iOS-Widget小组件开发实战与问题解决

文档

官方介绍
官方文档
官方文档-可用组件
官方文档翻译-非最新
官方文档-用户交互指南
主要参考文档
修改为IntentConfiguration
简单demo
各种widget效果demo

开发须知

  • iOS14 以上支持 widget,iOS16.1以上支持 LiveActivity
  • 使用 SwiftUI 开发,在 iOS 上只有小、中、大三种尺寸
  • iOS16以下不支持动效,iOS17以上支持动效
  • 小组件的运行内存,只有30M,确保不进行大量内存消耗的操作。特别是避免一次性加载大量数据或图片。
  • widget中,并不是所有的视图组件都可用。只是大部分可用。并且不能使用基于 UIViewRepresentable 包裹的 UIKit 组件,详见可用组件。但是可以通过截图的方式,获取image,加载到Image(uiImage:)上来变相使用 UIKit 组件展示。
  • 如果widget和主工程用同一个视图,视图基于UIKit实现,那么可以通过 UIGraphicsGetImageFromCurrentImageContext 将视图生成 UIImage,widget中只以 Image 加载的方式显示,间接绕过 widget 中对 UIKit 的限制
  • widget只能通过 Timeline 更新数据
  • 可以支持多语言,与主工程类似,创建Localizable.strings即可
  • 使用 App Group 与主工程共享数据,例如 网络请求用到的登录用户 token
  • 点击 widget,是通过主工程打点,以 scheme 区分
  • 判断 widget 是否安装到桌面上,可以在 widget 刷新方法里,使用 AppGroup 记录标志位,也可以调用widget的函数进行判断
  • widget 每天的刷新次数有限制,但具体多少次官方文档没说,只说有几个因素会影响小部件接收的刷新次数,例如包含应用程序是在前台还是后台运行、小组件在屏幕上显示的频率以及包含应用程序参与的活动类型
  • 当 App 位于前台时,调用 reloadTimelines(ofKind:) 刷新小组件,不会计入 widget 的每日限制
  • image.png

核心代码解释

  • 创建Widget时,可选Include Configuration Intent。开启的话,小组件可以支持长按弹出自定义选项的功能,类似 App 的 3DTouch 选项。不开启,则小组件长按只有“编辑主屏幕” 和 “移除小组件”
  • StaticConfiguration,静态widget,没有开启IntentConfiguration,不需要动态更新的小组件
  • IntentConfiguration,可以自动接收 Intent 更新的小组件
  • 创建好 Widget,会自动生成模版代码,只需要在上面修改相应代码即可
// 入口代码,如果需要支持多个相同尺寸的不同widget,需要在这里添加widget2(),widget3()..
// 每个widget,都可以支持小中大三种尺寸
@main
struct widgetBundle: WidgetBundle {
    var body: some Widget {
        widget()
        widget2()
        widgetLiveActivity()
    }
}
// widget组件
struct widget: Widget {
    // widget唯一标识符
//    let kind: String = FSWidgetKindString
    let kind: String = "com.company.project.widget"

    // 添加 widgetEntryView 为 widget 的视图
    // supportedFamilies,设定当前widget支持的尺寸,可以设置小中大三种
    // configurationDisplayName,显示在小组件库中的名字
    // description,显示在小组件库中的描述
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            widgetEntryView(entry: entry)
        }.supportedFamilies([.systemMedium])
        .configurationDisplayName("WidgetDemo")
        .description("New Message")
    }
}
// 时间线更新
struct Provider: TimelineProvider {
    // 显示到主屏幕,等待数据加载,显示的内容
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
    }

    // 预览和小组件库中的显示
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
        completion(entry)
    }

    // **最重要的方法**,后续生成新的entry,用于刷新界面,都是在这里完成
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
        // 或者发起API请求,回调请求内设置 timeline的更新策略
        // API.fetchData{ result in }
        
        // 每分钟更新一次
        let currentDate = Date()
        let updateDate = Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!
        
        // 生成新的entry数据
        let entry = SimpleEntry(date: Date(), image: UIImage(named: "placeholder")!)
        
        let timeline = Timeline(entries: [entry], policy: .after(updateDate))
        completion(timeline)
    }
}
// 更新widget所需要的所有数据
struct SimpleEntry: TimelineEntry {
    let date: Date
    let image: UIImage    // 当前需要展示的图片
}
// widget显示的内容,可以针对不同大小的widget设置不同的View
struct widgetEntryView: View {
    var entry: Provider.Entry

    @Environment(\.widgetFamily) var family

