今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

手取り足取りウィジェット2を作成する方法

ウィジェット 2 の作成方法#

前回のiOS Widgetに続き、ここでは WidgetBundle の使い方と、支付宝に似たウィジェットの作成方法について説明します。前回はWidgetBundleWidgetGroupと書いてしまいましたが、私のミスです。

WidgetBundle の使い方#

再度、WidgetBundleを使用する状況を振り返ります。前回はsupportedFamiliesを紹介しましたが、ウィジェットの異なるサイズを設定できます。例えば、SmallMediumLargeなどですが、同じサイズのウィジェットを複数作成したい場合、例えばSmallサイズのウィジェットを 2 つ作成したい場合、以下のように東方財富ウィジェットの効果を得るためにはWidgetBundleを使用して複数のWidgetを設定する必要があります。

image image image

WidgetBundleの使用は難しくありません。以下に示すように、前回の最後のコード(https://github.com/mokong/WidgetAllInOne からダウンロードし、Tutorial2 を開くことができます)では、1 つの Medium サイズのウィジェットしか表示されていません。ここでは、`WidgetBundle` を使用して 2 つの Medium サイズのウィジェットを表示するように変更します。

新しい SwiftUIView を作成し、WidgetBundleDemoと名付けます。手順は以下の通りです:

  • WidgetKit をインポート
  • main エントリを WidgetBundleDemo に変更
  • WidgetBundleDemo の型を WidgetBundle に変更
  • body の型を Widget に変更

コードは以下の通りです:


import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        DemoWidget()
        DemoWidget()
    }
}

その後、コンパイルして実行すると、失敗し、エラーはxxx... error: 'main' attribute can only apply to one type in a moduleです。これは、1 つのモジュールに 1 つの @main が必要であることを意味します。したがって、余分な @main を削除する必要があります。それはどこにあるのでしょうか?DemoWidget.swift の中にあります。以前は main エントリが DemoWidget でしたが、現在の main エントリは上で新しく作成した WidgetBundleDemo です。したがって、DemoWidget の @main を削除する必要があります。削除後、再度実行して効果を確認すると、ウィジェットのプレビューに 2 つの全く同じ Medium サイズのウィジェットが表示されることがわかります。

待ってください、前回言ったように、異なるウィジェットを左右にスワイプすると、上のタイトルと説明も一緒にスライドします。なぜここでは一緒にスライドしないのでしょうか?

確かに、うん、タイトルと内容が同じ理由だと思います。一緒に確認してみましょう。まず、DemoWidget に title と desc の属性を追加します。以下のように:


struct DemoWidget: Widget {
    let kind: String = "DemoWidget"

    var title: String = "私のウィジェット"
    var desc: String = "これは例のウィジェットです。"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            DemoWidgetEntryView(entry: entry)
        }
        .configurationDisplayName(title) // ウィジェットプレビューでのタイトルの表示を制御
        .description(desc) // ウィジェットプレビューでの説明の表示を制御
        .supportedFamilies([WidgetFamily.systemMedium])
    }
}

次に、DemoWidget を参照している場所、つまり WidgetBundleDemo クラスの中で、異なるタイトルと説明を渡します。以下のように:


import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        DemoWidget(title: "同期アシスタント", desc: "これはQQ同期アシスタントウィジェットです")
        DemoWidget(title: "支付宝", desc: "これは支付宝ウィジェットです")
    }
}

再度実行して効果を確認すると、タイトルと説明も移動することがわかります。効果は以下の通りです:

image

とても簡単ですね。WidgetBundleの使用は上記の通りですが、ここで一つ説明しておくべきことがあります。WidgetBundleに含まれるのはすべてWidgetであり、各WidgetにはそれぞれのEntryProviderがあります。つまり、WidgetBundle内の各Widgetは、DemoWidgetのようなメソッドと内容を実装する必要があります。

支付宝ウィジェットのコンポーネントを作成する#

次に、以下のような支付宝の小コンポーネントの効果を実現します:

image image image image

UI の実装#

最初の画像から始めて、構造を分解し、左右 2 つのビューに分けます。左側のビューはカレンダー + 天気、右側は 4 つの機能エントリで、全体で Medium サイズです。それでは実装してみましょう:

