iOS14 Widget小组件开发(Widget Extension)

自iOS8之后,苹果支持了扩展(Extension)的开发,开发者可以通过系统提供给我们的扩展接入点 (Extension point) 来为系统特定的服务提供某些附加的功能。
但iOS14后,苹果更新了扩展组件,引入了新的UI组件:WidgetKit 而舍弃了iOS14以下版本的Today Extension组件

开发须知

  • WidgetExtension 使用的是新的WidgetKit不同于Today Widget,它只能使用SwiftUI进行开发,所以需要SwiftUI和Swift基础
  • Widget只支持3种尺寸systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)
  • 默认点击Widget打开主应用程序
  • Widget类似于Today Widget是一个独立运行的程序,需要在项目中进行 App Groups 的设置才能使其与主程序互通数据,这点与Today Widget相同
  • Apple官方已经弃用Today Extension,Xcode12已经不再提供Today Extension的添加,已经有Today Widget的应用则会显示到一个特定的区域进行展示
    Widget官方说明

Widget实现

1.创建添加Widget Extension

File -> New -> Target -> Widget Extension

创建

Include Configuration Intent
如果你所创建的Widget需要支持用户自定义配置属性,则需要勾选这个(例如天气组件,用户可以选择城市;记事本组件,用户记录信息等),不支持的话则不用勾选

本文主要以未勾选用户配置属性的情况说明

创建

2.Widget文件函数解析

Provider

为小组件展示提供一切必要信息的结构体,遵守TimelineProvider协议,产生一个时间线,告诉 WidgetKit 何时渲染与刷新 Widget,时间线包含一个你定义的自定义TimelineEntry类型。时间线条目标识了你希望WidgetKit更新Widget内容的日期。在自定义类型中包含你的Widget的视图需要渲染的属性。

struct Provider: TimelineProvider {
    // 占位视图
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }
    // 编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }
    // 进行数据的预处理,转化成Entry
    func getTimeline(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)
            entries.append(entry)
        }
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
  • placeholder:提供一个默认的视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
  • getSnapshot:为了在小部件库中显示小部件,WidgetKit要求提供者提供预览快照,在组件的添加页面可以看到效果
  • getTimeline:在这个方法内可以进行网络请求,拿到的数据保存在对应的entry中,调用completion之后会到刷新小组件
    • 参数policy:刷新的时机
      .never:不刷新
      .atEnd:Timeline 中最后一个 Entry 显示完毕之后自动刷新。Timeline 方法会重新调用
      .after(date):到达某个特定时间后自动刷新
    • !!!Widget 刷新的时间由系统统一决定,如果需要强制刷新Widget,可以在 App 中使用 WidgetCenter 来重新加载所有时间线:WidgetCenter.shared.reloadAllTimelines()

Timeline的刷新策略是会延迟的,并不一定根据你设定的时间精确刷新。同时官方说明了每个widget窗口小部件每天接收的刷新都会有数量限制
TimelineProvider官方解释

Entry

渲染 Widget 所需的数据模型,需要遵守TimelineEntry协议。

struct SimpleEntry: TimelineEntry {
    let date: Date
}

EntryView

屏幕上 Widget 显示的内容,可以针对不同尺寸的 Widget 设置不同的 View。

struct NowWidgetEntryView : View {
    var entry: Provider.Entry
    //针对不同尺寸的 Widget 设置不同的 View
    @Environment(\.widgetFamily) var family // 尺寸环境变量

    @ViewBuilder
    var body: some View {
        switch family {
        case .systemSmall:
            // 小尺寸
            Text(entry.date, style: .time)
        case .systemMedium:
            // 中尺寸
        default:
            // 大尺寸
        }
    }

}

