手把手教你创建widget2

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

WidgetBundle 的用法

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

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

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

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

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13

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的属性,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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类中,传入不同的标题和描述,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13

import SwiftUI
import WidgetKit

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

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

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

创建一个支付宝Widget的组件

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

UI实现

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

左边的view代码如下:

1

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

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,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

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整体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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和右半边的按钮组结合起来,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61


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,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57


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中使用,如下:

1
2
3
4
5
6
7
8
9
10
11
12

import SwiftUI
import WidgetKit

@main
struct WidgetBundleDemo: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
AlipayWidget(title: "支付宝", desc: "这是支付宝Widget")
}
}

最终显示效果如下:

Widget Intent的使用

Static Intent Configuration

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

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

然后输入名字,需注意的是这里的target要主Target和Widget Target都要勾选,点击Create

打开新建的WidgetIntents,里面目前是空白,点击左下角的+,如下图

可以看到,有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建议或者快捷指令,再来勾选另外两个,尝试设置。

然后再来点击左下角的+,新增一个Enum,要注意的是Enum的类名不能和项目中Enum的名字一样,Enum是用来选择,点击编辑小组件后进行选择的,所以Enum中的内容是根据实际来定义的,添加case的displayName可以为中文,在这里就是和项目中ButtonType的内容一致,如下图。

Enum新增好了之后,再点击刚刚创建的StaticConfiguration,在Parameter部分点击新增,然后命名为btnType,修改Type为创建的Enum类型,取消勾选Resolvable,如下:

至此,Intent添加完成,运行,查看效果,发现,依旧没有编辑小组件入口,为啥呢?

虽然创建了Intent,但是并没有使用Intent的小组件,所以需要新增一个使用Intent的小组件,步骤如下:

新建StaticIntentWidgetProvider类,其中代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

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中添加显示,如下:

1
2
3
4
5
6
7
8
9
10
11
12

import SwiftUI
import WidgetKit

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

运行查看效果如下:

备注:

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

Dynamic Intent Configuration

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

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

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,来显示这个效果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

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

效果如下:

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

最终效果如下:

总结:

总结

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

补充:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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

参考