作者: kAzec, iOS 开发者,目前就职于字节跳动
在阅读本文前,推荐先对新的 Widget 系统有个大致的了解,同时也推荐先熟悉 Apple 在 SiriKit 中引入的 Intents API。
在 WWDC 2020 中,Apple 引入了 Widget (小挂件)这一全新的 App Extension,允许开发者在设备主屏幕、“今天”视图和 macOS 通知中心上显示自定义的小挂件。
该 session 首先介绍了如何通过 Intents API,让我们开发的 Widget 支持让用户进行个性化配置,并介绍了目前支持的配置参数的类型,如何自定义参数类型,并且支持为用户动态生成待选项列表。
之后介绍了 Widget 支持在 Smart Stack 中堆叠展示,同时可以通过开发者的配置让 Widget 智能地被系统展现:
通过复用 <code>Intents</code> 的“捐赠(donate)”的概念,让用户在合适的时机看到 TA 需要的内容。
通过为特定的 <code>TimelineEntry</code> 指定相关性信息,让用户能及时看到开发者认为和当前用户高度相关的内容。
让我们开始吧~
为了更好的解释相关概念,这个 session 使用了一个信用卡记账示例应用,这个应用实现了两个 Widget:
<code>RecentPurchases</code>: 显示某个信用卡账户最近的购买记录
<code>DueDate</code>: 显示某个信用卡账户消费额 & 还款日期
Tips: Apple 并没有放出该 session 中演示的 demo 的源码,但是,另一 Widgets Code-along[1] 系列 session 中,所演示的 Emoji Rangers demo 提供了源码[2],同时也实现了本文所描述的配置能力,可以用作参考。
Widget 的可配置的参数列表是使用 Intents 进行配置的。Intents 是 Apple 在 WWDC 2016 引入的新概念,了解 SiriKit 的开发者对此肯定不会感到陌生(参见 Introducing SiriKit[3] 和 Introduction to Siri Shortcuts[4])。
为了让用户可以自定义 <code>RecentPurchases</code> Widget 所显示的信用卡账户、对应的消费分类的功能,我们可以定义一个包含了两个参数的 Intent:
Card:Widget 所显示消费记录对应的信用卡账户
Category:Widget 所显示消费记录的特定分类
基于我们定义的这个 Intent,WidgetKit 会自动为我们生成如下图所示的配置界面,其中 Card 和 Category 参数分别对应一个配置行。
在用户进行 Widget 配置时,WidgetKit 可以通过 Intent Handling Protocol 从我们的 Widget Extension 或 Host App 获取要显示的待选项列表(这一点在后面会详细讲到)。
最后,在获取 Widget Timeline 数据时,WidgetKit 会将用户所自定义的 Intent 实例传入我们的 Widget Extension,我们可以使用该 Intent 实例中的信息来返回个性化的 Timeline 数据。
Widget 支持配置从整型、字符串等基础类型到日期、URL 等高级类型的参数,同时支持自定义参数类型 & 自定义枚举类型。
所有支持的类型配置可见下图:
其中,特定的类型还有近一步的自定义选项来定制输入 UI。例如,Decimal 类型可以选择采用输入框(Number Field)输入或者是滑块(Slider)输入,同时可以定制输入的上下限;Duration 类型可以定制输入值的单位为秒、分或者时;Date Components 可以指定输入日期还是时间,指定日期的格式等等。
除了系统自带的参数类型以外,也支持自定义参数类型。可以通过 <code>Add Type...</code> 添加自定义的参数类型,通过<code>Add Enum...</code> 添加自定义的枚举类型,Xcode 会自动生成/更新对应的 Swift 类型(在 Xcode 12 beta 中添加自定义类型后,大部分情况下需要重新 build 项目,或者重启 Xcode 才能看到生成的 Swift 类型????)。
可能有读者会注意到,上图中显示的第二个自定义枚举类型 <code>Dynamic</code>,支持动态生成所显示的待选项列表,这个我们会在后面详细介绍。
大部分类型的参数支持输入多个值,即输入一个数组。同时,支持根据不同的 Widget 大小,限制数组的固定长度。
下面,基于前述的信用卡实例应用中的 <code>RecentPurchases</code> Widget,对如何为 Widget 添加个性化配置的能力做逐步、详细的介绍。
<code>RecentPurchases</code> Widget 目前一次只能显示一张卡片的消费记录等信息,所以很自然,我们会想要让这个 Widget 支持自定义要显示的信用卡账号。同时,多个消费记录可能属于不同的类别(<code>category</code>),那么很自然的,我们可以让用户选择只显示某一个类别的消费记录。
所以,我们希望<code>RecentPurchases</code> Widget 可以支持自定义信用卡账号(<code>card</code>)以及消费类别(<code>category</code>)两个参数。
首先,我们在工程中的一个 Intent 定义文件中 (如项目中没有已有文件,可以通过 Choose File > New > File 选择 SiriKit Intent Definition File 添加)中,定义一个新的 Intent,叫做 <code>ViewRecentPurchasesIntent</code>。
需要注意以下几点: Intent 的 Category 选择为 View(即用于展示/配置 UI) 选中 Intent is eligible for widgets 取消选中 Siri can ask value for run(除非该 Intent 也用于 Siri Shortcuts)
在定义完新的 Intent 类型后,Xcode 会自动生成对应的 Swift 类型文件(以及相对应的 <code>IntentHandling</code> 协议,见下),我们在 Widget Extension & Host App 均可以使用这个 Intent 类型。(Xcode 12 beta 中添加新的 Intent 类型后可能需要重新 Build target,或者重启 Xcode 才能看到/使用新的类型)
在示例应用中的 <code>card</code> 参数,用户添加了那些信用卡账号只有运行时才知道,对于那些需要在运行时确定有哪些待选项的参数,我们可以选中 Options are provided dynamically 选项,并实现对应的 <code>IntentHandling</code> 协议。
一般我们通过创建一个 Intent Extension target 来处理和系统 Intents 相关的交互。 关于什么是 Intent Handling,如何提供某个 Intent 的 Handler 实现可以参考 SiriKit Programming Guide[5] 中的 Siri Intents 部分内容。
以之前定义的 <code>ViewRecentPurchasesIntent</code> 为例,Xcode 会自动生成一个 <code>ViewRecentPurchasesIntentHandling</code> 协议。通过指定一个 Handler 类,并实现下面两个方法:
<code>provideCardOptionsCollection(for:with:)</code>在用户点击 Widget 中 Card 配置项的时候,WidgetKit 会展示上图右侧中的列表 UI,其中的数据由这个方法异步返回。
<code>defaultCard(for:)</code>我们可以通过实现该方法,在用户首次添加我们的 Widget 时,对于该 Widget 的某一个可配置项返回一个默认的参数值。例如在图示的实现中,我们返回了用户的主要信用卡(Primary Card)。
通过使用 <code>INObjectCollection(sections:)</code> 构造器,传入 <code>INObjectSection</code> 数组,可以分区展示待选项列表。 自定义 Intent 类型继承自 <code>INObject</code>,通过重载/设置 <code>displayString</code>、 <code>subtitleString</code> 等属性,可以定制自定义类型在待选项列表中的显示内容。 Intent Handling 协议中定义的 <code>defaultXXX(for:)</code> 方法被标记为 <code>optional</code>,但是依然推荐实现,因为一个好的默认视图对我们的 Widget 来说是十分重要的。
你可能注意到了待选项列表上方的搜索框。默认情况下,搜索框会对我们所返回的全部内容进行搜索过滤。但是,当待选数据较多,或者说待选数据取决于用户具体输入时,我们可以打开 Intent handler provides search results as the user types 选项,实现对待选项列表的实时更新。
在打开该选项后,Xcode 会为生成的 <code>IntentHandling</code> 协议的 <code>provideCardOptionsCollection(for:with:)</code> 方法添加一个 <code>searchTerm</code> 参数:
当用户在搜索框中输入字符时,<code>WidgetKit</code> 会调用该方法对待选项列表进行更新。首次显示待选项列表时,该参数值为 nil。
现在我们定义了用于配置 <code>RecentPurchases</code> Widget 的 Intent 类型,同时实现了对应的 <code>IntentHandling</code> 协议。下面我们可以将 <code>RecentPurchases</code> Widget 切换至 Intent-based API 用以展示用户自定义的内容。
从 <code>StaticConfiguration</code> 切换至 <code>IntentConfiguration</code>,并传入所配置的 Intent 类型(示例中为 <code>ViewRecentPurchasesIntent.self</code>)。
从 <code>TimelineProvider</code> 切换至 <code>IntentTimelineProvider</code>,并更新相关的方法(<code>snapshot(for:with:completion:)</code>、<code>timeline(for:with:completion:)</code>),添加 <code>intent</code> 参数,修改实现,使用 <code>intent</code> 参数中的配置信息,返回个性化的 <code>TimelineEntry</code> 数据。
通过对 <code>WidgetConfiguration</code> 添加下面两个 <code>modifier</code>,自定义配置界面标题、描述文案。
通过在 Widget Extension target 的 Build Settings 页面中,配置 Widget 的 Global Accent Color Name 和 Widget Background Color Name,自定义配置界面的强调色和背景色。对应的颜色资源需要添加在 target 中的 Assets Catalog 中。
你可以控制某一个配置项,只在另一个配置项含有任何/特定值时展示。如下图,日历 App 的 Up Next Widget,仅在 Mirror Calendar App 选项没有被选中时,才会显示 Calendars 配置项。
在 Intent 定义文件中,将某一个参数 A,设置为另一个参数 B 的 Parent Parameter,这样,参数 B 的显示与否就取决于参数 A 的值。
例如,在下图中,<code>calendar</code> 参数仅在 <code>mirrorCalendarApp</code> 参数的值为 <code>false</code> 时展示:
在 iOS 14 中,随着 Widget 一并引入的还有 Smart Stack,即 Widget 智能堆栈。用户可以将多个 Widget 堆叠显示,通过上下滑动切换正在显示的 Widget。
为了让堆栈能够在合适的时机展示合适的 Widget,Apple 引入了一套类似 Siri Suggestions 的,基于 Intents donation 以及 Relevance 内容相关性的竞标机制。
Timely:Widget 应该在合适的时机,展示用户感兴趣的内容。
Glanceable:Widget 展示的内容应该是简洁、直观、一目了然的。
Obvious value:Widget 应该展示对用户来说最有价值/相关性高的内容。
一个优秀的 Widget 应该是一目了然的,并会在合适的时机,提供用户最感兴趣的内容。
例如,对于一个天气 App 来说,某个用户可能习惯在早上 8:00 左右打开天气 App 查看当日天气,那么我们希望用户在每天早上 8:00 打开手机时,能够在主屏幕上直接看到天气 App 的 Widget。
此外,我们也希望在有雷雨天气时,主动在 Smart Stack 中显示天气 App 的 Widget,让用户对恶劣天气做好准备。
为此,WidgetKit 提供了两种机制来实现上述目标。
在 iOS 12 中,Apple 引入了 Siri Shortcuts & Custom intent donations(参见 Introduction to Siri Shortcuts[6])。当用户在宿主 App 内进行某一操作时,App 可以主动 <code>donate</code> 一个 Intent 实例告知系统用户进行了此操作,从而让系统了解用户的行为规律。这一信息过去被用于在 Spotlight 中预测用户可能进行的操作,而在 iOS 14 中,同样的信息也可以让系统预测 Smart Stack 中某一 Widget 适合的展示时机。
对于 Widget 来说,利用这一机制的前提是实现了可配置化的能力。Widget 的可配置化是基于自定义 Intent 类型实现的,那么同样的 Intent 也可以被 <code>donate</code> 给系统。
以前述的 <code>RecentPurchases</code> Widget 为例,我们希望系统能掌握用户查看特定信用卡消费记录的规律,并在合适的时机展示对应卡片的 Widget,下面进行逐步介绍:
将 Widget 对应的 Intent 标记为 Intent is eligible for Siri Suggestions。
在 Suggestions 部分添加 Supported Combinations,我们只关注用户所查看的信用卡账户,所以添加一个只有 <code>card</code> 参数的 combination。
在宿主 App 内显示对应的信用卡消费记录时,创建并 <code>donate</code> 一个 <code>ViewRecentPurchasesIntent</code>。
让系统理解用户在 App 内的行为的关键在于配置合适的 Supported Combinations,直译过来是**“支持的参数组合”**。通过配置一个或多个参数组合,我们可以告诉系统用户在 App 内的行为的哪些特征(特征由对应的 Intent Parameter 决定)是值得关注的。
例如在上面的 Demo 实现中,我们制定了包含一个参数 <code>card</code> 的参数组合,那么系统会提取所有包含同一 <code>card</code> 参数的行为数据,归纳总结出用户在特定的时间,查看某一信用卡账户信息的行为规律。而在匹配最合适的 Widget 时,相应的会去查找所有配置展示了对应卡片的 Widget,不论该 Widget 的另一个参数 <code>category</code> 的取值。
例如上图的例子中,系统最后匹配到了两个 Widget:Acme Card - Grocery 和 Aceme Card - Travel。
而如果我们在参数组合中添加 category,则在匹配 Widget 时,会同时考虑两个参数,最后匹配到 Acme Card - Grocery:
在之前的学习中我们已经了解了如何使用 <code>TimelineProvider/IntentTimelineProvider</code> 来提供不同时间点的 Widget 渲染所需的数据。
<code>Timeline</code> 数据由一个个 <code>TimelineEntry</code> 组成,一个 <code>TimelineEntry</code> 除了包含 Widget 对应显示的时刻信息、 Widget 渲染的内容数据外,还可以包含一个 <code>TimelineEntryRelevance</code> 对象,用来表示这个 entry 的相关性。
<code>TimelineEntryRelevance</code> 信息包含两个 <code>score</code> 和 <code>duration</code> 两个属性:
A value that indicates the relevance of an entry compared to other entries in the past.
<code>score</code> 值的高低,反应了在对应的时间点,Widget 所展示的内容和用户的相关程度(或者说用户可能感兴趣的程度、对用户的重要程度)。
例如以前述的 <code>RecentPurchases</code> 挂件为例,我们将 <code>score</code> 值设置为要展示的消费记录的金额,那么相应的,金额越大的消费记录越有可能被系统展示给用户。
需要注意的是,<code>score</code> 值是一个相对值,它不会用来和别的 App 所提供的值进行比较,只会用于和过去该 App 所提供过的所有值进行比较。
当 <code>score</code> 值小于或等于0时,系统认为对应时刻的 Widget 内容对用户来说是完全不重要的(比如显示为空占位图视图时),所以不会主动展示该 Widget 。
The length of time following the entry's date that the widget has the relevance score set.
简而言之,<code>duration</code> 值代表了一个 <code>TimelineEntry</code> 的用户相关性所持续的时长。这个持续过程可以跨越多个 <code>entries</code>,直到下一个指定了 <code>non-nil relevance</code> 值的 <code>TimelineEntry</code> 把它覆盖,又或者是指定的持续时间结束。
例如,下图所示体育比赛信息挂件中,在 6:30 指定了一个长达三小时的相关性信息,由于后续(6:40、7:02、9:30)的 <code>TimelineEntry</code> 并没有提供新的 <code>relevance</code> 信息,那么后续的挂件展示依然会继承之前所指定的相关性数据。
当 <code>duration</code> 的值为 0 时,WidgetKit 认为该 entry 对应内容的相关性会持续到下一个提供了 <code>relevance</code> 信息的 <code>TimelineEntry</code> 被展示。"
本文分享自微信公众号 - 老司机技术周报(LSJCoding)。
如有侵权,请删除。