iOS14 Widget小组件开发(Widget Extension)

开发须知

1、WidgetExtension 使用的是新的WidgetKit不同于Today Widget,它只能使用SwiftUI进行开发,所以需要SwiftUI和Swift基础

2、Widget只支持3种尺寸systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)

3、默认点击Widget打开主应用程序

4、Widget类似于Today Widget是一个独立运行的程序,需要在项目中进行 App Groups 的设置才能使其与主程序互通数据,这点与Today Widget相同

Widget实现

0.创建Target所需的Profile

这个都懂,这里就忽略了

1.创建添加Widget Extension

File -> New -> Target -> Widget Extension

Include Configuration Intent
如果你所创建的Widget需要支持用户自定义配置属性,则需要勾选这个(例如天气组件,用户可以选择城市;记事本组件,用户记录信息等),
不支持的话则不用勾选,勾选的话会多个文件用来配置属性
 
本文主要介绍:未勾选用户配置属性,网络加载数据显示小组件,跳转到APP指定页面
 

cannot preview in this file — New build system required
无法在此文件中预览-需要新的构建系统, 如果遇到这个错误可以忽略

2.Widget文件函数解析

Provider

为小组件展示提供一切必要信息的结构体,遵守TimelineProvider协议,产生一个时间线,告诉 WidgetKit 何时渲染与刷新 Widget,
时间线包含一个你定义的自定义TimelineEntry类型。时间线条目标识了你希望WidgetKit更新Widget内容的日期。在自定义类型中包含你的Widget的视图需要渲染的属性。
struct Provider: TimelineProvider {
    // 占位视图
    // placeholder:提供一个默认的视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }
    /*
     编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法
     
     getSnapshot:为了在小部件库中显示小部件,WidgetKit要求提供者提供预览快照,在组件的添加页面可以看到效果
     */
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }
    /*
     getTimeline:在这个方法内可以进行网络请求,拿到的数据保存在对应的entry中,调用completion之后会到刷新小组件
     */
    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)
        }
        /*
         参数policy:刷新的时机
         .never:不刷新
         .atEnd:Timeline 中最后一个 Entry 显示完毕之后自动刷新。Timeline 方法会重新调用
         .after(date):到达某个特定时间后自动刷新
         
         !!!Widget 刷新的时间由系统统一决定,如果需要强制刷新Widget,可以在 App 中使用 WidgetCenter 来重新加载所有时间线:WidgetCenter.shared.reloadAllTimelines()
         
         Timeline的刷新策略是会延迟的,并不一定根据你设定的时间精确刷新。同时官方说明了每个widget窗口小部件每天接收的刷新都会有数量限制
         */
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

Entry

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

struct SimpleEntry: TimelineEntry {
    let date: Date
}

@main 主入口

/*
 @main:代表着Widget的主入口,系统从这里加载,可用于多Widget实现
 kind:是Widget的唯一标识
 WidgetConfiguration:初始化配置代码
 StaticConfiguration : 可以在不需要用户任何输入的情况下自行解析,可以在 Widget 的 App 中获 取相关数据并发送给 Widget
 IntentConfiguration: 主要针对于具有用户可配置属性的Widget
 ,依赖于 App 的 Siri Intent,会自动接收这些 Intent 并用于更新 Widget,用于构建动态 Widget
 configurationDisplayName:添加编辑界面展示的标题
 description:添加编辑界面展示的描述内容
 supportedFamilies:设置Widget支持的控件大小,不设置则默认三个样式都实现
 */