左側のビューのコードは以下の通りです:

次に、右側の 4 つの機能エントリを見てみましょう。エントリを作成する前に、エントリに対応するアイテムを考えてみましょう。このアイテムにはどのようなフィールドが必要ですか?表示には画像とタイトルが必要で、クリック後にリンクにジャンプする必要があります。また、SwiftUI の forEach で反復するには id が必要です。

次に、支付宝ウィジェットを見てみましょう。長押し -> 小コンポーネントを編集 -> 機能を選択すると、すべての選択可能な機能が表示されます。したがって、ここではすべての機能を列挙するための型を定義する必要があります。ここでは 8 つの機能を例として示します。リソースファイルはAlipayWidgetImagesフォルダに配置します。

したがって、機能エントリに対応する単一のアイテム全体の定義は以下の通りです:


import Foundation

public enum ButtonType: String {
    case Scan = "スキャン"
    case pay = "支払い"
    case healthCode = "健康コード"
    case travelCode = "旅行カード"
    case trip = "旅行"
    case stuck = "カードパック"
    case memberpoints = "会員ポイント"
    case yuebao = "残高宝"
}

extension ButtonType: Identifiable {
    public var id: String {
        return rawValue
    }
    
    public var displayName: String {
        return rawValue
    }
    
    public var urlStr: String {
        let imageUrl: (image: String, url: String) = imageAndUrl(from: self)
        return imageUrl.url
    }
    
    public var imageName: String {
        let imageUrl: (image: String, url: String) = imageAndUrl(from: self)
        return imageUrl.image
    }
    
    /// return (image, url)
    func imageAndUrl(from type: ButtonType) -> (String, String) {
        switch self {
        case .Scan:
            return ("widget_scan", "https://www.baidu.com/")
        case .pay:
            return ("widget_pay", "https://www.baidu.com/")
        case .healthCode:
            return ("widget_healthCode", "https://www.baidu.com/")
        case .travelCode:
            return ("widget_travelCode", "https://www.baidu.com/")
        case .trip:
            return ("widget_trip", "https://www.baidu.com/")
        case .stuck:
            return ("widget_stuck", "https://www.baidu.com/")
        case .memberpoints:
            return ("widget_memberpoints", "https://www.baidu.com/")
        case .yuebao:
            return ("widget_yuebao", "https://www.baidu.com/")
        }
    }
}

struct AlipayWidgetButtonItem {
    var title: String
    var imageName: String
    var urlStr: String
    var id: String {
        return title
    }
    
    static func generateWidgetBtnItem(from originalItem: AlipayWidgetButtonItem) -> AlipayWidgetButtonItem {
        let newItem = AlipayWidgetButtonItem(title: originalItem.title,
                                             imageName: originalItem.imageName,
                                             urlStr: originalItem.urlStr)
        return newItem
    }
}

次に、右半分のボタングループの実装を見て、AlipayWidgetGroupButtons.swiftを作成し、4 つのボタンを表示するビューをカプセル化します。コードは以下の通りです:


import SwiftUI

struct AlipayWidgetGroupButtons: View {
    var buttonList: [[AlipayWidgetButtonItem]]
    
    var body: some View {
        VStack() {
            ForEach(0..<buttonList.count, id: \.self) { index in
                HStack {
                    ForEach(buttonList[index], id: \.id) { buttonItem in
                        AlipayWidgetButton(buttonItem: buttonItem)
                    }
                }
            }
        }
    }
}

次に、左半分のビューを作成し、天気、日付、そしてヒントバーの 3 つの部分に分けます。ヒントバーは別にカプセル化します。コードは以下の通りです:

ヒントバーのビュー:


import SwiftUI

struct AlipayWidgetLunarView: View {
    var body: some View {
        ZStack(alignment: .leading) {
            
            ZStack {
                AliPayLunarSubview()
                    .hidden()
            }
            .background(.white)
            .opacity(0.27)
            .cornerRadius(2.0)
            
            AliPayLunarSubview()
        }
    }
}

