关于widgets的一些特性描述以及应用场景可以参考上篇文章 iOS14 Widgets新特性
开发思路浅谈
思维导图我们将通过如下模块解析小组件开发
- App Extension
- App Groups 数据通信
- 文件共享及pods共享
- OC和Swift混编
- Widget 核心代码解析
- Timeline刷新机制
- 获取数据
- SwiftUI 构建组件
- 跳转至App
- 动态配置小组件
开发须知:
-
iOS14系统
以上才支持 - 它只能使用
SwiftUI
进行开发,所以需要SwiftUI和Swift基础。(如果主项目为OC语言需要做语言桥接) - Widget只支持3种尺寸
systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)
- 不支持动画(包括视频),仅支持静态页面展示。
- 更新频率由系统通过机器学习来动态分配。
- 不支持拖拽、滚动等复杂的交互,不支持 Switch 等控件。
- 用户
点击 Widget
一定会跳转到 app
。 -
无法主动更新数据
(重点,让所有交互型的逻辑都无法实现) - 每一个 Widget都有其对应的独立 TimeLine,相互独立,互不干扰。
- 其他特性通过代码和下面的实际开发中描述。
App Extension
如果你已经有了 App Extension 的开发经验,可以略过这个章节。
首先需要明确的是小组件是一个独立于 App 环境(即 App Extension),小组件的生命周期/存储空间/运行进程都和 App 不同。所以我们需要引入这个环境下的一些基础设施,比如网络通信框架,图片缓存框架,数据持久化框架等。
按照苹果的说法:App Extension 可以将自定义功能和内容扩展到应用程序之外,并在用户与其他应用程序或系统交互时向用户提供。例如,您的应用可以在主屏幕上显示为小部件。也就是说小组件是一种 App Extension,小组件的开发工作,基本都在 App Extension 的环境中。
App 和 App Extension有什么关系?
本质上是两个独立的程序,你的主程序既不可以访问 App Extension 的代码,也不可以访问其存储空间,这完完全全就是两个进程、两个程序。App Extension 依赖你的 App 本体作为载体,如果将 App 卸载,那么 App Extension 也不会存在于系统中了。而且 App Extension 的生命周期大多数都作用于特定的领域,根据用户触发的事件由系统控制来管理。
创建 App Extension 和配置文件
下面我们开始进行widget开发
首先按照下图步骤在demo里添加一个widget
添加widget
弹出如下窗口后设置名称后创建
创建后生成如下小组件的Extension,他与主App在不同的target(所以内存和应用内的数据不共享,数据共享方法下面介绍)
生成小组件的Extension后,可以在运行栏选择小组件运行(也可以通过运行主app后在屏幕上长按后左上角添加小组件)
运行会自动生成一个小组件
默认小组件
断点调试
不同target断点调试需要调整到对应的模块,比如运行了widgetExtension,想要断点需要设置Debug -> Attach to Process 或 Attach to Process by PID or Name
里的widgetExtension,就能顺利断点调试啦
App Groups 数据通信
因为 App 和 App Extension 是不能直接通讯的,所以需要共享信息时,需要使用 App Groups 来进行通讯。App Groups 有两种共享数据的方式,NSUserDefaults
和NSFileManager
。
创建完Extension后我们要去创建该Extension对应的证书(每个Extension都需要独立的证书),需要前往苹果开发者中心,手动创建 App Extension
的 App ID 和配置文件
。如果需要数据共享,需要在创建证书时勾选App Groups。
在 Widget Extension 的 Target 中添加 App Groups,并保持和主程序相同的 App Group ID 。如果主程序中还没有App Groups,则需要这个时候同时增加主 App 的 App Groups,并定义好 Group ID。
添加App Group
添加完App Groups后会出现对应环境的entitlements文件,可以在Code Signing Entitlements
设置对应的路径(如果文件有移动也需要修改对应的路径设置)
之后再entitlements文件里设置Group ID。
NSUserDefaults 共享数据
使用 NSUserDefaults 的 initWithSuiteName: 初始化实例。 suitename传入之前定义好的 App GroupID。
- (instancetype)initWithSuiteName:(NSString *)suitename;
之后即可使用NSUserDefaults的实例的存取方法来储存和获取共享数据了。比如我们需要和小组件共享当前的用户信息,则可以如下操作。
//使用 Groups ID 初始化一个供 App Groups 使用的 NSUserDefaults 对象
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"widgetDemo.AppGroupID"];
//写入数据
[userDefaults setValue:@"123456789" forKey:@"userID"];
//读取数据
NSString *userIDStr = [userDefaults valueForKey:@"userID"];
NSFileManager 共享数据
使用 NSFileManager 的 containerURLForSecurityApplicationGroupIdentifier: 获取 App Group 共享的储存空间地址,即可进行文件的存取操作。
- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier;
存储NSData数据
//将图片存在AppGroup里
//获取App Group的共享目录
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"widgetDemo.AppGroupID"];
//拼接详细路径
containerURL = [containerURL URLByAppendingPathComponent:[NSString stringWithFormat:@"%@",@"lol1"]];
//获取到数据
NSData *imageData = [NSData dataWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"lol1" ofType:@"jpg"]];
//写入文件
[imageData writeToURL:containerURL atomically:YES];
获取存储的NSData数据
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"widgetDemo.AppGroupID"];
//拼接详细路径
containerURL = [containerURL URLByAppendingPathComponent:[NSString stringWithFormat:@"%@",deviceUUID]];
//获取到数据
NSData *resultData = [NSData dataWithContentsOfFile:containerURL.path];
文件共享及pods共享
-
文件共享
在需要跨targets的文件勾选对应的target就可以完成共享,但要注意是否有不兼容的情况,比如 swiftUI不支持UIKit 的组件,勾选了就会报错。
-
pods共享
正常使用下widget中无法使用pods导入的第三方SDK如Masonry等,会造成布局等极其不便,因此需要共享pods,在Podfile中需要另设置并重新install。(如果是手动导入的framework,需要在widget的target下的buid settings里手动添加对应需要的库和framework索引)
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '9.0'
inhibit_all_warnings!
use_frameworks!
#共享HandyJSON
def share_pods
pod 'HandyJSON'
end
target "targetName" do
pod 'Alamofire'
share_pods
end
target "widgetTargetName" do
share_pods
end
完成后即可使用pods中的第三方SDK了
OC和Swift混编
Swift调用OC代码
不需要在Swift中import OC的类,统一在桥接文件
Swift_OC_Header.h
(自定义名称)中导入需要暴露给Swift的OC类即可在Swift中访问。
因为工程是OC项目,并且需要使用主APP里的一些接口,这里要创建Swift调用OC的桥接文件,Calculator
文件作为主APP的接口类,因为是OC工程,不会自动弹出桥接类系统提示,需要手动创建一个Header File设置为桥接文件。(如果swift功能会自动弹出桥接文件创建提示,点击创建就好了)
手动建完后,使用
#import
来引用oc库,如下图。(这里注意不会有代码提示,要完全手打出来,最好把类名复制粘贴)之后在小组件的targets里设置
objective-C Bridging Header
为桥接文件的路径,如下图这样就设置完毕了,可以直接在swift里调用OC的接口类方法啦。
let vc = Calculator()
OC调用Swift代码
ps:项目里在widget刷新时候需要用到oc调用swift里的WidgetCenter.shared.reloadAllTimelines()
首先在OC工程新建一个swift文件,会出现提示,点击蓝色创建bridging按钮(如果已经有了桥接可以不创建,在上面说的Bridging Header
里设置好对应路径即可)
之后要在setting里设置
Defines Module
值为Yes之后在使用swift文件的地方,导入头文件工程名-Swift.h,以我的项目为例
#import "widgetDemo-Swift.h"
之后就可以正常调用啦!
工程名如下图,也可以自由修改
工程名
如果出现了如下报错
SWIFT_VERSION
在setting里修改Swift版本如下图即可
修改Swift版本
Widget 核心代码解析
先分析自动生成的代码,代码较多,后续的解析通过注释解读代码
首先我们看到在widget.swift
中有一个@main
函数,底下的struct widget
方法代码就是我们生成widget后首先运行的代码
//主入口,运行小组件会首先进入该方法,只会运行一次,初始化完成所有小组件内容
@main
struct widget: Widget {
let kind: String = "widget"
var body: some WidgetConfiguration {
//可编辑内容为IntentConfiguration,不可编辑为StaticConfiguration
//注册了Provider的block回调,当数据刷新后block回来带着数据传递给widgetEntryView
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
widgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget") //添加小组件时界面显示的黑色文案(字符串可国际化)
.description("This is an example widget.") //添加小组件时显示的灰色文案
}
}
数据模型,是每次刷新后让view显示的数据内容,Provider得到数据后转化为该模型block返回
// 渲染 Widget 所需的数据模型,需要遵守TimelineEntry协议
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
}
要显示的view,方法里设置你view的样式
// 屏幕上 Widget 显示的内容,可以针对不同尺寸的 Widget 设置不同的 View。
struct widgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
控件刷新及初始化等方法(小组件核心方法
)
//为小组件展示提供一切必要信息的结构体,遵守TimelineProvider协议,产生一个时间线,告诉 WidgetKit 何时渲染与刷新 Widget,时间线包含一个你定义的自定义TimelineEntry类型。时间线条目标识了你希望WidgetKit更新Widget内容的日期。在自定义类型中包含你的Widget的视图需要渲染的属性。
struct Provider: IntentTimelineProvider {
// 占位视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationIntent())
}
// 编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法(并不是每一次调用都会触发该方法,只有第一次展示或者到了固定的时间周期才会刷新,期间系统会缓存你上一次展示的内容展示出来)
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), configuration: configuration)
completion(entry)
}
// 小组件刷新时候走的方法,第一次加载也会触发该方法。
// 被动刷新触发,由系统(可以设置时间)控制刷新频率,到了设定时间会刷新小组件。也可以使用手动刷新reload触发(需要主app在运行时候调用)
// 进行数据的预处理,转化成Entry,最后completion返回。
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
// 多长时间后后刷新
for hourOffset in 0 ..< 5 {
//.hour 是小时,hourOffset默认是5,结合起来就是5小时刷新一次,这个时间可以修改,目前测试结果最快为5分钟刷新一次,设置更短的时间也是失效的。
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
以上为系统自动生成的小组件代码,只能加载一种小组件。
如果需要不同类型的小组件,需要修改@main函数的内容,将继承改为WidgetBundle
,WidgetConfiguration
改为Widget
,block里面的改为多个Widget
,即可实现多个组件功能,但这里注意最多只能设置5种小组件
,每个小组件有大中小三种尺寸(共享同一套数据),就是最多有15个小组件。
struct UserWidget: WidgetBundle {
let kind: String = "Widget"
@WidgetBundleBuilder
var body: some Widget {
FourToolWidget()
NightFourToolWidget()
OneClickRunWidget()
}
}
下面介绍一下设置placeholder、getSnapshot、getTimeline后控制的视图内容
-
placeholder
-
getSnapshot
添加页面显示 -
getTimeline
主屏显示
Timeline刷新机制
首先,Widget 的刷新完全由 WidgetCenter 控制。开发者无法通过任何 API 去主动刷新 Widget 的页面,只能告知 WidgetCenter,Timeline 需要刷新了。(也就是最多只能整体刷新,不能单独刷新小组件里某一个地方)
系统提供了两种方式来驱动 Timeline 的 Reload。System Reloads 和 App-Driven Reloads。这两种方式的触发其实就是运行了getTimeline
方法。
1. 被动刷新
System Reloads: 这个行为由系统主动发起的 Timeline 刷新,会调用一次 Reload Timeline 向 Widget 请求下一阶段刷新的数据。系统除了会按时发起 System Reloads 之外,还会借助端智能的能力,动态决策每个不同的 TimeLine 的 System Reloads 的频次。超过频次的刷新请求将不会生效。高频使用的小组件可以获得更多的刷新频次。(目前自测最多5分钟刷新一次
,如果设置小于5分钟的时间也不会生效)
TimelineReloadPolicy
TimeLine里的刷新规则由TimelineReloadPolicy设定的时间刷新,下面看他的api介绍
TimelineReloadPolicy
有三种形式:
-
atEnd
是指 Timeline 执行到最后一个时间片的时候再刷新。
atEnd -
after(date)
date 是指定的下次刷新的时间,系统会在这个时间对 Timeline 进行刷新。
after(date) -
never
TimelineReloadPolicy 永远不会刷新 Timeline,最后一个 entry 也展示完毕之后 小组件就会一直保持那个 entry 的显示内容
never
一般常用的是atEnd,就是隔一段时间刷新一次,比如下图设置的每隔5分钟刷新一次
var entries: [FourToolWidgetSimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
// 从当前日期开始,每小时产生一个包含十二个条目的时间轴。
let currentDate = Date()
// 设置5分钟刷新一次(实际要看苹果的算法,可能有偏差)
for hourOffset in 0 ..< 12 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = FourToolWidgetSimpleEntry(date: entryDate, contact: contact, contact2: contact2, contact3: contact3, contact4: contact4)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
2. 手动刷新
App-Driven Reloads:指的是 App 请求 Widget 下一阶段刷新的数据。这里也要分两种场景,应用在前台运行和应用在后台运行。当应用在前台运行的时候,App 可以直接请求 WidgetCenter 的 API 来触发 Reload Timeline;而当应用处于后台时,后台推送(Background Notification)也可以触发 Reload Timeline。
通过swift调用widgetKit里的api实现刷新widget。
[WidgetKitHelper reloadAllWidgets];
reloadWidget.swift文件
import Foundation
import WidgetKit
//声明 14以上的系统才可用此 api
@available(iOS 14.0, *)
//定义 oc 方法
@objcMembers final class WidgetKitHelper : NSObject {
class func reloadAllWidgets() {
// 刷新所有widget
// arm64架构真机以及模拟器可以使用
#if arch(arm64) || arch(i386) || arch(x86_64)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
// 刷新某一个widget. xxxx 是该widget的 identifier
class func reloadWidgetForKind(kindStr: NSString) {
WidgetCenter.shared.reloadTimelines(ofKind: "xxxx")
}
}
获取数据
网络请求
小组件中可以使用 URLSession
,所以网络请求和 App 中基本一致,在此就不赘述了。
需要注意的点:
- 使用第三方框架需要引入小组件所在的 Target。
- 在刷新 Timeline 时调用网络请求。
- 如果需要和 App 共享信息,则需要通过 App Group 的方式存取。
- 异步的网络请求要用block实现。
如果调用异步的网络请求,需要在getTimeline
里使用block回调 (试过在网络请求里用gcd或其他线程同步方法去模拟block效果,但是不生效,只有直接使用block才可以)
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<FourToolWidgetSimpleEntry>) -> ()) {
mediumProdLoader.fetch(editArr: editArr as! NSArray){
//里面执行XXXXX
completion(timeline) //最后返回completion
}
}
block的内部实现可以去加载你的网络请求,我项目里要调用OC的接口,所以这里调用了OC代码
// 获取所有设备数据
struct mediumProdLoader {
/// 获取设备列表数据
static func fetch(editArr: NSArray,completion: @escaping (Result<NSArray,Error>) -> Void) {
let vc = Calculator()
let Arr = editArr
//传参
vc.getDeviceList(Arr as! [NSString?]) { (data) -> Void in
let arr = data ?? NSArray() as! [Any]
if arr.count > 0 {
completion(.success(arr as NSArray))
}else{
completion(.success(NSArray()))
}
}
}
}
SwiftUI 构建组件
我们先来看下小组件的设计稿
因需要左右分割布局首先使用
HStack
分成2部分,这里需要注意swiftUI里控件与控件的默认间隔距离8,连接处如果需要2个控件紧贴需要设置padding
对应的距离为-8。GeometryReader
可以获取控件内部的宽高,且起点是和oc一样在控件的左上角(内部的视图还是在中心点),swiftUI默认情况下起点在中心位置。其他api可以查阅资料了解,下面展示UI对应代码
struct MediumProductView: View {
var entry: FourToolWidgetProvider.Entry
var body: some View
{
let contact : Contact = entry.contact
let contact2 : Contact = entry.contact2
HStack{
Link(destination: URL(string: "widget_deviceUUID:" + contact.deviceUUID + ":Device:" + contact.status)!, label: {
VStack
{
ProductView(imageUrl: contact.imageUrl)
// .cornerRadius(14)
.padding(EdgeInsets(top:10, leading: 10, bottom: -8, trailing:0))
GeometryReader { geo in
if !contact.deviceUUID.isEmpty {
GeometryReader { geo in
Text(loadStatusText(status: contact.status) ?? String())
.font(.system(size: 16))
.foregroundColor(Color("testColor"))
}
.frame(height: 18, alignment: .center)
.padding(EdgeInsets(top: 4, leading: 15, bottom: 28, trailing: 5))
GeometryReader { geo in
Text(entry.contact.name)
.font(.system(size: 13))
.foregroundColor(Color("testColor2"))
}
.frame(height: 12, alignment: .center)
.padding(EdgeInsets(top: 25, leading: 15, bottom: 13, trailing: 5))
}
}
.frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 18, maxHeight: 49.5, alignment: .center)
}
.padding(EdgeInsets(top:0, leading: 0, bottom: 0, trailing:1))
})
Link(destination: URL(string: "widget_deviceUUID:" + contact2.deviceUUID + ":Device:" + contact2.status)!, label: {
VStack
{
Image(uiImage: loadNetworkImage(imgUrlString: "https://wimg.ruan8.com/uploadimg/image/20190502/20190502165644_70280.jpg")!)
.resizable() //自适应大小 * 图片没有完全显示全,我们可以用Image的resizable()来让图片自动适应
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.clipped()
// ProductView(imageUrl: contact2.imageUrl)
// .cornerRadius(14)
.padding(EdgeInsets(top:10, leading: 0, bottom: -8, trailing:10))
GeometryReader { geo in
if !contact2.deviceUUID.isEmpty {
GeometryReader { geo in
Text(loadStatusText(status: contact2.status) ?? String())
.font(.system(size: 16))
.foregroundColor(Color("testColor"))
}
.frame(height: 18, alignment: .center)
.padding(EdgeInsets(top: 4, leading: 5, bottom: 28, trailing: 15))
GeometryReader { geo in
Text(contact2.name)
.font(.system(size: 13))
.foregroundColor(Color("testColor2"))
}
.frame(height: 12, alignment: .center)
.padding(EdgeInsets(top: 25, leading: 5, bottom: 13, trailing: 15))
}
}
.frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 18, maxHeight: 49.5, alignment: .center)
}
.padding(EdgeInsets(top:0, leading: 1, bottom: 0, trailing:0))
})
}
}
}
通过上述简单的例子可以发现,在常规的流式布局中,使用 VStack 和 HStack 即可达到布局效果。而如果想要实现例子中 logo 图标的效果的话,就需要使用 position/offset 来改变定位坐标来达成目标了。
跳转至App
不可交互,只可点击
Widget 的 UI 是无状态的,不支持滚动,也不支持像 Switch 一样的互动元素。唯一开放的能力只有通过点击和DeepLink 来唤起主 App。
苹果提供了两种 API 给到开发者,对于 systemSmall 类型来说,只支持 widgetURL 的方式, systemMedium 和 systemLarge 只支持使用。
SwiftUI widgetURL API,代码如下所示:
而 widgetURL 的可点击区域如下:
SwiftUI Link API,代码如下所示:
而 Link 的可点击区域如下:
同时,为了性能和耗电量的考虑。Widget 不能展示视频和动态图像。所以期待通过动效吸引用户眼球的方式可以暂时息熄火了~
设置完widgetURL或Link后,添加对应的URL Types
之后再AppDelegate
里的- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
方法写对应的逻辑处理,如果有SceneDelegate
文件,是在SceneDelegate 文件的- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts
方法里实现。
动态配置小组件
Widget 需要一定的定制能力,比如当我添加一个天气 Widget,我只需要关心杭州的天气怎么样。为了实现这个能力,苹果给 Widget 提供了 Configuration 的能力。顾名思义,就是可配置。一共有两种配置类型:
- StaticConfiguration,也就是用户无需配置,展示的内容只和用户信息有关系。
- IntentConfiguration,支持用户配置及用户意图的推测功能。
小组件支持用户在不打开应用的情况下配置自定义数据,使用 Intents 框架,可以定义用户在编辑小组件时看到的配置页面。 这里用的词的定义而不是绘制,是因为只能通过 Intents 来生成配置数据,系统会根据生成的数据来构建配置页面。
编辑功能需要创建独立的Extension,他的类型为com.apple.intents-service
,和siriKit的Intent类型一致,无法像widget那样独立运行,同widget创建逻辑,创建Intents Extension
语言可以选择oc或swift,这里我选择了swift,并且Staring Point选择None
创建后会生成对应的文件,默认只有一个handler方法,这里的方法需要你修改widget.intentdefinition文件后手动继承代理后实现对应的代理方法
如果这里也要桥接OC代码可以引用之前的桥接文件,在Build Setting里做同样的路径配置
编辑功能的具体内容需要在intentdefinition文件中设置
,如果没有先创建一个intentdefinition文件(默认会自带一个),之后添加新的Intent如下图新建Intent后需要选择一种Type,有已经设定好的一些内容和自定义的内容,如果想展示通过网络请求这种自定义的数据需要新建一个Type类型。
根据不同的需求配置完成后(因为我要根据不同类型展示编辑个数不同且需要动态编辑数据,所以最终配置如下图)
编辑功能配置
修改好配置后,Xcode 会自动帮你生成对应的代码和类型,需要
command+b
刷新一下配置,因为这里的配置会动态生成对应的编辑功能swift文件。(ps:有时候更新的会比较慢,很坑,可以多刷新或者重启下XCode试试)
这里要注意你的intent的命名是后面代理方法和类的名称都要用到的
之后修改IntentHandler里的代码如下
//这里的代理名称是你设置的intent的命名后面加上IntentHandling
class IntentHandler: INExtension, ConfigurationIntentHandling , RunIntentHandling {
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
}
}
之后会出现个报错,提示没有实现代理方法,直接点击fix即可
点击后生成必须实现的方法,也就是编辑功能对应的回调方法
需要在方法里返回items数组,列表里是编辑的数据内容!数组里元素默认是2个值
identifier
和display
func provideParameterOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection<Type>?, Error?) -> Void) {
mediumCacheProdLoader.fetch{ result in
let arr: NSArray
if case .success(let data) = result {
arr = data
//判空处理
if arr.count == 0 {
completion(nil, nil)
}
let animals: [Type] = arr.map { dict in
let dic = dict as? Dictionary<NSString, Any>
return Type(identifier: dic?["deviceUUID"] as? String, display: dic?["name"] as! String)
}
//最后通过回调返回编辑列表
completion(INObjectCollection(items: animals), nil)
}
}
}
之后要修改 Widget 的相关参数支持 ConfigurationIntent
旧:
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MyWidgetEntryView(entry: entry)
}
}
}
新:
@main
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: WidgetConfiguratIntent.self, provider: Provider()) { entry in
MyWidgetEntryView(entry: entry)
}
}
}
修改 IntentTimelineProvider 的继承
旧:
struct Provider: TimelineProvider {
...
}
新:
struct Provider: IntentTimelineProvider {
typealias Intent = WidgetConfiguratIntent
...
}
如果要用到编辑的列表内容,可以给getSnapshot
和getTimeline
增加configuration
属性
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<WidgetSimpleEntry>) -> ()) {
...
}
大致逻辑就是这样,具体的动态配置过程可以参照官方视频里的步骤操作https://developer.apple.com/videos/play/wwdc2020/10194/
修改后长按小组件就会弹出编辑弹窗
点击编辑小组件
编辑界面
点击选取后会弹出可自定义的编辑列表
自定义编辑列表
每次选择完编辑内容后会再次触发
getTimeline
方法,在参数configuration.parameter
中存放编辑的数据。
总结
小组件是一个优缺点都非常明显的事物,在桌面即点即用确实方便,可以是App的快捷入口,展示信息的窗口,还可以提升主操作页面的美观及多样性,可以说是一次很重要的体验更新。但是交互方式的匮乏以及不能实时更新数据又是非常大的缺陷。正如苹果所说:"Widgets are not mini-apps",不要用开发 App 的思维来做小组件,小组件只是由一连串数据驱动的静态视图。
- 优势:
- 常驻桌面,大大增加了对产品的曝光。
- 利用网络接口和数据共享,可以展示与用户相关的个性化内容。
- 缩短了功能的访问路径。一次点击即可让用户触达所需功能。
- 可以多次重复添加,搭配自定义和推荐算法,添加多个小组件样式和数据都可以不同。
- 自定义配置简单。
- 多种尺寸,大尺寸可以承载复杂度高的内容展示。
- 缺点:
- 不能实时更新数据。
- 只能点击交互。
- 小组件的背景不能设置透明效果。
- 不能展示动态图像(视频/动图)。
- UI样式固定,如编辑页面不能自由调整。
- 功能单一,对开发者的限制较大。(目前来看小组件更多的适合信息展示类的内容加载)
总结来说,个人认为iOS14小组件功能还是利大于弊的,毕竟这种主桌面的增项功能可以提升App的粘性,使用得当可以极大提升用户体验。虽然目前对小组件的功能使用限制较多,但如果未来苹果愿意开通更多的权限给到开发者,小组件也必将是App开发者们的必争之地!
参考资料
网易云音乐 iOS 14 小组件实战手册
Widget 到底是什么?和 App 的区别在哪儿呢?
iOS14 Widget小组件开发(Widget Extension)
iOS14 Widget 开发
Add configuration and intelligence to your widgets
iOS:OC与Swift互调
SwiftUI 的一些初步探索 (一)
SWiftUI之Layout基础篇
Demo下载
包含上述描述文章的所有功能,已上传github(mac 打不开github解决办法)
widgetDemo
网友评论