@main
struct getWidget: Widget {
    let kind: String = "getWidget"

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

Widget控件尺寸大小

首次运行

首次运行会显示一个text,显示的是时间

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

首先定个小目标,实现一个这样的页面

swift数据处理

struct Poster {
    /*
     posterImage:默认图片占位
     */
    let dic: Dictionary<String, Any>
    let idStr: String
    var posterImage: UIImage? = UIImage(named: "getWidgettest")
}
在Widget页面中Entry中绑定对应的模型

struct SimpleEntry: TimelineEntry {
    let date: Date
    let poster : Poster
}

创建请求函数,并且回调请求参数,声明一个请求工具,实现数据请求并将网络图片同步请求

struct Poster {
    /*
     posterImage:默认图片占位
     */
    let dic: Dictionary<String, Any>
    let idStr: String
    var posterImage: UIImage? = UIImage(named: "getWidgettest")
}
struct PosterData {
    static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) {

        let urlString:String = "http://XXXXXXXXXXXXXXXXx"
//        加密,当传递的参数中含有中文时必须加密
       let newUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
       //创建请求配置
       let config = URLSessionConfiguration.default
//        创建请求URL
       let url = URL(string: newUrlString!)
//        创建请求实例
       let request = URLRequest(url: url!)
       
//        进行请求头的设置
//        request.setValue(Any?, forKey: String)
       
//        创建请求Session
       let session = URLSession(configuration: config)
//        创建请求任务
       let task = session.dataTask(with: request) { (data,response,error) in
//            print(String(data: data! , encoding: .utf8) as Any)
//            将json数据解析成字典
//        let dictionary = try? JSONSerialization.jsonObject(with: data!, options: .mutableContainers)
           
        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["data"] as? [Any] else{

            return Poster(dic:["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888], idStr: "1", posterImage: UIImage(named: "getWidgettest"))
          }
          let randomInt = Int(arc4random() % 2)
          let datafirst = result[randomInt] as? [String: Any]
          let idStr = String(datafirst!["id"] as! Int)
          let posterImage = datafirst!["image_url"] as! String
          let vDic = datafirst
          
          //图片同步请求
          var image: UIImage? = nil
          if let imageData = try? Data(contentsOf: URL(string: posterImage)!) {
              image = UIImage(data: imageData)
          }
          
        return Poster(dic:vDic!, idStr: idStr, posterImage: image)
    }
}
SwiftUI中的Image没有提供直接加载URL方式的图片显示
getTimeline中进行数据请求中completion(timeline)执行完之后,不再支持图片的异步回调,用异步加载的方式就无法加载网络图片,所以必须在数据请求回来的处理中采用同步方式,将图片的data获取,转换成UIImage,在赋值给Image展示
 

数据加载处理

struct Provider: TimelineProvider {
    let poster = Poster(dic:["name":"Air Jordan 1 Mid “Chicago”","id":1,"market_price":8888],idStr: "1",posterImage:UIImage(named: "getWidgettest"))
    // 占位视图
    // placeholder:提供一个默认的视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
    func placeholder(in context: Context) -> SimpleEntry {
        return SimpleEntry(date: Date(),poster: poster)

    }
    /*
     编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法
     
     getSnapshot:为了在小部件库中显示小部件,WidgetKit要求提供者提供预览快照,在组件的添加页面可以看到效果
     */
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), poster: poster)
        completion(entry)
    }
    /*
     getTimeline:在这个方法内可以进行网络请求,拿到的数据保存在对应的entry中,调用completion之后会到刷新小组件
     */
    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(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1");
            }
            
            /*
             参数policy:刷新的时机
             .never:不刷新
             .atEnd:Timeline 中最后一个 Entry 显示完毕之后自动刷新。Timeline 方法会重新调用
             .after(date):到达某个特定时间后自动刷新
             
             !!!Widget 刷新的时间由系统统一决定,如果需要强制刷新Widget,可以在 App 中使用 WidgetCenter 来重新加载所有时间线:WidgetCenter.shared.reloadAllTimelines()
             
             Timeline的刷新策略是会延迟的,并不一定根据你设定的时间精确刷新。同时官方说明了每个widget窗口小部件每天接收的刷新都会有数量限制
             */
            
            let entry = Entry(date: currentDate, poster: poster)
            let timeline = Timeline(entries: [entry], policy: .after(updateDate))
            completion(timeline)
        }
    }
}

页面搭建展示

这里只举例systemSmall

struct getWidgetEntryView : View {
    var entry: Provider.Entry