struct AliPayLunarSubview: View {
    var body: some View {
        HStack {
            Image("alipay")
                .resizable()
                .frame(width: 16.0, height: 16.0)
                .padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 0))

            Text("支付宝")
                .font(Font.custom("Montserrat-Bold", size: 13.0))
                .minimumScaleFactor(0.5)
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 4.0, leading: -7.0, bottom: 4.0, trailing: 0.0))

            Text("今日宜")
                .font(Font.system(size: 10.0))
                .minimumScaleFactor(0.5)
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 0.0, leading: -5.0, bottom: 0.0, trailing: 0.0))

            Image("right_Arrow")
                .resizable()
                .frame(width: 10, height: 10)
                .padding(EdgeInsets(top: 0.0, leading: -7.0, bottom: 0.0, trailing: 5.0))
        }
    }
}

左半分のビュー全体:


import SwiftUI

struct AlipayWidgetWeatherDateView: View {    
    var body: some View {
        VStack(alignment: .leading) {
            Spacer()

            Text("多雲 28℃")
                .font(.title)
                .foregroundColor(.white)
                .fontWeight(.semibold)
                .minimumScaleFactor(0.5)
                .padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 4.0, trailing: 0.0))

            Text("06/09 木曜日 上海市")
                .lineLimit(1)
                .font(.body)
                .foregroundColor(.white)
                .minimumScaleFactor(0.5)
                .padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 4.0, trailing: 0.0))

            AlipayWidgetLunarView()

            Spacer()
        }
    }
}

最後に、左半分のビューと右半分のボタングループを組み合わせます。コードは以下の通りです:


struct AlipayWidgetMeidumView: View {
    @ObservedObject var mediumItem: AliPayWidgetMediumItem

    var body: some View {
        ZStack {
            // 背景画像
            Image("widget_background_test")
                .resizable()
            HStack {
                AlipayWidgetWeatherDateView()
                
                Spacer()
                
                AlipayWidgetGroupButtons(buttonList: mediumItem.dataButtonList())
            }
            .padding()
        }
    }
}

ここで定義された AliPayWidgetMediumItem は、モデルをビューに変換するためのデータ出力を持つ VM のようなものです。コードは以下の通りです:



class AliPayWidgetMediumItem: ObservableObject {
    @Published private var groupButtons: [[AlipayWidgetButtonItem]] = [[]]
    
    init() {
        self.groupButtons = AliPayWidgetMediumItem.createMeidumWidgetGroupButtons()
    }
    
    init(with widgetGroupButtons: [AlipayWidgetButtonItem]?) {
        guard let items = widgetGroupButtons else {
            self.groupButtons = AliPayWidgetMediumItem.createMeidumWidgetGroupButtons()
            return
        }
        
        var list: [[AlipayWidgetButtonItem]] = [[]]
        var rowList: [AlipayWidgetButtonItem] = []
        for i in 0..<items.count {
            let originalItem = items[i]
            let newItem = AlipayWidgetButtonItem.generateWidgetBtnItem(from: originalItem)
            if i != 0 && i % 2 == 0 {
                list.append(rowList)
                rowList = []
            }
            rowList.append(newItem)
        }
        
        if rowList.count > 0 {
            list.append(rowList)
        }
        self.groupButtons = list
    }
    
    private static func createMeidumWidgetGroupButtons() -> [[AlipayWidgetButtonItem]] {
        let scanType = ButtonType.Scan
        let scanItem = AlipayWidgetButtonItem(title: scanType.rawValue,
                                              imageName: scanType.imageName,
                                              urlStr: scanType.urlStr)
        
        let payType = ButtonType.pay
        let payItem = AlipayWidgetButtonItem(title: payType.rawValue,
                                             imageName: payType.imageName,
                                             urlStr: payType.urlStr)
        
        let healthCodeType = ButtonType.healthCode
        let healthCodeItem = AlipayWidgetButtonItem(title: healthCodeType.rawValue,
                                                    imageName: healthCodeType.imageName,
                                                    urlStr: healthCodeType.urlStr)

        let travelCodeType = ButtonType.travelCode
        let travelCodeItem = AlipayWidgetButtonItem(title: travelCodeType.rawValue,
                                                    imageName: travelCodeType.imageName,
                                                    urlStr: travelCodeType.urlStr)
        return [[scanItem, payItem], [healthCodeItem, travelCodeItem]]
    }
    
