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) -> ()) {
            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) -> ()) {
    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) -> ()) {
        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?, 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) -> 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

你可能感兴趣的:(iOS Widgets)