    @ViewBuilder
    var body: some View {
        switch family {
        case .systemSmall:
            // 小,只支持widgetURL,点击widget跳转指定scheme
            ZStack {
                Image(uiImage: entry.image)
                            .resizable()
                            .scaledToFill()
            }.widgetURL(URL(string: "demoapp://homepage")!)

        case .systemMedium:
            // 中,同时支持Link和widgetURL
            ZStack {
                Image(uiImage: entry.image)
                            .resizable()
                            .scaledToFill()
                Link(destination: URL(string: "demoapp://link")!) {
                    Text("点击跳转")
                }
            }.widgetURL(URL(string: "demoapp://homepage")!)

        default:
            // 大,同时支持Link和widgetURL
            ZStack {
                Image(uiImage: entry.image)
                            .resizable()
                            .scaledToFill()
                Link(destination: URL(string: "demoapp://link")!) {
                    Text("点击跳转")
                }
            }.widgetURL(URL(string: "demoapp://homepage")!)
        }
    }
}

与主App通过Group共享数据

// UserDefault 共享
let DRGroupDefaultSuitName = "group.com.demo"

func DRGroupDefault() -> UserDefaults {
    return UserDefaults(suiteName: DRGroupDefaultSuitName)!
}

func DRGroupDefaultSet(_ value: Any?, forKey defaultName: String) {
    DRGroupDefault().set(value, forKey: defaultName)
}

func DRGroupDefaultObject(forKey defaultName: String) -> Any? {
    return DRGroupDefault().object(forKey: defaultName)
}

func DRGroupDefaultBool(forKey defaultName: String) -> Bool {
    return DRGroupDefault().bool(forKey: defaultName)
}

func DRGroupDefaultSynchronize() {
    let userDefault: UserDefaults = DRGroupDefault()
    
    if Thread.isMainThread {
        NSObject.cancelPreviousPerformRequests(withTarget: userDefault, selector: #selector(userDefault.synchronize), object: nil)
        userDefault.perform(#selector(userDefault.synchronize), with: nil, afterDelay: 2.0)
    } else {
        userDefault.synchronize()
    }
}
// File 文件共享
let DRGroupFileIdentifier = "group.com.demo"

func DRGroupFileURL() -> URL? {
    return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: DRGroupFileIdentifier)
}

func DRGroupFileWrite(toFile fileName: String, content: String) -> Bool {
    guard let groupURL = DRGroupFileURL() else {
        return false
    }
    
    let fileURL = groupURL.appendingPathComponent(fileName)
    print(fileURL)
    
    if DRFileManager.isExist(atPath: fileURL.absoluteString) {
        DRFileManager.removeItem(atPath: fileURL.absoluteString)
    }

    do {
        try content.write(to: fileURL, atomically: true, encoding: .utf8)
        print("writeDataToFile 写入文件成功")
        return true
    } catch {
        print("writeDataToFile 写入文件失败")
        return false
    }
}

func DRGroupFileRead(fromFile fileName: String) -> String {
    guard let groupURL = DRGroupFileURL() else {
        return ""
    }
    
    let fileURL = groupURL.appendingPathComponent(fileName)
    print(fileURL)
    
    guard let contentData = FileManager.default.contents(atPath: fileURL.path) else {
        return ""
    }
    let content = String(data: contentData, encoding: .utf8)
    return content ?? ""
}

主App基于UIKit实现View,widget引入图片

// 主App
public struct TestPainting {
    
    static func uiKitView() -> UIView {
        let view = UIImageView.init(frame: CGRect(x: 0, y: 0, width: DRHelper.screen_width, height: 180))
        view.backgroundColor = .cyan
        view.roundingCorners(corners: .allCorners, radii: 20)
        
        let image = UIImage(named: "icon_placeholder")
        view.image = image
        view.contentMode = .center
        
        return view
    }
    
    
    public static func uiKitContextImage() -> UIImage? {
        let  imageView = self.uiKitView()
        let image = imageView.toImage()
        return image
    }
    
}

extension UIView {
    func toImage() -> UIImage? {
        let scale = UIScreen.main.scale
        UIGraphicsBeginImageContextWithOptions(self.frame.size, false, scale)
        guard let context = UIGraphicsGetCurrentContext() else {
            UIGraphicsEndImageContext()
            return nil
        }
        self.layer.render(in: context)
        let viewImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return viewImage
    }
}
// widget
struct widgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        ZStack {
            let image = TestPainting.uiKitContextImage()
            Image(uiImage: image ?? UIImage(named: "placeholder")!)
                .resizable()
                .scaledToFill()
        }.widgetURL(URL(string: "demo://homepage")!)
    }
}
    // 主App 判断是否添加了小组件
    static func isWidgetExist(complete: @escaping (_ exist: Bool) -> Void) {
        WidgetCenter.shared.getCurrentConfigurations { result in
            var exist = false
            defer {
                complete(exist)
            }
            guard case let .success(widgets) = result else {
                return
            }
            let currentWidget = widgets.first(where: { widget in
                debugPrint("小组件名称:\(widget.kind)")
                return widget.kind == DRConstantKey.widgetKindString
            })
            if currentWidget != nil {
                exist = true
            }
        }
    }

真机运行报错

  • 手机系统更新到最新,xcode更新到最新,可以解决所有的报错。不用去修改widget 的 edit scheme,XCWidgetKind,勾选XCWidgetDefaultView啥的,更新到最新,直接跑就行
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容