Created By Ningyuan — 2020/09/27

首先,吐槽一下苹果,发布会上、官方文档上,就仅仅以Swift项目作基础,演示了常规流程,而我们这些OC项目或混编的,就得自己去踩坑研究。

iOS14带来了新的WidgetKit框架,其实就是iOS10那会出来的Extension加强版,最低支持版本为iOS14版本,开发语言为SwiftUI(OC党们都快开始学起来吧)。

  • Widget只有三种尺寸:systemSmall、systemMedium、systemLarge,下面是各尺寸

    Screen size (portrait) Small widget Medium widget Large widget
    414x896 pt 169x169 pt 360x169 pt 360x379 pt
    375x812 pt 155x155 pt 329x155 pt 329x345 pt
    414x736 pt 159x159 pt 348x159 pt 348x357 pt
    375x667 pt 148x148 pt 321x148 pt 321x324 pt
    320x568 pt 141x141 pt 292x141 pt 292x311 pt
  • 一个App可创建多个不同功能或展示样式Widget,每个Widget也可选择性提供上述的三种尺寸

  • Widget跟先前版本的Extension一样,是一个独立的程序,有自己的生命周期(称为Timeline),理论上可以说跟主项目“没有关系”

  • Widget仅能通过设定好的Timeline刷新数据,并不能实时更新

下面会结合Demo,来讲讲Widget开发过程中一些要点

1. 创建不可配置的Widget

这里就跳过创建主工程的步骤,直接开始创建Widget项目

新建一个Target,在Application Extension中找到Widget Extension

点击下一步之后,这里填上Widget的ProductName,下面比较需要注意的就是Include Configuration Intent,这个是涉及Widget是使用静态的StaticConfiguration还是用户可配置的IntentConfiguration,区别之后再写,先不打勾,从常规的Widget不可配置模式开始

一般第一次创建的话,会出现这个弹窗,点击Activate即可

之后Xcode默认会创建一个时间显示的Widget,运行如下图所示

也可以手动选择其他尺寸的Widget,添加到桌面

image.png

2. 基础代码

下面来看看Xcode创建的基础代码,与Beta版稍有不同

Provider

提供刷新数据、控制数据的刷新相关方法,里面默认实现了三个方法

  • placeholder方法,给widget提供占位数据,异步返回一个TimelineEtry
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

展示上大致是这样的

  • getSnapshot方法提供Widget初次启动渲染时,或首次出现时所需的数据,一般可看作初始化数据
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date())
    completion(entry)
}
  • getTimeline方法,方法回调接收Entry数组,可认为是数据源跟刷新间隔
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []

    // 添加5个entry,每个entry设定刷新时间为1小时
    let currentDate = Date()

    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate)
        entries.append(entry)
    }

    // 这里是重点,传入Entry与你希望设置的刷新机制,.atEnd表示 等entries都显示完毕之后,再进行Timeline 的刷新
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

SimpleEntry

最简单的TimelineEntry,内部只声明了一个date变量,用来记录该entry的时间,官方解释是WidgetKit进行渲染widget的时间

struct SimpleEntry: TimelineEntry {
    let date: Date
}

EntryView

Widget的View部分,实现数据与View的绑定

struct WeatherWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

Widget

Widget加载的入口,我们创建的Widget是不可配置的,所以这边会自动生成StaticConfiguration,专门对静态类型的Widget提供配置

@main
struct WeatherWidget: Widget {
    let kind: String = "WeatherWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WeatherWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

Preview

画布上的预览,经常出问题,感觉可以忽略~

struct WeatherWidget_Previews: PreviewProvider {
    static var previews: some View {
        WeatherWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

3. 适配不同尺寸

  • 将EntryView修改成如下方式,使用SwiftUI提供的@Environment,可根据不同的尺寸展示不同的布局
struct MyWidgetEntryView : View {

    @Environment(\.widgetFamily)
    var family: WidgetFamily
    var entry: Provider.Entry

    @ViewBuilder
    var body: some View {

        switch family {
        case .systemSmall:Text(entry.date, style: .time)
        case .systemMedium: Text(entry.date, style: .time)
        case .systemLarge: Text(entry.date, style: .time)
        default:Text(entry.date, style: .time)
        }
    }
}
  • 若想仅适配某个尺寸,可在Configuration之后添加.supportedFamilies,不加则表示默认适配三个尺寸

4. 为Widget添加可配置模式

接下来说说如何为Widget添加可配置模式,下面分两个方法进行介绍:新建项目添加、在静态配置基础上添加

(1)新建Widget时勾选Include Configuration Intent

新建的项目中会多一个xx.intentdefinition文件

代码中,IntentConfiguration替代了StaticConfiguration

@main
struct WeatherWidget: Widget {
    let kind: String = "WeatherWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            WeatherWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

Provider里面的getSnapshot、getTimeline方法多了一项configuration参数

struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent())
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), configuration: configuration)
        completion(entry)
    }

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