    func dataButtonList() -> [[AlipayWidgetButtonItem]] {
        return groupButtons
    }
}

次に、エントリとプロバイダーを作成します。コードは以下の通りです:



import WidgetKit
import SwiftUI

struct AlipayWidgetProvider: TimelineProvider {
    typealias Entry = AlipayWidgetEntry
    
    func placeholder(in context: Context) -> AlipayWidgetEntry {
        AlipayWidgetEntry(date: Date())
    }
    
    func getSnapshot(in context: Context, completion: @escaping (AlipayWidgetEntry) -> Void) {
        let entry = AlipayWidgetEntry(date: Date())
        completion(entry)
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<AlipayWidgetEntry>) -> Void) {
        let entry = AlipayWidgetEntry(date: Date())
        // 2時間ごとにデータを更新
        let expireDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], policy: .after(expireDate))
        completion(timeline)
    }
}

struct AlipayWidgetEntry: TimelineEntry {
    let date: Date
    
    
}

struct AlipayWidgetEntryView: View {
    var entry: AlipayWidgetProvider.Entry
    let mediumItem = AliPayWidgetMediumItem()
    
    var body: some View {
        AlipayWidgetMeidumView(mediumItem: mediumItem)
    }
}

struct AlipayWidget: Widget {
    let kind: String = "AlipayWidget"
    
    var title: String = "支付宝ウィジェット"
    var desc: String = "支付宝ウィジェットの説明"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: AlipayWidgetProvider()) { entry in
            AlipayWidgetEntryView(entry: entry)
        }
        .configurationDisplayName(title)
        .description(desc)
        .supportedFamilies([.systemMedium])
    }
}

最後に、WidgetBundle で使用します。以下のように:


import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        AlipayWidget(title: "支付宝", desc: "これは支付宝ウィジェットです")
    }
}

最終的な表示効果は以下の通りです:

image

Widget Intent の使用#

Static Intent Configuration#

前回の内容を続けて、支付宝ウィジェットと比較すると、支付宝ウィジェットは長押し後に小コンポーネントを編集のエントリが表示されますが、上記の実装にはそれがありません。次に、その表示を実現する方法を見ていきましょう。

小コンポーネントを編集のエントリを表示するには、Intent を作成する必要があります。CMD+Nで新規作成し、intentを検索します。以下の図のように、次へをクリックします。

image

次に名前を入力します。ここで注意が必要なのは、ターゲットを主ターゲットとウィジェットターゲットの両方にチェックを入れる必要があることです。Createをクリックします。

新しく作成した WidgetIntents を開くと、現在は空白です。左下の+をクリックします。以下の図のように。

image

4 つのボタンが選択できることがわかります。それぞれNew IntentCustomize System IntentNew EnumNew Typeです。ここではNew Intentを選択します。

Ps: いくつかのエントリの中でCustomize System Intentはあまり使われず、New Intentはほぼ必ず追加する必要があります。New Enumは新しい列挙型を作成しますが、この列挙型はコード内の列挙型の名前と同じであってはならないため、使用時に変換する必要があります。New Typeはクラスを新しく作成します。後で例を示します。

New Intentをクリックした後、いくつかの点に注意が必要です:

  • Intent の名前を変更する必要があります。デフォルトはIntentですが、プロジェクト内には複数のIntentファイルがある可能性があるため、名前を変更する必要があります。名前を変更する際、プロジェクト内で使用する際には自動的に変更した名前の後にIntentが追加されます。例えば、XXXに変更した場合、プロジェクト内で使用する際の名前はXXXIntentになりますので、重複しないように注意が必要です。
  • 次に、Intent のCategoryを変更します。ここではViewに変更します。他のいくつかのタイプについても興味があれば試してみてください。下のtitleもファイル名に変更します。
  • 次に、下の内容のチェックを外します。デフォルトではConfigurable in ShortcutsSuggestionsがチェックされていますが、ここではこれらのチェックを外し、Widgetsにチェックを入れます。意味は明確です。チェックが多いほど設定が多くなるため、最初はWidgetsにチェックを入れるだけで十分です。後で慣れてきたら、Siriの提案ショートカットを設定したい場合は、他の 2 つにチェックを入れて試してみてください。