    //针对不同尺寸的 Widget 设置不同的 View
    @Environment(.widgetFamily) var family // 尺寸环境变量
    var body: some View {
        //使用 GeometryReader 获取小组件的大小
        GeometryReader{ geo in
        VStack(content: {
            //HStack:纵向布局,默认居中对齐
            VStack(alignment: .center, spacing: 5) {
                let content = entry.poster.dic["name"] as! String
                Text("get  0元抽奖")
                    .padding(EdgeInsets(top: 10, leading: 14, bottom: 0, trailing: 14))
                    .frame( geo.size.width, height: 20, alignment: .leading)
                    .font(.system(size: 14, weight: .bold, design: .default))
                    .lineLimit(1)

                Image(uiImage: entry.poster.posterImage!)
                     .resizable()
                    .frame(60, height: 60)
                     .clipShape(Circle())

                Text(content)
                    // 增加 padding 使 Text 过长时不会触及小组件边框
                    .padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
                    .frame( geo.size.width, height: 20, alignment: .center)
                    .font(.system(size: 13))
                    .lineLimit(1)
                }
            Spacer().frame( geo.size.width, height:  5 , alignment: .leading)
//            .border(Color.green,  1) //可以查看控件范围
            HStack(alignment: .center, spacing: 0){
                Spacer()
                let money = String(entry.poster.dic["market_price"] as! Int)
                Text("¥0")
                    .foregroundColor(.red)
//                    .background(Color.green)//可以查看范围
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .frame( 30, height: 25, alignment: .leading)
//                    .font(.system(size: 20, weight: .bold, design: .default)) //也可以自定义字体
                    .font(Font.custom("HelveticaNeue-CondensedBold", size: 26))
                    .lineLimit(1)
                let color: Color = Color(red: 0.6, green: 0.6, blue: 0.6)
                Text(money)
                    .foregroundColor(color)
                    .strikethrough(true, color: .gray)
                    .padding(EdgeInsets(top: 7, leading: -4, bottom: 0, trailing: 0))
                    .frame( 40, height: 25, alignment: .leading)
                    .font(.system(size: 13))
                    .lineLimit(1)
//                    .background(color)

                Spacer()

                Text("去抽奖")
                    .foregroundColor(.white)
                    .frame( 50, height: 20, alignment: .center)
                    .font(.system(size: 12, weight: .bold, design: .default))
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .background(Color.orange)
                Spacer()

            }
//            .border(Color.yellow,  1)
            .frame( geo.size.width, height:25 , alignment: .leading)
            .widgetURL(URL(string: "appXXXt://XXX?" + entry.poster.idStr))
        })
        }
    }
}

Widget点击交互

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

1、widgetURL:点击区域是Widget的所有区域,适合元素、逻辑简单的小部件

2、Link:通过Link修饰,允许让界面上不同元素产生点击响应

3、systemSmall只能用widgetURL实现URL传递接收

4、systemMediumsystemLarge可以用Link或者widgetUrl处理

var body: some View {
        Link(destination: URL(string: "跳转链接Link")!){
            VStack{
                //UI编写
            }
        }
    }

接收方式

//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;
}

全部代码

import WidgetKit
import SwiftUI

struct Poster {
    /*
     posterImage:默认图片占位
     */
    let dic: Dictionary<String, Any>
    let idStr: String
    var posterImage: UIImage? = UIImage(named: "getWidgettest")
}
struct PosterData {
    static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) {

        let urlString:String = "XXXXXXXXXXXXXXXXXX"
//        加密,当传递的参数中含有中文时必须加密
       let newUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
       //创建请求配置
       let config = URLSessionConfiguration.default
//        创建请求URL
       let url = URL(string: newUrlString!)
//        创建请求实例
       let request = URLRequest(url: url!)
       
//        进行请求头的设置
//        request.setValue(Any?, forKey: String)
       
//        创建请求Session
       let session = URLSession(configuration: config)
//        创建请求任务
       let task = session.dataTask(with: request) { (data,response,error) in
//            print(String(data: data! , encoding: .utf8) as Any)
//            将json数据解析成字典
//        let dictionary = try? JSONSerialization.jsonObject(with: data!, options: .mutableContainers)
           
        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["data"] as? [Any] else{

            return Poster(dic:["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888], idStr: "1", posterImage: UIImage(named: "getWidgettest"))
          }
          let randomInt = Int(arc4random() % 2)
          let datafirst = result[randomInt] as? [String: Any]
          let idStr = String(datafirst!["id"] as! Int)
          let posterImage = datafirst!["image_url"] as! String
          let vDic = datafirst
          
          //图片同步请求
          var image: UIImage? = nil
          if let imageData = try? Data(contentsOf: URL(string: posterImage)!) {
              image = UIImage(data: imageData)
          }
          
        return Poster(dic:vDic!, idStr: idStr, posterImage: image)
    }
}
struct Provider: TimelineProvider {
    let poster = Poster(dic:["name":"Air Jordan 1 Mid “Chicago”","id":1,"market_price":8888],idStr: "1",posterImage:UIImage(named: "getWidgettest"))
    // 占位视图
    // placeholder:提供一个默认的视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
    func placeholder(in context: Context) -> SimpleEntry {
        return SimpleEntry(date: Date(),poster: poster)

    }
    /*
     编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法
     
     getSnapshot:为了在小部件库中显示小部件,WidgetKit要求提供者提供预览快照,在组件的添加页面可以看到效果
     */
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), poster: poster)
        completion(entry)
    }
    /*
     getTimeline:在这个方法内可以进行网络请求,拿到的数据保存在对应的entry中,调用completion之后会到刷新小组件
     */
    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(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1");
            }
            
            /*
             参数policy:刷新的时机
             .never:不刷新
             .atEnd:Timeline 中最后一个 Entry 显示完毕之后自动刷新。Timeline 方法会重新调用
             .after(date):到达某个特定时间后自动刷新
             
             !!!Widget 刷新的时间由系统统一决定,如果需要强制刷新Widget,可以在 App 中使用 WidgetCenter 来重新加载所有时间线:WidgetCenter.shared.reloadAllTimelines()
             
             Timeline的刷新策略是会延迟的,并不一定根据你设定的时间精确刷新。同时官方说明了每个widget窗口小部件每天接收的刷新都会有数量限制
             */
            
            let entry = Entry(date: currentDate, poster: poster)
            let timeline = Timeline(entries: [entry], policy: .after(updateDate))
            completion(timeline)
        }
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let poster : Poster
}

