iOS14 Widgets开发(从0到1最详细攻略)

关于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 有两种共享数据的方式,NSUserDefaultsNSFileManager

创建完Extension后我们要去创建该Extension对应的证书(每个Extension都需要独立的证书),需要前往苹果开发者中心,手动创建 App ExtensionApp 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) -> ()) {
        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函数的内容,将继承改为WidgetBundleWidgetConfiguration改为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 中基本一致,在此就不赘述了。

需要注意的点:

  1. 使用第三方框架需要引入小组件所在的 Target。
  2. 在刷新 Timeline 时调用网络请求。
  3. 如果需要和 App 共享信息,则需要通过 App Group 的方式存取。
  4. 异步的网络请求要用block实现。

如果调用异步的网络请求,需要在getTimeline里使用block回调 (试过在网络请求里用gcd或其他线程同步方法去模拟block效果,但是不生效,只有直接使用block才可以)

func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
        mediumProdLoader.fetch(editArr: editArr as! NSArray){
             //里面执行XXXXX
             completion(timeline)  //最后返回completion
        }
}

block的内部实现可以去加载你的网络请求,我项目里要调用OC的接口,所以这里调用了OC代码

// 获取所有设备数据
struct mediumProdLoader {
    /// 获取设备列表数据
    static func fetch(editArr: NSArray,completion: @escaping (Result) -> 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 的方式, systemMediumsystemLarge 只支持使用。

SwiftUI widgetURL API,代码如下所示:


而 widgetURL 的可点击区域如下:

SwiftUI Link API,代码如下所示:


而 Link 的可点击区域如下:

同时,为了性能和耗电量的考虑。Widget 不能展示视频和动态图像。所以期待通过动效吸引用户眼球的方式可以暂时息熄火了~

设置完widgetURL或Link后,添加对应的URL Types


之后再AppDelegate里的- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options方法写对应的逻辑处理,如果有SceneDelegate文件,是在SceneDelegate 文件的- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)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个值identifierdisplay

func provideParameterOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection?, 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
                    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
    ...
}

如果要用到编辑的列表内容,可以给getSnapshotgetTimeline增加configuration属性

func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
    ...
}

大致逻辑就是这样,具体的动态配置过程可以参照官方视频里的步骤操作https://developer.apple.com/videos/play/wwdc2020/10194/

修改后长按小组件就会弹出编辑弹窗

长按编辑弹窗

点击编辑小组件
编辑界面

点击选取后会弹出可自定义的编辑列表
自定义编辑列表

每次选择完编辑内容后会再次触发getTimeline方法,在参数configuration.parameter中存放编辑的数据。

总结

小组件是一个优缺点都非常明显的事物,在桌面即点即用确实方便,可以是App的快捷入口,展示信息的窗口,还可以提升主操作页面的美观及多样性,可以说是一次很重要的体验更新。但是交互方式的匮乏以及不能实时更新数据又是非常大的缺陷。正如苹果所说:"Widgets are not mini-apps",不要用开发 App 的思维来做小组件,小组件只是由一连串数据驱动的静态视图。

  • 优势:
  1. 常驻桌面,大大增加了对产品的曝光。
  2. 利用网络接口和数据共享,可以展示与用户相关的个性化内容。
  3. 缩短了功能的访问路径。一次点击即可让用户触达所需功能。
  4. 可以多次重复添加,搭配自定义和推荐算法,添加多个小组件样式和数据都可以不同。
  5. 自定义配置简单。
  6. 多种尺寸,大尺寸可以承载复杂度高的内容展示。
  • 缺点:
  1. 不能实时更新数据。
  2. 只能点击交互。
  3. 小组件的背景不能设置透明效果。
  4. 不能展示动态图像(视频/动图)。
  5. UI样式固定,如编辑页面不能自由调整。
  6. 功能单一,对开发者的限制较大。(目前来看小组件更多的适合信息展示类的内容加载)

总结来说,个人认为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

你可能感兴趣的:(iOS14 Widgets开发(从0到1最详细攻略))