image

次に、左下の+をクリックして、新しいEnumを追加します。ここで注意が必要なのは、Enum のクラス名がプロジェクト内の Enum の名前と同じであってはならないことです。Enum は選択のために使用され、クリックして小コンポーネントを編集した後に選択されるため、Enum 内の内容は実際に基づいて定義されます。追加する case の displayName は日本語にすることができます。ここではプロジェクト内のButtonTypeの内容と一致させます。以下の図のように。

image

Enum が追加されたら、次に作成したStaticConfigurationをクリックし、Parameter 部分で新しいものを追加します。btnTypeと名付け、Type を作成した Enum 型に変更し、Resolvableのチェックを外します。以下のように:

image

これで Intent の追加が完了しました。実行して効果を確認すると、依然として小コンポーネントを編集のエントリが表示されません。なぜでしょうか?

Intent を作成しましたが、Intent を使用するウィジェットがないためです。したがって、Intent を使用するウィジェットを新たに追加する必要があります。手順は以下の通りです:

StaticIntentWidgetProviderクラスを新規作成し、コードは以下の通りです:


import Foundation
import WidgetKit
import SwiftUI

struct StaticIntentWidgetProvider: IntentTimelineProvider {
    
    typealias Entry = StaticIntentWidgetEntry
    typealias Intent = StaticConfigurationIntent
    
    // Intentで定義されたボタンタイプをウィジェット内で使用するボタンタイプに変換します
    func buttonType(from configuration: Intent) -> ButtonType {
        switch configuration.btnType {
        case .scan:
            return .scan
        case .pay:
            return .pay
        case .healthCode:
            return .healthCode
        case .travelCode:
            return .travelCode
        case .trip:
            return .trip
        case .stuck:
            return .stuck
        case .memberpoints:
            return .memberpoints
        case .yuebao:
            return .yuebao
        case .unknown:
            return .unknown
        }
    }
    
    func placeholder(in context: Context) -> StaticIntentWidgetEntry {
        StaticIntentWidgetEntry(date: Date())
    }
    
    func getSnapshot(for configuration: StaticConfigurationIntent, in context: Context, completion: @escaping (StaticIntentWidgetEntry) -> Void) {
        let buttonType = buttonType(from: configuration)
        
        let entry = StaticIntentWidgetEntry(date: Date())
        completion(entry)
    }
    
    func getTimeline(for configuration: StaticConfigurationIntent, in context: Context, completion: @escaping (Timeline<StaticIntentWidgetEntry>) -> Void) {
        let entry = StaticIntentWidgetEntry(date: Date())
        // 2時間ごとにデータを更新
        let expireDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], policy: .after(expireDate))
        completion(timeline)
    }
}

struct StaticIntentWidgetEntry: TimelineEntry {
    let date: Date
    
}

struct StaticIntentWidgetEntryView: View {
    var entry: StaticIntentWidgetProvider.Entry
    let mediumItem = AliPayWidgetMediumItem()
    
    var body: some View {
        AlipayWidgetMeidumView(mediumItem: mediumItem)
    }
}

struct StaticIntentWidget: Widget {

    let kind: String = "StaticIntentWidget"
    
    var title: String = "StaticIntentWidget"
    var desc: String = "StaticIntentWidgetの説明"
    
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind,
                            intent: StaticConfigurationIntent.self,
                            provider: StaticIntentWidgetProvider()) { entry in
            StaticIntentWidgetEntryView(entry: entry)
        }
        .configurationDisplayName(title)
        .description(desc)
        .supportedFamilies([.systemMedium])
    }
}

WidgetBundleに表示を追加します。以下のように:


import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        StaticIntentWidget()
    }
}

実行して効果を確認すると、以下のようになります:

image image image

備考:

もし実行後に小コンポーネントを編集が表示され、クリック後に編集画面が空白で、上記の手順 2 と 3 の画像が表示されない場合は、Intent が主プロジェクトにチェックされているか確認してください。以下のように:

image

Dynamic Intent Configuration#

支付宝のウィジェットと比較すると、上記の実装のStatic Intent Configurationスタイルは支付宝のものとは異なります。支付宝は複数のウィジェットを表示し、各クリック選択のスタイルも異なります。これはどうやって実現されているのでしょうか?

答えはDynamic Intent Configurationです。続けて見ていきましょう:

Intent を選択し、New Intentを追加します。名前をDynamicConfigurationに変更し、CategoryViewに変更し、Widgetsにチェックを入れ、Configurable in ShortcutsSuggestionsのチェックを外します。以下のように:

image

次に、New Typeを追加し、名前をCustomButtonItemにします。これはDynamicConfigurationParameterを追加する際に使用します。PropertiesurlStrimageNameの属性を String 型で追加し、buttonType属性は定義した Enum 型にします。以下のように:

image

次に、DynamicConfigurationParameterを追加します。Type をCustomButtonItemに選択し、Supports multiple valuesFixed SizeDynamic Optionsにチェックを入れ、Resolvableのチェックを外します。Dynamic Optionsの下のPrompt Label選択してくださいと入力し、Fixed Sizeの異なるスタイルのサイズを変更できます。以下のように:

image

これで Intent 内の設定が完了しましたが、もう一つの問題があります。Intent でSupports multiple valuesにチェックを入れましたが、データはどこから来るのでしょうか?小コンポーネントを編集した後にデフォルトで表示されるいくつかのデータはどこから来るのでしょうか?各ボタンをクリックしたときに表示されるすべてのデータはどこから来るのでしょうか?

答えはIntent Extensionです。ファイル -> 新規 -> ターゲットをクリックします。ここで注意が必要なのは、これはターゲットであることです。Intentを検索し、Intent Extensionを選択します。以下のように、次へをクリックし、Includes UI Extensionのチェックを外して完了します。

image image

次に、.intentdefinitionファイルを選択し、Target MemberShipで先ほど作成したターゲットにもチェックを入れます。以下のように:

image

次に、プロジェクトを選択し、WidgetIntentExtensionターゲットを選択し、Deployment Info15.0に変更し、Supported Intents+をクリックし、DynamicConfigurationIntentを入力します。以下のように:

image

Intent Extension内でButtonTypeを使用する必要があるため、ButtonTypeが含まれているクラスを選択し、Target MemberShipIntent Extensionのターゲットにチェックを入れます。以下のように:

次に、IntentHandlerを選択します。ここがデータの供給元です。内容を以下のように変更します:


import Intents

class IntentHandler: INExtension {
    
    override func handler(for intent: INIntent) -> Any {
        // これはデフォルトの実装です。異なるオブジェクトが異なるインテントを処理する場合は、
        // これをオーバーライドして、特定のインテントに対して処理したいハンドラを返すことができます。
        
        return self
    }
    
}

extension IntentHandler: DynamicConfigurationIntentHandling {
    func provideSelectButtonsOptionsCollection(for intent: DynamicConfigurationIntent, searchTerm: String?, with completion: @escaping (INObjectCollection<CustomButtonItem>?, Error?) -> Void) {
        let typeList: [ConfigrationButtonType] = [.scan, .pay, .healthCode, .trip, .travelCode, .stuck, .memberpoints, .yuebao]
        let itemList = generateItemList(from: typeList)
        completion(INObjectCollection(items: itemList), nil)
    }
    
    func defaultSelectButtons(for intent: DynamicConfigurationIntent) -> [CustomButtonItem]? {
        let defaultBtnTypeList: [ConfigrationButtonType] = [.scan, .pay, .healthCode, .trip]
        let defaultItemList = generateItemList(from: defaultBtnTypeList)
        return defaultItemList
    }
    
    fileprivate func generateItemList(from typeList: [ConfigrationButtonType]) -> [CustomButtonItem] {
        let defaultItemList = typeList.map({
            let formatBtnType = buttonType(from: $0)
            let item = CustomButtonItem(identifier: formatBtnType.id,
                                        display: formatBtnType.displayName)
            item.buttonType = $0
            item.urlStr = formatBtnType.urlStr
            item.imageName = formatBtnType.imageName
            return item
        })
        return defaultItemList
    }
    
