如何刷新小部件 iOS14 中的多个计时器?

How to refresh multiple timers in widget iOS14?

我目前正在使用 SwiftUI 开发一个应用程序,并试图让 widget iOS 14 用户可以检查计时器列表。

这个 Widget 有多个 Text(Data(),style: .timer) 来显示一些日期数据作为计时器。当计时器的剩余值结束时,我想像这样显示它 00:00.

所以我参考了这篇

getTimeline函数中实现了一些方法

但我不知道如何对多个计时器进行相同的操作...

在我下面的代码中,每个计时器显示相同的值,因为我 不知道我应该如何为 timeline 制作一个 entries 以在多个计时器的情况下处理。

有什么方法可以显示我想要的吗?


代码如下:

import WidgetKit
import SwiftUI
import CoreData

struct Provider: TimelineProvider {

    var moc = PersistenceController.shared.managedObjectContext
    
    init(context : NSManagedObjectContext) {
        self.moc = context
    }

    
    func placeholder(in context: Context) -> SimpleEntry {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")
        
        do{
            let result = try moc.fetch(request)
            timerEntities = result
        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }
        return SimpleEntry(date: Date(), timerEntities: timerEntities!, duration: Date())
    }

    
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")
        
        do{
            let result = try moc.fetch(request)
            timerEntities = result
        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }
        let currentDate = Date()
        let firstDuration = Calendar.current.date(byAdding: .second, value: 300, to: currentDate)!
        
        let entry = SimpleEntry(date: Date(), timerEntities: timerEntities!, duration: firstDuration)
        return completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")

        do{
            let result = try moc.fetch(request)
            timerEntities = result

        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }

        let currentDate = Date()
        let duration = timerEntities?[0].duration ?? 0
        
        let firstDuration = Calendar.current.date(byAdding: .second, value: Int(duration) - 1, to: currentDate)!
        let secondDuration = Calendar.current.date(byAdding: .second, value: Int(duration), to: currentDate)!

        let entries: [SimpleEntry] = [
            SimpleEntry(date: currentDate, timerEntities: timerEntities!, duration: secondDuration),
            SimpleEntry(date: firstDuration, timerEntities: timerEntities!, duration: secondDuration, isDurationZero: true)
        ]

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

        completion(timeline)
    }
}


struct SimpleEntry: TimelineEntry {
    let date: Date
    let timerEntities:[TimerEntity]
    let duration:Date
    var isDurationZero:Bool = false
}

struct TimerWidgetEntryView : View {

    var entry: Provider.Entry
    
    var body: some View {
        return (
            ForEach(entry.timerEntities){(timerEntity:TimerEntity) in
                HStack{
                    Text(timerEntity.task!)
                    if !entry.isDurationZero{
                        Text(entry.duration, style: .timer)
                            .multilineTextAlignment(.center)
                            .font(.title)
                    }
                    else{
                        Text("00:00")
                            .font(.title)
                    }
                }
            }
        )
    }
}

@main
struct TimerWidget: Widget {
    let kind: String = "TimerWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(context: PersistenceController.shared.managedObjectContext)) { entry in
            TimerWidgetEntryView(entry: entry)
                .environment(\.managedObjectContext, PersistenceController.shared.managedObjectContext)
        }
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

更新:

TimerEntity中的字段类型

id: UUID
duration: Double
setDuration: Double
task: String
status: String

当用户添加 duration 时,setDurarion 也会保存与 duration 相同的值。


如何处理定时器的描述

在Host App中,当作为定时器计数的duration值变为0时,status设置为stoped,00:00为显示。

然后如果用户点击重置按钮,它会returns到setDuration的值并显示它,这样如果计时器结束它不会从[=32=中删除].

Widget 中,我尝试使用 isDurationZero:Bool 来检测要显示的条件 00:00 而不是在主机应用程序中使用 status


timerEntities?[0] .duration ?? 0 这是否意味着这些计时器每隔 duration 秒就会重复触发?

计时器每秒运行一次。

解释了CoreData中的字段类型,duration类型为Double,但Casting为Int类型对应(byAdding:.second) Calendar.current.date () 如下:

let firstDuration = Calendar.current.date(byAdding: .second, value: Int(duration) - 1, to: currentDate)!
let secondDuration = Calendar.current.date(byAdding: .second, value: Int(duration), to: currentDate)!

更新2:

如果您的应用不是 运行 但小部件是怎么办?

如果宿主应用程序中没有运行定时器,小部件中的定时器也将不起作用(小部件中有任何开始或停止按钮,所有操作都在应用程序中完成)。

如果我不需要在 Widget 中的每个计时器上显示 00:00Widget 的代码如下所示:

