SwiftUI 如何创建widgets for iOS14

Widgets小部件在iOS中存在很长一段时间,但iOS 14对其进行了彻底的改进,WWDC2020明确要求使用SwiftUI编写。 iOS 14的小部件有多种类型,范围从简单的信息方块到可以从其父应用程序的Siri Intents检索和显示信息的小部件。

但是,其中最具吸引力的就是可以在主页放置小部件,这意味着从技术上讲,您现在可以制作可视化的“微型应用程序”。如果您发现自己一遍又一遍地执行相同的任务,例如检查某个应用程序支持的版本或最新版本中的崩溃次数,则可以在iOS 14中创建一个小部件。

尽管除了触摸小部件(它会触发应用程序启动)之外,您无法与其进行交互,但是在它们中显示的内容并没有很多限制,因此您可以使用它们来开发只读的可视化应用程序。

开发“快速提交跟踪器”小部件

我发现自己偶尔去Swift仓库,看看社区在做什么。为了让我的生活更轻松,如何在主屏幕上直接显示此信息?为此,我们可以向GitHub的公共API发出请求,解析信息并将其呈现给我们的小部件。

我们可以从创建一个新的iOS项目开始-该项目的细节无关紧要,因为在本教程中,所有代码都将在Widget的模块内。

创建项目后,通过转到File-> New-> Target并选择Widget Extension target添加一个Widget模块。

widgets

确保取消选中“Include Configuration Intent”复选框,因为这涉及一项仅在本文稍后介绍的功能!生成目标后,请确保删除示例代码,以便我们可以逐步检查它。


create widget extension

要定义Widget,您要做的就是创建一个从Widget继承并配置其功能的结构:

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

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: CommitTimeline(), placeholder: PlaceholderView()) { entry in
            CommitCheckerWidgetView(entry: entry)
        }
        .configurationDisplayName("Swift's Latest Commit")
        .description("Shows the last commit at the Swift repo.")
    }
}

在定义此类功能之前,不会编译此代码,但这对于第一步很重要,因为这是Widget本身。 WidgetConfiguration返回值描述此窗口小部件是什么以及如何构建,但最重要的是,它如何获取其内容。

小部件类型

StaticConfiguration WidgetConfiguration定义了无需用户任何输入即可自行解决的Widget。您可以在Widget的父应用中获取任何相关数据,并将结果作为“用户输入”发送到Widget模块,但是由于在配置Widget时可以进行API调用,因此如果没有上下文,则无需这样做请求中涉及的信息。

另一方面,您可以使用IntentConfiguration WidgetConfiguration定义一个依赖于父应用程序中Siri Intent的Widget,它允许您构建可配置的动态Widget。例如,当使用意图时,食品配送应用程序可以创建一个小部件,以显示用户最新订单的配送状态。这是通过让应用程序分派一个Siri Intent(就像开发Siri快捷方式时一样)来完成的,这些IntentConfiguration会自动将它们拾取并用于更新Widget。您可以通过在创建Widget扩展时选中intent框来创建基本的IntentConfiguration Widget,但是由于我们要做的只是解析GitHub的公共API,因此我们可以使用StaticConfiguration Widget并避免与应用程序本身进行交互。

时间线提供者

iOS 14的小部件显示的内容类似于watchOS的复杂性,在某种意义上,您无需提供一直在运行的扩展程序,而是一次提供了操作系统应在整个小时,几天内显示的事件的“时间表”甚至几个星期这对于“天气”和“日历”等应用程序很有用,您可以在其中“预测”将来将要显示的内容,因为它们已经具有该信息。

在我们的案例中,由于我们无法预测Swift的提交,因此我们将提供一个仅包含单个事件的时间轴-使iOS更定期地刷新Widget。

要创建时间线,我们首先需要定义一个TimelineEntry。当预期在小部件中呈现此条目时,TimelineEntry仅需要日期,但是它也可以包含您需要的任何其他信息。在我们的例子中,我们的条目将包含我们要在小部件中显示的提交。