    // Intentで定義されたボタンタイプをウィジェット内で使用するボタンタイプに変換します
    func buttonType(from configurationType: ConfigrationButtonType) -> ButtonType {
        switch configurationType {
        case .scan:
            return .scan
        case .pay:
            return .pay
        case .healthCode:
            return .healthCode
        case .travelCode:
            return .travelCode
        case .trip:
            return .trip
        case .stuck:
            return .stuck
        case .memberpoints:
            return .memberpoints
        case .yuebao:
            return .yuebao
        case .unknown:
            return .unknown
        }
    }
}

最後に、新しいIntentTimelineProviderを作成してこの効果を表示します。コードは以下の通りです:


import Foundation
import WidgetKit
import SwiftUI

struct DynamicIntentWidgetProvider: IntentTimelineProvider {
    typealias Entry = DynamicIntentWidgetEntry
    typealias Intent = DynamicConfigurationIntent

    func placeholder(in context: Context) -> DynamicIntentWidgetEntry {
        DynamicIntentWidgetEntry(date: Date())
    }
    
    func getSnapshot(for configuration: DynamicConfigurationIntent, in context: Context, completion: @escaping (DynamicIntentWidgetEntry) -> Void) {
        let entry = DynamicIntentWidgetEntry(date: Date(), groupBtns: configuration.selectButtons)
        completion(entry)
    }
    
    func getTimeline(for configuration: DynamicConfigurationIntent, in context: Context, completion: @escaping (Timeline<DynamicIntentWidgetEntry>) -> Void) {
        let entry = DynamicIntentWidgetEntry(date: Date(), groupBtns: configuration.selectButtons)
        let expireDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
        let timeline = Timeline(entries: [entry], policy: .after(expireDate))
        completion(timeline)
    }
}


struct DynamicIntentWidgetEntry: TimelineEntry {
    let date: Date
    var groupBtns: [CustomButtonItem]?
}

struct DynamicIntentWidgetEntryView: View {
    var entry: DynamicIntentWidgetProvider.Entry
    
    var body: some View {
        AlipayWidgetMeidumView(mediumItem: AliPayWidgetMediumItem(with: entry.groupBtns))
    }
}

struct DynamicIntentWidget: Widget {

    let kind: String = "DynamicIntentWidget"
    
    var title: String = "DynamicIntentWidget"
    var desc: String = "DynamicIntentWidgetの説明"
    
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind,
                            intent: DynamicConfigurationIntent.self,
                            provider: DynamicIntentWidgetProvider()) { entry in
            DynamicIntentWidgetEntryView(entry: entry)
        }
        .configurationDisplayName(title)
        .description(desc)
        .supportedFamilies([.systemMedium])
    }
}

効果は以下の通りです:

image

これでほぼ完成です。支付宝ウィジェットと比較すると、天気の表示機能の位置の選択のスタイルがあります。DynamicConfigurationParameterに、機能の位置の選択を Enum 型、天気の表示を Bool 型として直接追加し、位置を調整し、selectButtons属性を最下部に移動します。詳細な手順は皆さん自身で試してみてください。

最終的な効果は以下の通りです:

image

まとめ:#

まとめ

完全なプロジェクトコードはgithubにアップロードされています: https://github.com/mokong/WidgetAllInOne

補足:

ウィジェットを更新したい場合、ウィジェットのデフォルトの更新タイミングはタイムライン設定に基づいていますが、強制的に更新したい場合、例えばアプリ内で操作を行い、状態が変化した場合、ウィジェットを更新するには以下のコードを使用し、更新をトリガーする場所で呼び出すことができます:


import WidgetKit

@objc
class WPSWidgetCenter: NSObject {
    @available(iOS 14, *)
    static func reloadTimelines(_ kind: String) {
        WidgetCenter.shared.reloadTimelines(ofKind: kind)
    }
    
    @available(iOS 14, *)
    @objc static func reloadAllTimelines() {
        WidgetCenter.shared.reloadAllTimelines()
    }
}

参考#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。