• 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

     
  • 相关阅读:
    mybatis-plus解析
    ybatis中查询出多个以key,value的属性记录,封装成一个map返回的方法
    mybatis-plus分页记坑
    ComponentScan注解的使用
    fastJson序列化
    SpringBoot-RestTemplate测试Controller
    configparser模块
    python 将乱码转为汉字
    1.x 版本Django对应rest_framework版本
    docker容器内执行linux的dmidecode命令
  • 原文地址:https://www.cnblogs.com/ljcgood66/p/14169020.html
Copyright © 2020-2023  润新知