iOS Widgets

iOS14 以前的 Widget

项目构成

  • The main app:

    项目原工程。

  • A Today extension containing the widget:

    这部分里面是Widget的生命周期,UI以及数据展示。

  • An embedded framework for shared code:

    这部分是把 widget 和原工程需要共同使用的分代码放到这个模块里来,比如一些 Model 和网络请求数据的方法。

尺寸

compact

expanded

小部件的尺寸分为两种:一种是 compact,一种是 expanded。如图所示点击箭头按钮进行切换。

func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
    switch activeDisplayMode {
    case .compact:
        // The compact view is a fixed size.
    case .expanded:
        // Dynamically calculate the height of the cells for the extended height.
    @unknown default:
        preconditionFailure("Unexpected value for activeDisplayMode.")
    }
}

界面跳转

从 Widget 界面跳转到 app 里面通过 URL scheme 的方案。

widget 中构造好URL 调用 open(_:completionHandler:) 方法,打开 app 。

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let weatherForecast = weatherForecastData[indexPath.row]
    if let appURL = URL(string: "weatherwidget://?daysFromNow=\(weatherForecast.daysFromNow)") {
        extensionContext?.open(appURL, completionHandler: nil)
    }
    tableView.deselectRow(at: indexPath, animated: true)
}

共享数据

widget 和 主 app 之间本地数据共享,通过 AppGroup 的方式,添加权限和创建 AppGroup 后,可以使用 containerURL(forSecurityApplicationGroupIdentifier:) 方法,通过 URL 获取共享目录下面的数据。

static var sharedDataFileURL: URL {
    let appGroupIdentifier = "group.com.example.apple-samplecode.WeatherWidget"
    guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
        else { preconditionFailure("Expected a valid app group container") }
    return url.appendingPathComponent("Data.plist")
}

iOS14 WidgetKit

简介

使用新的 WidgetKit 框架和 SwiftUI 关于 widget 新的 api,创建的 widget能满足 iOS,iPadOS,和 macOS 平台,在手机上不仅能显示在手机 Today View 的地方,还能放在 Home screen 的任意位置。而且还拥有 Smart Stacks 功能,能根据你使用时间,位置等因素,来智能显示组件。

Families

Small

Medium

Large

Widget 尺寸有三种如上图所示,分别是.systemSmall, .systemMedium, .systemLarge。

项目构建

创建一个 Widget Extension 的 target


NfzaP1.png

创建的 swift 文件里面会有一些默认代码。包含了 widget 的大致结构。

NhPLvt.png
  • Configuration:

    • StaticConfiguration:没有用户可配置属性的窗口小部件。例如,显示一般市场信息的股市小部件,或显示趋势头条的新闻窗口小部件。
    • IntentConfiguration:对于具有用户可配置属性的窗口小部件。使用SiriKit自定义意图来定义属性。例如,需要一个城市的邮政编码的天气小部件。

    以下代码创建一个常规的,不可配置的状态的 Widget:

    @main
    struct Test: Widget {
        private let kind: String = "Test"
        
        public var body: some WidgetConfiguration {
            StaticConfiguration(kind: kind, provider: Provider(), placeholder: PlaceholderView()) { entry in
                TestEntryView(entry: entry)
            }
            .configurationDisplayName("My Widget")
            .description("This is an example widget.")
        }
    }
    
    • @main属性是 Widget 的入口点。
    • kind 是 Widget 的一个标识符。
    • provider 是一个用来刷新 widget 的时间线。
    • placeholder view: 就是占位的视图。
    • Content Closure: Widget 视图。
  • Provider

    struct Provider: TimelineProvider {
        public typealias Entry = SimpleEntry
    
        public func snapshot(with context: Context, completion: @escaping (SimpleEntry) -> ()) {
            let entry = SimpleEntry(date: Date())
            completion(entry)
        }
    
        public func timeline(with context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
            var entries: [SimpleEntry] = []
            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)
        }
    }   
    
    • snapshot:

      快照预览为了在小部件库中显示小部件。

    • timelines:

      提供一个时间轴,里面包含一个或多个 entry 来控制 widget 的刷新。

  • PlaceholderView

    Widget 占位图有一个属性 isPlaceholder, 系统更具你的 view 自动渲染一个占位图。

    N55Vn1.png

    N55yBq.png

    目前在Beta1中还没有这个属性。

Widget 刷新

可以通过创建一个 Timeline,里面可以包含一个或多个 entry,每个 entry 有自己的日期和时间,来更新 Widget。每个 Timeline 都有一个自己刷新策略。