import WidgetKit
import SwiftUI
import CoreData

struct Provider: TimelineProvider {
    
    var moc = PersistenceController.shared.managedObjectContext
    
    init(context : NSManagedObjectContext) {
        self.moc = context
    }
    
    func placeholder(in context: Context) -> SimpleEntry {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")

        do{
            let result = try moc.fetch(request)
            timerEntities = result
        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }
        return SimpleEntry(date: Date(), timerEntities: timerEntities!)
    }
    
    
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")

        do{
            let result = try moc.fetch(request)
            timerEntities = result
        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }
        let entry = SimpleEntry(date: Date(), timerEntities: timerEntities!)
        return completion(entry)
    }
    
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
        var timerEntities:[TimerEntity]?
        let request = NSFetchRequest<TimerEntity>(entityName: "TimerEntity")
        
        do{
            let result = try moc.fetch(request)
            timerEntities = result
            
        }
        catch let error as NSError{
            print("Could not fetch.\(error.userInfo)")
        }
        
        let entries: [SimpleEntry] = [
            SimpleEntry(date: Date(), timerEntities: timerEntities!)
        ]
        
        let timeline = Timeline(entries: entries, policy: .never)
        
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let timerEntities:[TimerEntity]
}

struct TimerWidgetEntryView : View {
    var entry: Provider.Entry
    
    var body: some View {
        return (
            VStack(spacing:5){
                    ForEach(0..<3){index in
                        HStack{
                            Text(entry.timerEntities[index].task ?? "")
                                .font(.title)
                            Text(entry.timerEntities[index].status ?? "")
                                .font(.footnote)
                            Spacer()
                            if entry.timerEntities[index].status ?? "" == "running"{
                                Text(durationToDate(duration: entry.timerEntities[index].duration), style: .timer)
                                    .multilineTextAlignment(.center)
                                    .font(.title)
                            }else{
                                Text(displayTimer(duration: entry.timerEntities[index].duration))
                                    .font(.title)
                            }
                        }
                    }
            }
        )
    }
}

@main
struct TimerWidget: Widget {
    let kind: String = "TimerWidget"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(context: PersistenceController.shared.managedObjectContext)) { entry in
            TimerWidgetEntryView(entry: entry)
                .environment(\.managedObjectContext, PersistenceController.shared.managedObjectContext)
        }
        .supportedFamilies([.systemMedium, .systemLarge])
    }
}

//MARK: - funcs for Widget

func durationToDate(duration:Double) -> Date{
    let dateDuration = Calendar.current.date(byAdding: .second, value: Int(duration), to: Date())!
    return dateDuration
}

func displayTimer(duration:Double) -> String {
    let hr = Int(duration) / 3600
    let min = Int(duration) % 3600 / 60
    let sec = Int(duration) % 3600 % 60
    
    if duration > 3599{
        return String(format: "%02d:%02d:%02d", hr, min, sec)
    }else{
        return String(format: "%02d:%02d", min, sec)
    }
}

但在这种情况下,每个计时器显示 0:00 后,它会根据 Text(Data(),style: .timer) 规格开始计数。( 我想在计时器到期时将显示保持为 0:00)


但是只存储时长怎么知道定时器计时结束了呢?

到目前为止,我一直在尝试一种不直接更新核心数据的方法。

我在 SimpleEntry 中制作了一个 isDurationZero 的标志,使条件知道定时器仅以持续时间的值结束。

struct SimpleEntry: TimelineEntry {
    let date: Date
    let timerEntity:[TimerEntity]
    let duration:Date
    var isDurationZero:Bool = false
}

然后 isDurationZero 将通过 SimpleEntry classes 传递给 Timeline,如下所示: 在第二个class中,isDurationZero变为 True,计时器可以在widget中知道计时器到期。

    let currentDate = Date()
    
    let firstDuration = Calendar.current.date(byAdding: .second, value: Int(timerEntity?.duration ?? 10 ) - 1, to: currentDate)!
    let secondDuration = Calendar.current.date(byAdding: .second, value: Int(timerEntity?.duration ?? 10 ), to: currentDate)!
    
    let entries: [SimpleEntry] = [
        SimpleEntry(configuration: configuration, date: currentDate, timerEntity: timerEntity , duration: secondDuration),
        SimpleEntry(configuration: configuration, date: firstDuration, timerEntity: timerEntity , duration: secondDuration, isDurationZero: true)
    ]
    
    let timeline = Timeline(entries: entries, policy: .never)

    completion(timeline)

在这段代码中,即使用户只保存了句点,定时器也能在widget中知道时间结束,但只能支持一个定时器。

这个问题我最想知道的是如何对多个定时器做这个,或者还有什么办法。