接着在Parameters添加不同的参数,也可以在这里添加枚举、自定义类型等,具体还需各位自行摸索

再取title进行显示

配置完毕,运行,长按Widget,弹窗会多出一项配置Edit Widget,点击则可进入Widget编辑界面

进入配置界面,即可为配置

完成

(2)在静态配置的基础上修改为动态配置

这个方式处理起来比新建widget直接勾选intent要麻烦一些,首先我们在Widget项目内新建一个Intent Definition文件

文件名可以随意建,记住Targets需要勾选中Extension

创建完是这样子的,里面的Intents需要自己创建

+,选择New Intent,刚新建Intent后如下图所示

给Intent起个名字,这里我用Configuration,一会要用到;Custom Intent处,修改Category为View,选中Widgets,至于Configurable in Shortcuts、Suggestion则根据自己项目的需求来确定是否勾选吧,之后在Parameter添加一个content

接着要到Widget的swift文件去修改,添加import Intents,将Intent替换成刚刚的自定义配置,名称为ConfigurationIntent,上面起的配置名为Configuration,Xcode生成配置文件时,则会在末尾加上Intent

修改Provider
  • 遵循的协议,改成IntentTimelineProvider
  • getSnapshot、getTimeline也相应需要修改,建议先把原本的方法暂时注释,敲几个单词,由Xcode提示并回车,确认改好再删掉原代码

我猜99%的小伙伴在这步都会报错,请移步至常见问题Q&A**

修改SimpleEntry

为其添加一个指定类型为ConfigurationIntent的data变量

修改Widget

原本的StaticConfiguration改为IntentConfiguration

需要Preview的,再按照Xcode提示修改即可

常见问题Q&A:

Q1:新建Widget时勾选Include Configuration Intent后,编译报错

有可能是项目前缀导致的,假设项目前缀是AB,那么Intent的类名也需要加上前缀,如:ABConfigurationIntent

Q2:手动新建Intent配置时报错

一般错误为:Type 'Provider' does not confirm to protocol 'IntentTimelineProvider'Cannot find 'ConfigurationIntent' in Scope,这里的ConfigurationIntent对应IntentDefinition文件的自定义配置名称,各位注意对应

首先要说说这个ConfigurationIntent文件,通过查看新建Widget时直接勾选所生成的文件路径得知,这个文件并非直接存在我们的主项目,而是每次编译之后生成的

那既然编译的时候报错,就说明这个ConfigurationIntent文件没有跟我们的项目关联起来,跟没有import这个项目有点像,所以才会有Cannot find 'XXXIntent' in Scope的错误

在Extension的Target的BuildSettings中,搜索intent,有一个Intent Definition Complier - Code Generation的属性栏,可以看到Intent Class Generation Language这个配置,是Automatic

如果搜索不到,请确认项目中是否已添加了DefinitionIntent文件

可以先将其改成Swift,然后再编译一下,可以看到刚刚的缓存文件,生成了OC文件。。。难怪编译报错。那我们就找到问题了。

而这个Automatic,好像是不会根据项目所使用的的语言去自动生成相应的Intent配置文件,不知道是不是只会生成OC文件,目前已知组合:

OC项目,创建的是OC文件

混编项目,创建的是OC文件

纯Swift项目,创建的是OC文件

将这个选项选中,改成Swift,就可以解决这个问题

Q3:Q2中的报错,修改完毕,使用模拟器运行后点击Edit Widget没反应

这个好像是bug,需要先将模拟器内的应用删除,再重新运行

参考资料:

作者:Linghit_iOS
链接:https://juejin.im/post/6885148185602785293