struct getWidgetEntryView : View {
    var entry: Provider.Entry

    //针对不同尺寸的 Widget 设置不同的 View
    @Environment(.widgetFamily) var family // 尺寸环境变量
    var body: some View {
        //使用 GeometryReader 获取小组件的大小
        GeometryReader{ geo in
        VStack(content: {
            //HStack:纵向布局,默认居中对齐
            VStack(alignment: .center, spacing: 5) {
                let content = entry.poster.dic["name"] as! String
                Text("get  0元抽奖")
                    .padding(EdgeInsets(top: 10, leading: 14, bottom: 0, trailing: 14))
                    .frame( geo.size.width, height: 20, alignment: .leading)
                    .font(.system(size: 14, weight: .bold, design: .default))
                    .lineLimit(1)

                Image(uiImage: entry.poster.posterImage!)
                     .resizable()
                    .frame(60, height: 60)
                     .clipShape(Circle())

                Text(content)
                    // 增加 padding 使 Text 过长时不会触及小组件边框
                    .padding(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
                    .frame( geo.size.width, height: 20, alignment: .center)
                    .font(.system(size: 13))
                    .lineLimit(1)
                }
            Spacer().frame( geo.size.width, height:  5 , alignment: .leading)
//            .border(Color.green,  1)
            HStack(alignment: .center, spacing: 0){
                Spacer()
                let money = String(entry.poster.dic["market_price"] as! Int)
                Text("¥0")
                    .foregroundColor(.red)
//                    .background(Color.green)
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .frame( 30, height: 25, alignment: .leading)
//                    .font(.system(size: 20, weight: .bold, design: .default))
                    .font(Font.custom("HelveticaNeue-CondensedBold", size: 26))
                    .lineLimit(1)
                let color: Color = Color(red: 0.6, green: 0.6, blue: 0.6)
                Text(money)
                    .foregroundColor(color)
                    .strikethrough(true, color: .gray)
                    .padding(EdgeInsets(top: 7, leading: -4, bottom: 0, trailing: 0))
                    .frame( 40, height: 25, alignment: .leading)
                    .font(.system(size: 13))
                    .lineLimit(1)
//                    .background(color)

                Spacer()

                Text("去抽奖")
                    .foregroundColor(.white)
                    .frame( 50, height: 20, alignment: .center)
                    .font(.system(size: 12, weight: .bold, design: .default))
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .background(Color.orange)
                Spacer()

            }
//            .border(Color.yellow,  1)
            .frame( geo.size.width, height:25 , alignment: .leading)
            .widgetURL(URL(string: "appXXX://?XXX=" + entry.poster.idStr))
        })

        }
    }
}

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

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            getWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("get 抽奖")
        .description("更多活动快来参与吧.")
        .supportedFamilies([.systemSmall])
    }
}

struct getWidget_Previews: PreviewProvider {
    static var previews: some View {
        let poster = Poster(dic: ["name":"Air Jordan 1 Mid “Chicago","id":1,"market_price":8888],idStr: "1")
        getWidgetEntryView(entry: SimpleEntry(date: Date(), poster: poster))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

 展示如下

备注:

1.如果发现显示黑色,或者控件显示不全,请检查数据,数据错误会导致这样

2.如果发现xcode真机运行后搜不到小组件,重启手机试一下,这个我遇到过

 结束语

先到这里,刚开始了解设计小组件,有什么不对的地方,还请大佬指教。

参考:https://www.jianshu.com/p/94a98c203763

 
原文地址:https://www.cnblogs.com/ljcgood66/p/14169020.html