@main 主入口

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

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            NowWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        //.supportedFamilies([.systemSmall,.systemMedium])
    }
}
  • @main:代表着Widget的主入口,系统从这里加载,可用于多Widget实现
  • kind:是Widget的唯一标识
  • WidgetConfiguration:初始化配置代码
    • StaticConfiguration : 可以在不需要用户任何输入的情况下自行解析,可以在 Widget 的 App 中获 取相关数据并发送给 Widget
    • IntentConfiguration主要针对于具有用户可配置属性的Widget
      ,依赖于 App 的 Siri Intent,会自动接收这些 Intent 并用于更新 Widget,用于构建动态 Widget
  • configurationDisplayName:添加编辑界面展示的标题
  • description:添加编辑界面展示的描述内容
  • supportedFamilies:设置Widget支持的控件大小,不设置则默认三个样式都实现
Widget控件尺寸大小
屏幕尺寸(portrait) Small widget Medium widget Large widget
414x896 pt 169x169 pt 360x169 pt 360x376 pt
375x812 pt 155x155 pt 329x155 pt 329x345 pt
414x736 pt 159x159 pt 348x159 pt 348x357 pt
375x667 pt 148x148 pt 322x148 pt 322x324 pt
320x568 pt 141x141 pt 291x141 pt 291x299 pt

3.多Widget组件实现

一个Widget只能实现大中小三个不同尺寸的组件形式,如果现有需求要做不同功能并且相同尺寸规格的组件则需要实现多组件

1.通过修改原Widget入口文件方法添加更多配置来支持多个Widget

@main
struct NowSwiftWidget: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        NowPosterWidget()
        NowRecommendWidget()
        NowDailyWidget()
    }
}

2.另建SwiftUI文件实现组件功能,并去除@main,修改相同函数名

struct NowPosterWidget: Widget {
    private let kind: String = "NowPosterWidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: PosterProvider()) { entry in
            NowPosterWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("格言")
        .description("Now冥想每日海报、日签小组件")
        .supportedFamilies([.systemSmall,.systemMedium])
    }
}

4.Widget数据请求及网络图片加载

1).新建一个swift文件,用于单独处理数据
2).创建网络请求的数据模型
struct Poster {
    let author: String
    let content: String
    var posterImage: UIImage? = UIImage(named: "plan_collect")
}

并在Widget页面中Entry中绑定对应的模型

struct PosterEntry: TimelineEntry {
    let date: Date
    let poster: Poster
}
3).创建请求函数,并且回调请求参数,声明一个请求工具,实现数据请求并将网络图片同步请求

如果主APP用的swift编写,可以将网络请求模块文件共享或pods库共享(方法后文会介绍)
posterFromJson此数据模型转换方法仅适用简易接口(为了偷懒🤷‍♀️),复杂数据模型还是用HandyJSONKaKaJson解析
若用第三方模型转换方法,图片的同步请求处理放置于getTodayPoster的请求中同步处理即可

struct PosterData {
    static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) {
        let url = URL(string: "https://nowapi.navoinfo.cn/get/now/today")!
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard error==nil else{
                completion(.failure(error!))
                return
            }
            let poster=posterFromJson(fromData: data!)
            completion(.success(poster))
        }
        task.resume()
    }
    
    static func posterFromJson(fromData data:Data) -> Poster {
        let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
        guard let result = json["result"] as? [String: Any] else{
            return Poster(author: "Now", content: "加载失败")
        }
        
        let author = result["author"] as! String
        let content = result["celebrated"] as! String
        let posterImage = result["poster_image"] as! String
        
        //图片同步请求
        var image: UIImage? = nil
        if let imageData = try? Data(contentsOf: URL(string: posterImage)!) {
            image = UIImage(data: imageData)
        }
        
        return Poster(author: author, content: content, posterImage: image)
    }
}

SwiftUI中的Image没有提供直接加载URL方式的图片显示
getTimeline中进行数据请求中completion(timeline)执行完之后,不再支持图片的异步回调,用异步加载的方式就无法加载网络图片,所以必须在数据请求回来的处理中采用同步方式,将图片的data获取,转换成UIImage,在赋值给Image展示