public struct TimelineReloadPolicy : Equatable {
    public static let atEnd: TimelineReloadPolicy
    public static let never: TimelineReloadPolicy
    public static func after(_ date: Date) -> TimelineReloadPolicy
} 

Timeline 有3个加载策略:

  • atEnd: timeline 中最后一个 entry 显示后更新。timelines 方法会重新调用。
  • after(date): 指定日期,重新更新timeline。
  • never:系统不会自动更新,除非我们主动通过 Widget Center Api 来更新。

下面以显示角色健康状况的游戏小部件的示例。当健康水平低于100%时,角色每小时以25%的速度恢复。例如,当角色的健康量为25%时,需要3个小时才能完全恢复到100%。下面是创建 Timeline 的代码,时间间隔为 1 H。

func timeline(for configuration: CharacterSelectionIntent, with context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> ()) {
    let selectedCharacter = characher(for: configuration)
    let endDate = selectedCharacter.fullHealthDate
    let oneHour: TimeInterval = 60 * 60
    var currentDate = Date()
    var entries: [SimpleEntry] = []

    while currentDate < endDate {
        let relevance = TimelineEntryRelevance(score: Float(selectedCharacter.healthLevel))
        let entry = SimpleEntry(date: currentDate, character: selectedCharacter, relevance: relevance)
        currentDate += oneHour
        entries.append(entry)
    }
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

下图显示了 WidgetKit 如何请求时间轴,并在时间轴条目中指定的每个时间渲染窗口小部件。

N7ZYWt.png

最开始会请求 Timeline,并创建4个 entry。当每个 entry 日期到达的时候,WidgetKit 调用 content 闭包,来重新绘制 Widget。由于策略是 atEnd,当最后一个 entry 显示后,重新调用 Timeline 方法。

Smart Stacks

当我们的 Widget 放到智能 Stack 中,系统会智能的显示它,我们可以给系统一些提示关于我们认为 Widget 可以优先显示的时间。通过 relevance 属性。

struct SimpleEntry: TimelineEntry {
    public let date: Date
    let character: CharacterDetail
    var relevance: TimelineEntryRelevance?
}

let relevance = TimelineEntryRelevance(score: Float(selectedCharacter.healthLevel))
let entry = SimpleEntry(date: currentDate, character: selectedCharacter, relevance: relevance)

我们可以在 entry 的结构体中添加 relevance 属性,初始化 entry 时传入我们设置 TimelineEntryRelevance,score 的值就是一个比例值,表示与同一时间线中 entry 和的其他 entry 相比的重要性。

Intent

Widget 通过向您的项目添加自定义SiriKit意向定义,为用户提供自定义 Widget 选项。

  • Xcode 创建 SiriKit Intent Definition File 文件。


    NTWYgH.png
  • 这里配置一下自定义的意图。hero 是我们添加可供用户选择的参数。


    NT4GCT.png
  • hero 参数设置为枚举类型。我们自定义好枚举值。


    NT5Jot.png
  • 代码中之前的 StaticConfiguration 换成 IntentConfiguration,intent 参数就传递我们创建的 CharacterSelection Intent。

@main
struct EmojiRangerWidget: Widget {
    private let kind: String = "EmojiRangerWidget"

    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: CharacterSelectionIntent.self, provider: Provider(), placeholder: PlaceholderView()) { (entry) in
            EmojiRangerWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Ranger Detail")
        .description("See your favorite ranger.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

然后 Provider 换成 IntentTimeLineProvider, snapshot 和 timeline 里面的 entry 都更具我们传的 CharacterSelectionIntent 里面自定义参数来配置。

struct Provider: IntentTimelineProvider {

    public typealias Entry = SimpleEntry

    func characher(for configuration: CharacterSelectionIntent) -> CharacterDetail {
        switch configuration.hero {
        case .panda:
            return .panda
        case .egghead:
            return .egghead
        case .spouty:
            return .spouty
        default:
            return .panda
        }
    }

    func snapshot(for configuration: CharacterSelectionIntent, with context: Self.Context, completion: @escaping (Self.Entry) -> ()) {
        let character = characher(for: configuration)
        let entry = SimpleEntry(date: Date(), character: character)
        completion(entry)
    }

    func timeline(for configuration: CharacterSelectionIntent, with context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> ()) {
        let selectedCharacter = characher(for: configuration)
        let endDate = selectedCharacter.fullHealthDate
        let oneMinute: TimeInterval = 60
        var currentDate = Date()
        var entries: [SimpleEntry] = []

        while currentDate < endDate {
            let relevance = TimelineEntryRelevance(score: Float(selectedCharacter.healthLevel))
            let entry = SimpleEntry(date: currentDate, character: selectedCharacter, relevance: relevance)

            currentDate += oneMinute
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)

        completion(timeline)
    }
}

Background URLSession

Widget 能够响应他创建的所以 URLSeesions 包括 background session。但区别传统 App delegate 的方式,通过 onBackgroundURLSessionEvents 回调闭包的方式。

public var body: some WidgetConfiguration {
    StaticConfiguration(kind: kind, provider: LeaderboardProvider(), placeholder: LeaderboardPlaceholderView()) { entry in
        LeaderboardWidgetEntryView(entry: entry)
    }
    .configurationDisplayName("Ranger Leaderboard")
    .description("See all the rangers.")
    .supportedFamilies([.systemLarge])
    .onBackgroundURLSessionEvents {
        (sessionIdentifier, competion) in
    }

}

Link

从 Widget 跳转到 App 指定界面,只需要用 SwiftUI Link 的方式,在单个 Cell View 的外层包上一个 Link,destination 是设定好的 url,就能实现跳转了。

var body: some View {
    VStack(spacing: 48) {
        ForEach(
            characters.sorted { $0.healthLevel > $1.healthLevel }, id: \.self) { character in
            Link(destination: character.url) {
                HStack {
                    Avatar(character: character)
                    VStack(alignment: .leading) {
                        Text(character.name)
                            .font(.headline)
                            .foregroundColor(.white)
                        Text("Level \(character.level)")
                            .foregroundColor(.white)
                        HealthLevelShape(level: character.healthLevel)
                            .frame(height: 10)
                    }
                }
            }
        }
    }
}

Widget bundles

如果你有多个种类 Widget,那需要用 @WidgetBundleBuilder 把多个 Widget 放在一起。

@main
struct EmojiBundle: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        EmojiRangerWidget()
        LeaderboardWidget()
    }
}

Dynamic configuration

之前我们配置了自定义的小组件,但是自定义的枚举值都是我们写死的,如果我们想动态的添加这些值,就要用到 intents Extension.

Nb9IYV.png

intentdefinition 文件的配置和之前配置差不多。Hero 里面有两个默认的属性 identifier 和 displayString。

NbP9Nq.png
class IntentHandler: INExtension, DynamicCharacterSelectionIntentHandling {
    
    func provideHeroOptionsCollection(for intent: DynamicCharacterSelectionIntent,
                                      with completion: @escaping (INObjectCollection<Hero>?, Error?) -> Void) {
        let characters: [Hero] = CharacterDetail.availableCharacters.map { character in
            let hero = Hero(identifier: character.name, display: character.name)

            return hero
        }

        let remoteCharacters: [Hero] = CharacterDetail.remoteCharacters.map { (character) in
            let hero = Hero(identifier: character.name, display: character.name)
            return hero
        }
        
        let collection = INObjectCollection(items: characters + remoteCharacters)
        
        completion(collection, nil)
    }
    
    override func handler(for intent: INIntent) -> Any {
        return self
    }
}

这里代码里面包含了默认 items,加上远程获取的 items。
然后把 Provider 里面的 Intent 替换为 DynamicCharacterSelectionIntent。CharacterDetail 通过 identifier 来获取到。

struct Provider: IntentTimelineProvider {
    typealias Intent = DynamicCharacterSelectionIntent
    
    public typealias Entry = SimpleEntry
    
    func character(for configuration: DynamicCharacterSelectionIntent) -> CharacterDetail {
        let name = configuration.hero?.identifier
        return CharacterDetail.characterFromName(name: name)
    }

    public func snapshot(for configuration: DynamicCharacterSelectionIntent, with context: Context, completion: @escaping (SimpleEntry) -> Void) {
        let entry = SimpleEntry(date: Date(), relevance: nil, character: .panda)
        
        completion(entry)
    }

    public func timeline(for configuration: DynamicCharacterSelectionIntent, with context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
        let selectedCharacter = character(for: configuration)

        let endDate = selectedCharacter.fullHealthDate
        let oneMinute: TimeInterval = 60
        var currentDate = Date()
        var entries: [SimpleEntry] = []
        
        while currentDate < endDate {
            let relevance = TimelineEntryRelevance(score: Float(selectedCharacter.healthLevel))
            let entry = SimpleEntry(date: currentDate, relevance: relevance, character: selectedCharacter)
            
            currentDate += oneMinute
            entries.append(entry)
        }
        
        let timeline = Timeline(entries: entries, policy: .atEnd)
        
        completion(timeline)
    }
}

以上内容参考资料链接:

Meet WidgetKit

Build SwiftUI views for widgets

Widgets Code-along, part 1: The adventure begins

Widgets Code-along, part 2: Alternate timelines

Widgets Code-along, part 3: Advancing timelines

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