struct Commit {
    let message: String
    let author: String
    let date: String
}

struct LastCommitEntry: TimelineEntry {
    public let date: Date
    public let commit: Commit
}

但是在创建时间轴之前,我们需要能够获取此类提交。让我们创建一个CommitLoader类,该类获取并解析Swift的最新提交:

struct Commit {
    let message: String
    let author: String
    let date: String
}

struct CommitLoader {
    static func fetch(completion: @escaping (Result<Commit, Error>) -> Void) {
        let branchContentsURL = URL(string: "https://api.github.com/repos/apple/swift/branches/master")!
        let task = URLSession.shared.dataTask(with: branchContentsURL) { (data, response, error) in
            guard error == nil else {
                completion(.failure(error!))
                return
            }
            let commit = getCommitInfo(fromData: data!)
            completion(.success(commit))
        }
        task.resume()
    }

    static func getCommitInfo(fromData data: Foundation.Data) -> Commit {
        let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
        let commitParentJson = json["commit"] as! [String: Any]
        let commitJson = commitParentJson["commit"] as! [String: Any]
        let authorJson = commitJson["author"] as! [String: Any]
        let message = commitJson["message"] as! String
        let author = authorJson["name"] as! String
        let date = authorJson["date"] as! String
        return Commit(message: message, author: author, date: date)
    }
}

调用fetch时,此加载程序类型将请求发送到GitHub的公共API并解析最新的提交-为我们提供消息,作者及其时间戳。现在,我们可以创建一个时间轴,以获取最新的提交,将其添加为条目,并计划在一段时间后进行更新。

struct CommitTimeline: TimelineProvider {
    typealias Entry = LastCommitEntry
    /* protocol methods implemented below! */
}

TimelineProvider协议有两种我们需要实现的方法:

snapshot()-小部件的伪造信息
TimelineProvider协议所需的snapshot()方法定义了当Widget在瞬态情况下(例如Widget选择屏幕)出现时,应该如何配置Widget。当显示正确的信息无关紧要时,将使用此配置:

要创建快照配置,所有要做的就是创建并返回TimelineEntry对象的假条目。

public func snapshot(with context: Context, completion: @escaping (LastCommitEntry) -> ()) {
    let fakeCommit = Commit(message: "Fixed stuff", author: "John Appleseed", date: "2020-06-23")
    let entry = LastCommitEntry(date: Date(), commit: fakeCommit)
    completion(entry)
}

timeline()-小部件的真实信息
但是,timeline()方法定义了窗口小部件应使用的真实信息。目的是让您返回一个时间轴实例,其中包含要显示的所有条目,预期显示的条目(条目的日期)以及时间轴“到期”的时间。

由于我们的应用程序无法像Weather应用程序那样“预测”其未来状态,因此我们只需创建一个具有立即显示的单个条目的时间轴即可,这可以通过将条目的日期设置为当前Date()来完成。 :

public func timeline(with context: Context, completion: @escaping (Timeline<LastCommitEntry>) -> ()) {
    let currentDate = Date()
    let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!

    CommitLoader.fetch { result in
        let commit: Commit
        if case .success(let fetchedCommit) = result {
            commit = fetchedCommit
        } else {
            commit = Commit(message: "Failed to load commits", author: "", date: "")
        }
        let entry = LastCommitEntry(date: currentDate, commit: commit)
        let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
        completion(timeline)
    }
}

时间轴的policy属性定义了iOS何时应尝试丢弃此时间轴并获取新的时间轴。当前,它们可以是.never(显示静态内容的小部件,这些内容永远不变)、. atEnd(显示时间线中的最后一个条目时)或.after(Date),即显示完后的特定时间后时间表。由于我们的时间轴只有一个条目,因此我决定使用.after告诉iOS该小部件应每5分钟重新加载一次。