4).数据加载处理
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let currentDate = Date()
        //设定1小时更新一次数据
        let updateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!
        
        PosterData.getTodayPoster { result in
            let poster: Poster
            if case .success(let fetchedData) = result{
                poster = fetchedData
            }else{
                poster=Poster(author: "Now", content: "Now格言");
            }
            
            let entry = Entry(date: currentDate, poster: poster)
            let timeline = Timeline(entries: [entry], policy: .after(updateDate))
            completion(timeline)
        }
    }
5).页面搭建展示
struct NowPosterWidgetEntryView : View {
    var entry: PosterProvider.Entry
    var body: some View {
        ZStack{
            Image(uiImage: entry.poster.posterImage!)
                .resizable()
                .frame(minWidth: 169, maxWidth: .infinity, minHeight: 169, maxHeight: .infinity, alignment: .center)
                .scaledToFill()
                .edgesIgnoringSafeArea(.all)
                .aspectRatio(contentMode: .fill)
            Text(entry.poster.content)
                .foregroundColor(Color.white)
                .lineLimit(4)
                .font(.system(size: 14))
                .padding(.horizontal)
        }
        .widgetURL(URL(string: "跳转链接"))
    }
}

然后更新补全placeholder getSnapshot Previews处相应的Entry即完成Widget内容展示

实现展示

5.Widget点击交互

点击Widget窗口唤起APP进行交互指定跳转支持两种方式:

  • widgetURL:点击区域是Widget的所有区域,适合元素、逻辑简单的小部件
  • Link:通过Link修饰,允许让界面上不同元素产生点击响应

Widget三种尺寸规格中

  • systemSmall只能用widgetURL实现URL传递接收
var body: some View {
        ZStack{
            //UI编写
        }
        .widgetURL(URL(string: "跳转链接widgetURL"))
    }
  • systemMediumsystemLarge可以用Link或者widgetUrl处理
var body: some View {
        Link(destination: URL(string: "跳转链接Link")!){
            VStack{
                //UI编写
            }
        }
    }

接收方式
APPDelegate中接收返回的URL

//swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        
}

//OC
-(BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options{
    if ([url.scheme isEqualToString:@"NowWidget"]){
        //执行跳转后的操作
    }
    return YES;
}

如果项目实现了SceneDelegate则需要在SceneDelegate里面实现跳转处理

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    for context in URLContexts {
        print(context.url)
    }
}


数据共享

由于widget跟APP间相互独立,如果想用相同的数据则需要两者间数据共享,创建App Group
主APP中 Target -> Signing & Capability -> +Capability -> 添加 App Group

APPGroups创建.png

ps:网上说的还需创建申请 APPID 但在开启自动管理 Automatically manage signing的情况下xcode会自动给你创建相关联的APPID

两者间的数据共享主要通过UserDefaultsFileManager两种形式。
以OC中使用UserDefaults共享数据为例

//存数据
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.imoblife.now"];
[userDefaults setObject:@"content" forKey:@"widget"];
[userDefaults synchronize];

//取数据
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.imoblife.now"];
NSString *content = [userDefaults objectForKey:@"widget"];

oc、swift混编调用


文件共享及pods共享
  • 文件共享

    文件共享.png

    勾选共享widget选项即可

  • pods共享
    正常使用下widget中无法使用pods导入的第三方SDK如Masonry等,会造成布局等极其不便,因此需要共享pods,在Podfile中需要另设置并重新install

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了

Pods第三方SDK使用错误提示
如果在pods导入共享第三方库,或者使用[UIApplication sharedApplication]方法报错如下时

not available on iOS (App Extension) - Use view controller based solutions where appropriate instead.

则需要在pods Target里面,选中出错的SDK并点击buildSettings 搜索Require
然后把Require Only App-Extension-Safe API 然后把YES改为NO即可

修正路径说明.png

ps:工程项目里也可按照这个方法去排查原因