Xcode: 版本 12.0.1

iOS: 14.0

生命周期:SwiftUI 应用程序

为什么它不起作用

首先我会解释为什么您当前的方法没有达到您的预期效果。

假设您在 getTimeline 函数中,并且您想将 duration 传递给条目(对于此示例,我们假设 duration = 15)。

目前 duration 描述了 并且是 相对的 。所以 duration = 15 意味着在 15 秒后计时器触发并且应该显示 "00:00".

如果只有一个定时器,方法见 will work (see also )。 15 秒后,您只需重新创建时间线就可以了。每当您在 getTimeline 函数中时,您就知道计时器刚刚结束(或即将开始)并且您处于 起点 .

当您有多个计时器时,问题就出现了。如果 duration 是相对的,你怎么知道当你进入 getTimeline 时你处于哪个状态?每次从 Core Data 读取 duration 时,它都是相同的值 (15 seconds)。即使其中一个计时器结束,您也会在不知道计时器状态的情况下从 Core Data 读取 15 secondsstatus 属性 在这里无济于事,因为您不能从视图内部将其设置为 finished 也不能将其传递给 getTimeline.

您的代码中还有:

let duration = timerEntities?[0].duration ?? 0

我假设你有很多计时器,它们可以有不同的 duration 并且不止一个计时器可以同时 运行ning。如果只选择第一个计时器的duration,当更快的计时器结束时,您可能无法刷新视图。

你还说:

The timer runs every second.

但是您不能使用小部件执行此操作。它们不适合每秒钟的操作,而且不会经常刷新。您需要在任何计时器结束时刷新时间线,但不能更早。

此外,你只将时间线设置为运行一次:

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

有了上面的 policy 你的 getTimeline 将不会被再次调用,你的视图也不会被刷新。

最后,让我们假设您有几个在一个小时(甚至一分钟)内触发的计时器。您的小部件的刷新次数有限,通常最好假设每小时刷新次数不超过 5 次。使用您当前的方法,可以在 分钟 甚至 .[=47 内使用 daily 限制=]

如何让它发挥作用

首先,您需要一种方法来了解当您在 getTimeline 函数中时您的计时器处于哪种状态。我看到两种方式:

  1. (不推荐)将即将结束的定时器信息存储在UserDefaults中,并在下一次迭代中排除(并将status设置为finished ).然而,这仍然是不可靠的,因为理论上可以在下一个刷新日期(在 TimelineReloadPolicy 中设置)之前刷新时间线。

  2. duration更改为绝对,而不是相对。您可以将 Double/Int 改为 Date。这样无论计时器是否结束,您都可以知道。

演示

struct TimerEntity: Identifiable {
    let id = UUID()
    var task: String
    var endDate: Date
}

struct TimerEntry: TimelineEntry {
    let date: Date
    var timerEntities: [TimerEntity] = []
}
struct Provider: TimelineProvider {
    // simulate entities fetched from Core Data
    static let timerEntities: [TimerEntity] = [
        .init(task: "timer1", endDate: Calendar.current.date(byAdding: .second, value: 320, to: Date())!),
        .init(task: "timer2", endDate: Calendar.current.date(byAdding: .second, value: 60, to: Date())!),
        .init(task: "timer3", endDate: Calendar.current.date(byAdding: .second, value: 260, to: Date())!),
    ]

    // ...

    func getTimeline(in context: Context, completion: @escaping (Timeline<TimerEntry>) -> Void) {
        let currentDate = Date()
        let timerEntities = Self.timerEntities
        let soonestEndDate = timerEntities
            .map(\.endDate)
            .filter { [=13=] > currentDate }
            .min()
        let nextRefreshDate = soonestEndDate ?? Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
        let entries = [
            TimerEntry(date: currentDate, timerEntities: timerEntities),
            TimerEntry(date: nextRefreshDate, timerEntities: timerEntities),
        ]
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
struct TimerEntryView: View {
    var entry: TimerEntry

    var body: some View {
        VStack {
            ForEach(entry.timerEntities) { timer in
                HStack {
                    Text(timer.task)
                    Spacer()
                    if timer.endDate > Date() {
                        Text(timer.endDate, style: .timer)
                            .multilineTextAlignment(.trailing)
                    } else {
                        Text("00:00")
                            .foregroundColor(.secondary)
                    }
                }
            }
        }
        .padding()
    }
}

备注

请记住,小部件的刷新频率不应高于每两分钟)。否则您的小部件将无法正常工作。这是 Apple 施加的限制。

目前,看到每秒刷新的日期的唯一可能是在Text中使用style: .timer(其他样式也可以)。这样您就可以仅在计时器结束后刷新小部件。