但是请注意,Widget API的文档指出您无法预测何时更新Widget。即使确实会在5分钟后再次获取时间轴本身,也无法保证iOS会同时更新视图。从我撰写本文的个人经验来看,视图实际上需要大约20分钟的时间来更新。更新时间基于几个因素,其中包括用户看到窗口小部件的频率。如果需要强制更新小部件,则可以使用主应用程序中的WidgetCenter API重新加载所有时间轴(或特定时间轴):

WidgetCenter.shared.reloadAllTimelines()

尽管我们的Widget不需要这样做,但重要的是要提到时间表和条目还有其他有趣的功能。例如,可以为条目设置相关性值,这将使iOS可以确定小部件的重要性。例如,这用于确定堆栈内小部件的顺序:

struct LastCommit: TimelineEntry {
    public let date: Date
    public let commit: Commit

    var relevance: TimelineEntryRelevance? {
        return TimelineEntryRelevance(score: 10) // 0 - not important | 100 - very important
    }
}

创建小部件视图

现在我们已经配置了时间线,我们可以创建小部件的可视组件。我们需要创建两个视图:在加载时间线时显示的占位符,以及能够呈现时间线条目的实际Widget视图。

struct PlaceholderView : View {
    var body: some View {
        Text("Loading...")
    }
}

struct CommitCheckerWidgetView : View {
    let entry: LastCommitEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("apple/swift's Latest Commit")
                .font(.system(.title3))
                .foregroundColor(.black)
            Text(entry.commit.message)
                .font(.system(.callout))
                .foregroundColor(.black)
                .bold()
            Text("by \(entry.commit.author) at \(entry.commit.date)")
                .font(.system(.caption))
                .foregroundColor(.black)
            Text("Updated at \(Self.format(date:entry.date))")
                .font(.system(.caption2))
                .foregroundColor(.black)
        }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .leading)
            .padding()
            .background(LinearGradient(gradient: Gradient(colors: [.orange, .yellow]), startPoint: .top, endPoint: .bottom))
    }

    static func format(date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MM-dd-yyyy HH:mm"
        return formatter.string(from: date)
    }
}

您应该能够编译您的代码,并且现在已经提供了所有组件,我们可以再次查看我们的配置方法,以了解如何将它们全部包装在一起:

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

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: CommitTimeline(), placeholder: PlaceholderView()) { entry in
            CommitCheckerWidgetView(entry: entry)
        }
        .configurationDisplayName("Swift's Latest Commit")
        .description("Shows the last commit at the Swift repo.")
    }
}

我们创建了一个静态小部件,该小部件可从CommitTimeline获取其内容,具有PlaceholderView作为占位符,并在准备显示条目时生成CommitCheckerWidgetView。

在运行我们的应用程序并将小部件添加到我们的家中之后,我们现在可以看到一个自动更新的Swift提交显示器!

允许用户配置要可视化的仓库/分支

如前所述,iOS 14的新API还支持与Siri Intent绑定的Widget,从而使您可以创建可由用户配置的动态Widget。我们可以有一个基于意图的Widget,允许用户直接从Widget本身配置要观看的仓库。

要创建基于意图的Widget,我们首先需要一个Siri意图。在您的窗口小部件扩展中,添加一个SiriKit Intent Definition File。

为了允许用户查看任何回购或分支的提交,让我们创建一个支持account,回购和分支属性的LastCommitIntent。确保也选中“小部件的意图”框。

可以将Widgets与任何捐赠的Siri Intent的数据一起使用,但是魔术之处在于不需要这样做。如果该意图具有窗口小部件功能(如我们创建的那样),则您可以直接在窗口小部件上设置参数,稍后我们将看到。

升级Widget之前,请确保我们的代码支持从其他存储库中获取提交。让我们升级时间线条目以支持回购配置:

struct RepoBranch {
    let account: String
    let repo: String
    let branch: String
}

struct LastCommit: TimelineEntry {
    public let date: Date
    public let commit: Commit
    public let branch: RepoBranch
}

从这里,我们可以升级fetcher的fetch()方法,以从任何存储库下载任何分支:

static func fetch(account: String, repo: String, branch: String, completion: @escaping (Result) -> Void) {
    let branchContentsURL = URL(string: "https://api.github.com/repos/\(account)/\(repo)/branches/\(branch)")!
    // ...
}

如前所述,基于意图的窗口小部件需要使用IntentConfiguration,它与我们之前的静态方法的唯一主要区别在于,我们必须提供此窗口小部件链接到的意图。让我们更新小部件以使用IntentConfiguration和LastCommitIntent:

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

    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: LastCommitIntent.self, provider: CommitTimeline(), placeholder: PlaceholderView()) { entry in
            CommitCheckerWidgetView(entry: entry)
        }
        .configurationDisplayName("A Repo's Latest Commit")
        .description("Shows the last commit at the a repo/branch combination.")
    }
}

我们必须进行的另一项修改是更新时间轴以从IntentTimelineProvider继承,而不是从TimelineProvider继承。它们的工作方式大致相同,不同之处在于intents变体提供对我们intent实例的访问,从而使我们能够掌握用户所做的任何自定义。在这种情况下,我们将更新snapshot()以另外返回伪造的仓库和我们的时间轴方法以获取用户的仓库配置并使用这些参数来获取提交。

struct CommitTimeline: IntentTimelineProvider {
    typealias Entry = LastCommit
    typealias Intent = LastCommitIntent

    public func snapshot(for configuration: LastCommitIntent, with context: Context, completion: @escaping (LastCommit) -> ()) {
        let fakeCommit = Commit(message: "Fixed stuff", author: "John Appleseed", date: "2020-06-23")
        let entry = LastCommit(
            date: Date(),
            commit: fakeCommit,
            branch: RepoBranch(
                account: "apple",
                repo: "swift",
                branch: "master"
            )
        )
        completion(entry)
    }

    public func timeline(for configuration: LastCommitIntent, with context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let currentDate = Date()
        let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!

        guard let account = configuration.account,
              let repo = configuration.repo,
              let branch = configuration.branch
        else {
            let commit = Commit(message: "Failed to load commits", author: "", date: "")
            let entry = LastCommit(date: currentDate, commit: commit, branch: RepoBranch(
                account: "???",
                repo: "???",
                branch: "???"
            ))
            let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
            completion(timeline)
            return
        }

        CommitLoader.fetch(account: account, repo: repo, branch: branch) { result in
            let commit: Commit
            if case .success(let fetchedCommit) = result {
                commit = fetchedCommit
            } else {
                commit = Commit(message: "Failed to load commits", author: "", date: "")
            }
            let entry = LastCommit(date: currentDate, commit: commit, branch: RepoBranch(
                account: account,
                repo: repo,
                branch: branch
            ))
            let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
            completion(timeline)
        }
    }
}

尽管此代码有效,但我们的视图仍将“ apple / swift”硬编码到其中。让我们对其进行更新以使用条目现在拥有的新参数:

struct RepoBranchCheckerEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("\(entry.branch.account)/\(entry.branch.repo)'s \(entry.branch.branch) Latest Commit")
                .font(.system(.title3))
                .foregroundColor(.black)
            Text("\(entry.commit.message)")
                .font(.system(.callout))
                .foregroundColor(.black)
                .bold()
            Text("by \(entry.commit.author) at \(entry.commit.date)")
                .font(.system(.caption))
                .foregroundColor(.black)
            Text("Updated at \(Self.format(date:entry.date))")
                .font(.system(.caption2))
                .foregroundColor(.black)
        }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .leading)
            .padding()
            .background(LinearGradient(gradient: Gradient(colors: [.orange, .yellow]), startPoint: .top, endPoint: .bottom))
    }

    static func format(date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MM-dd-yyyy HH:mm"
        return formatter.string(from: date)
    }
}

现在,运行您的应用程序并检查Widget。您看到的回购配置将由您在intents文件中添加的默认值确定,但是如果您长按Widget并单击Edit(编辑)按钮,现在将能够自定义Intent的参数并更改获取的回购!

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。