今是昨非

今是昨非

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

手把手教你创建widget2

手把手教你创建 widget2#

接上篇iOS Widget,这里介绍下 WidgetBundle 的用法和怎么做一个支付宝类似的 widget,上篇里把WidgetBundle写成了WidgetGroup,我的错。

WidgetBundle 的用法#

再来回顾一下什么情况下使用 WidgetBundle,上篇里介绍了supportedFamilies,可以设置 Widget 不同的尺寸,比如SmallMeidumLarge等,但是如果想要多个同尺寸的 Widget ,比如:想要两个Small尺寸的 Widget ,类似于下面东方财富 Widget 的效果,就需要用WidgetBundle,设置多个Widget

image image image

WidgetBundle的使用不难,下面来看下,上篇最后的代码(可以去https://github.com/mokong/WidgetAllInOne 下载,打开 Tutorial2),只显示了一个 Medium 尺寸的 Widget,这里修改为使用WidgetBundle显示两个Medium尺寸的 Widget。

新建 SwiftUIView,命名为WidgetBundleDemo,步骤如下:

  • 导入 WidgetKit
  • 修改 main 入口为 WidgetBundleDemo
  • 修改 WidgetBundleDemo 类型为 WidgetBundle
  • 修改 body 类型为 Widget

代码如下:


import SwiftUI
import WidgetKit

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

然后编译运行,failed,报错是xxx... error: 'main' attribute can only apply to one type in a module,意思是,一个 module 中只有有一个 @main,标记程序入口,所以需要移除多余的 @main,那哪里有呢,在 DemoWidget.swift 中,因为之前 main 入口是 DemoWidget,而现在的 main 入口是上面新建的 WidgetBundleDemo,所以需要移除 DemoWidget 中的 @main,移除后再次运行查看效果,发现添加 Widget 的预览中出现两个一模一样的 Medium 尺寸的 Widget。

Wait,上篇里说过,不同的 Widget 左右滑动的时候,上面的 title 和 desc 也是会跟着滑,为什么这里没有跟着滑?

确实是,嗯,应该是标题和内容一样的原因,一起来验证下,首先在 DemoWidget 中添加 title 和 desc 的属性,如下:


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

    var title: String = "My Widget"
    var desc: String = "This is an example widget."
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            DemoWidgetEntryView(entry: entry)
        }
        .configurationDisplayName(title) // 控制Widget预览中Title的显示
        .description(desc) // 控制Widget预览中Desc的显示
        .supportedFamilies([WidgetFamily.systemMedium])
    }
}

然后修改引用 DemoWidget 的地方,即 WidgetBundleDemo 类中,传入不同的标题和描述,如下:


import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        DemoWidget(title: "同步助手", desc: "这是QQ同步助手Widget")
        DemoWidget(title: "支付宝", desc: "这是支付宝Widget")
    }
}

再次运行,查看效果,就会发现 title 和 desc 也移动了,效果如下:

image

很简单是不是,WidgetBundle的使用就是上面的用法,但是这里需要说明一点,WidgetBundle中放的都是Widget,而每个Widget都有自己EntryProvider,即:WidgetBundle中的每个Widget都需要实现类似DemoWidget的方法和内容。

创建一个支付宝 Widget 的组件#

然后来实现如下支付宝小组件的效果:

image image image image

UI 实现#

从第一张图开始,先来拆分结构,分为左右两个 view,左边 view 是日历 + 天气,右边是 4 个功能入口,整体是一个 medium 尺寸的,然后来实现:

左边的 view 代码如下:

再来看右侧 4 个功能入口,再创建入口之前,先来考虑一下创建入口对应的 Item,这个 Item 要有哪些字段?显示需要图片和标题,点击后跳转需要链接,另外 SwiftUI 中 forEach 遍历需要 id。

然后再看下支付宝 widget,长按 -> 编辑小组件 -> 选择功能,能看到所有可选的功能,所以这里需要定义一个 type,用于枚举所有的功能,这里仅以 8 个来示例。资源文件放在AlipayWidgetImages文件夹下。

所以功能入口对应的单个 item 整体定义如下:


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 个按钮的 view,代码如下:


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)
                    }
                }
            }
        }
    }
}

然后创建左半边的 view,分为三个部分,天气、日期、和提示条,其中提示条单独封装。代码如下:

提示条 view:


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))
        }
    }
}

左半边 view 整体:


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()
        }
    }
}

最后把左半边 view 和右半边的按钮组结合起来,代码如下:


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,将 model 转为 view 需要的数据输出,代码如下:



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
    }
}

然后创建入口和 Provider,代码如下:



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())
        // refresh the data every two hours
        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 = "支付宝Widget"
    var desc: String = "支付宝Widget描述"
    
    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: "这是支付宝Widget")
    }
}

最终显示效果如下:

image

Widget Intent 的使用#

Static Intent Configuration#

接着上面的来看,对比支付宝 widget,可以看到支付宝 widget 长按后会出现编辑小组件的入口,而上面实现的没有,下面就来看下如何实现这个的显示。

编辑小组件入口的出现,需要创建 Intent,然后CMD+N新建,搜索intent,如下图,点击下一步

image

然后输入名字,需注意的是这里的 target 要主Target和Widget Target都要勾选,点击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建议或者快捷指令,再来勾选另外两个,尝试设置。
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中定义的按钮类型转为Widget中的按钮类型使用
    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())
        // refresh the data every two hours
        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

备注:

如果运行后,出现了编辑小组件,但是点击后,编辑界面为空,没有显示上面步骤二和三的图片,可以查看 Intent 是否勾选到主项目,如下:

image

Dynamic Intent Configuration#

继续对比支付宝的 Widget,可以看到上面的实现的Static Intent Configuration样式和支付宝的并不相同,支付宝的展示了多个,且每个点击选择的样式也和上面实现的样式不同,所以是怎么实现的呢?

答案是Dynamic Intent Configuration,接着往下看:

选中 Intent,点击添加New Intent,命名为DynamicConfiguration,修改 Category 为View,勾选Widgets,取消勾选Configurable in ShortcutsSuggestions,如下:

image

继续,点击添加New Type,命名为CustomButtonItem,用于DynamicConfiguration中添加Parameter时使用。在Properties中添加urlStrimageName属性为 String 类型,再添加buttonType属性是定义的 Enum 类型 ——ConfigrationButtonType,如下:

image

然后,为DynamicConfiguration添加Parameter,选择 Type 为CustomButtonItem,勾选Supports multiple valuesFixed SizeDynamic Options,取消勾选Resolvable,在Dynamic Options下的Prompt Label中输入文案请选择,Fixed Size` 中不同样式下的 Size 可修改。如下:

image

到这里,Intent 中的设置已经完成了,但是还有个问题,虽然 Intent 中勾选了Supports multiple values,数据从哪里来,点击编辑小组件后,默认展示的几个数据是哪里来的?点击单个按钮时,跳转后展示的所有的数据是哪里来的?

答案是Intent Extension,点击 File -> New -> Target,这里注意,这个是Target,搜索Intent,选择Intent Extension,如下,点击下一步,取消勾选Includes UI Extension,点击完成,如下:

image image

然后,选中.intentdefinition文件,Target MemberShip中把刚刚创建的 Target 也勾选上,如下图:

image

再然后,选中项目,选中WidgetIntentExtensionTarget,修改Deployment Info15.0,在Supported Intents中点击+,然后输入DynamicConfigurationIntent,如下:

image

由于Intent Extension中要使用 Widget 中的ButtonType,所以选中ButtonType所在的类,在Target MemberShip中勾选Intent Extension的 Target,如下:

然后选中IntentHandler,这里面就是数据来源的地方,修改内容如下:


import Intents

class IntentHandler: INExtension {
    
    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.
        
        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中定义的按钮类型转为Widget中的按钮类型使用
    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

到此差不多就完成了,对比支付宝 widget,可以看到,还有展示天气选择功能位置的样式,在DynamicConfigurationParameter中,直接添加两个属性,选择功能位置Enum类型,展示天气Bool类型,然后调整位置,把selectButtons属性移到最下方,详细步骤大家自己尝试一下。

最终效果如下:

image

总结:#

总结

完整项目代码已放在github: https://github.com/mokong/WidgetAllInOne

补充:

如果想要刷新 widget,widget 默认刷新时机是根据 timiline 设置来的,但是如果想要强制刷新,比如在 APP 中操作了,状态发生了改变,想要 widget 里吗刷新,可以用如下代码,在触发刷新的地方调用即可:


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()
    }
}

参考#

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。