简体   繁体   English

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

[英]How to refresh multiple timers in widget iOS14?

I'm currently developing an application using SwiftUI and trying to make a widget iOS 14 users can check a list of timers.我目前正在使用SwiftUI开发一个应用程序并尝试制作一个widget iOS 14用户可以检查计时器列表。

This Widget has multiple Text(Data(),style: .timer) to show some date data as timer.Widget有多个Text(Data(),style: .timer)以将一些日期数据显示为计时器。 and when the rest of the value for the timer is over, I want to show it like this 00:00 .当计时器的值的 rest 结束时,我想像这样显示00:00

So I implemented some way in getTimeline function referring to this article SwiftUI iOS 14 Widget CountDown所以我在getTimeline function 中实现了一些方法,参考这篇文章SwiftUI iOS 14 Widget CountDown

But I don't know how can I do it the same way for multiple timers...但我不知道如何为多个计时器做同样的事情......

In the case of my code below, each timer shows the same value, because I don't know how should I make an entries for timeline to handle in the case of multiple timers.在下面的代码中,每个计时器显示相同的值,因为我不知道在多个计时器的情况下我应该如何为timeline创建entries来处理。

Is there any way to display what I want?有什么办法可以显示我想要的吗?


Here are the codes:以下是代码:

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])
    }
}

UPDATED:更新:

Types of the field in TimerEntity TimerEntity 中的字段类型

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

When users add duration , setDurarion also saves the same value as the duration .当用户添加duration时, setDurarion也会保存与duration相同的值。


description of how timers are handled描述如何处理定时器

In the Host App, when the duration value that to be counted as a timer becomes 0, the status is set to stoped , and 00:00 is displayed.在 Host App 中,当要计为定时器的时duration值变为 0 时, status设置为stoped ,并显示 00:00。

And then if users tap the reset button, it returns to the value of setDuration and displays it, so that if a timer finishes It will not be deleted from the CoreData .然后如果用户点击重置按钮,它会返回setDuration的值并显示它,这样如果计时器完成它就不会从CoreData中删除。

In the Widget I tried to use isDurationZero:Bool to detect a condition to display 00:00 instead of using status in the host App.Widget中,我尝试使用isDurationZero:Bool来检测显示00:00的条件,而不是在主机应用程序中使用status


timerEntities?[0].duration?? 0 timerEntities?[0].duration?? 0 Does this mean these timers fire repeatedly every duration seconds? timerEntities?[0].duration?? 0这是否意味着这些计时器每持续时间秒重复触发?

The timer runs every second.计时器每秒运行一次。

As explained the field type in the CoreData , the duration type is Double , but Casting to Int type to correspond to (byAdding: .second) of Calendar.current.date () as below:正如CoreData中的字段类型所解释的, duration类型是Double ,但 Casting to Int类型对应于 Calendar.current.date () 的 (byAdding: .second) ,如下所示:

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)!

UPDATED2:更新2:

What if your app is not running but the widget is?如果您的应用程序没有运行但小部件正在运行怎么办?

If the timer is not running in the host app, the timer in the widget will not work either (there are any start or stop buttons in the widget and all operations are done in the app).如果计时器没有在宿主应用程序中运行,则小部件中的计时器也将不起作用(小部件中有任何开始或停止按钮,所有操作都在应用程序中完成)。

If I don't need display 00:00 on each timer in Widget the code for the Widget is like below:如果我不需要在Widget中的每个计时器上显示00:00 ,则Widget的代码如下所示:

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)
    }
}

But in this case, after each timer displays 0:00 it starts to count up based on Text(Data(),style: .timer) specification.( I want to keep the display as 0:00 when the timer expires)但在这种情况下,在每个计时器显示0:00后,它会根据Text(Data(),style: .timer)规范开始计数。(我想在计时器到期时将显示保持为0:00


But how can you know that the timer finished if you only store the duration?但是如果你只存储持续时间,你怎么能知道定时器已经结束呢?

Until now, I've been trying a method that doesn't update Core Data directly.到目前为止,我一直在尝试一种不直接更新 Core Data 的方法。

I made a flag of isDurationZero in SimpleEntry to make the condition to know the timer finishes with the value of duration only.我在isDurationZero中创建了一个SimpleEntry标志,以使条件知道计时器仅以持续时间的值结束。

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

Then isDurationZero will get passing SimpleEntry classes to Timeline as follows: In the second class, isDurationZero becomes True and the timer can know the timer expiration in the widget.然后isDurationZero将通过SimpleEntry类传递给Timeline ,如下所示:在第二个 class 中, isDurationZero变为True ,并且计时器可以知道小部件中的计时器到期时间。

    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)

In this code, even if the user saves only the period, the timer can know the end of it in the widget, but only one timer can be supported.在这段代码中,即使用户只保存了周期,定时器在小部件中也可以知道它的结束,但只能支持一个定时器。

What I want to know the most about this question is how to do this for multiple timers, or what else way is possible.关于这个问题,我最想知道的是如何为多个计时器执行此操作,或者还有其他可能的方法。


Xcode: Version 12.0.1 Xcode:版本 12.0.1

iOS: 14.0 iOS:14.0

Life Cycle: SwiftUI App生命周期:SwiftUI 应用程序

Why it's not working为什么它不起作用

I'll start by explaining why your current approach is not working as you expected.我将首先解释为什么您当前的方法没有按您的预期工作。

Let's assume you're in the getTimeline function and you want to pass the duration to the entry (for this example let's assume duration = 15 ).假设您在getTimeline function 中,并且您想将duration传递给条目(对于本示例,我们假设duration = 15 )。

Currently the duration describes seconds and is relative .目前duration描述并且是相对的。 So duration = 15 means that after 15 seconds the timer fires and should display "00:00" .所以duration = 15意味着 15 秒后计时器触发并且应该显示"00:00"

If you have one timer only, the approach described in SwiftUI iOS 14 Widget CountDown will work (see also Stopping a SwiftUI Widget's relative textfield counter when hits zero? ).如果您只有一个计时器, SwiftUI iOS 14 Widget CountDown中描述的方法将起作用(另请参阅Stopping a SwiftUI Widget's relative textfield counter when hit? )。 After 15 seconds you just re-create the timeline and that's fine. 15 秒后,您只需重新创建时间线就可以了。 Whenever you're in the getTimeline function you know that the timer has just finished (or is about to start) and you're in the starting point .每当您在getTimeline function 中时,您就知道计时器刚刚结束(或即将开始)并且您处于起点

The problem starts when you have more than one timer.当您有多个计时器时,问题就开始了。 If duration is relative how do you know in which state you are when you're entering getTimeline ?如果duration是相对的,当您进入getTimeline时,您如何知道您在哪个 state 中? Every time you read duration from Core Data it will be the same value ( 15 seconds ).每次您从 Core Data 读取duration时,它都会是相同的值( 15 seconds )。 Even if one of the timers finishes, you'll read 15 seconds from Core Data without knowing of the timer's state.即使其中一个计时器完成,您也会在不知道计时器的 state 的情况下从 Core Data 读取15 seconds The status property won't help here as you can't set it to finished from inside the view nor pass it to getTimeline . status属性在这里无济于事,因为您无法从视图内部将其设置为已finished ,也无法将其传递给getTimeline

Also in your code you have:同样在您的代码中,您有:

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

I assume that you if you have many timers, they can have different durations and more than one timer can be running at the same time.我假设如果你有很多计时器,它们可以有不同的持续时间,并且可以同时运行多个计时器。 If you choose the duration of the first timer only, you may fail to refresh the view when faster timers are finished.如果您只选择第一个计时器的duration ,当更快的计时器完成时,您可能无法刷新视图。

You also said:你还说:

The timer runs every second.计时器每秒运行一次。

But you can't do this with Widgets.但是你不能用小部件来做到这一点。 They are not suited for every-second operations and simply won't refresh so often.它们不适合每秒操作,并且根本不会经常刷新。 You need to refresh the timeline when any of timers ends but no sooner.您需要在任何计时器结束时刷新时间线,但不要更早。

Also, you set the timeline to run only once:此外,您将时间线设置为仅运行一次:

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

With the above policy your getTimeline won't be called again and your view won't be refreshed either.使用上述policy ,您的getTimeline将不会再次被调用,您的视图也不会刷新。

Lastly, let's imagine you have several timers that fire in the span of an hour (or even a minute).最后,让我们假设您有几个在一个小时(甚至一分钟)内触发的计时器。 Your widget has a limited number of refreshes, generally it's best to assume no more than 5 refreshes per hour.您的小部件的刷新次数有限,通常最好假设每小时刷新不超过 5 次。 With your current approach it's possible to use the daily limit in minutes or even seconds .使用您当前的方法,可以在几分钟甚至几秒钟内使用每日限制。

How to make it work如何使它工作

Firstly, you need a way to know in which state your timers are when you are in the getTimeline function.首先,当您在getTimeline function 中时,您需要一种方法来了解您的计时器在哪个 state 中。 I see two ways:我看到两种方法:

  1. (Unrecommended) Store the information of timers that are about to finish in UserDefaults and exclude them in the next iteration (and set status to finished ). (不推荐)将即将结束的计时器信息存储在UserDefaults中,并在下一次迭代中排除(并将status设置为finished )。 This, however, is still unreliable as the timeline can theoretically be refreshed before the next refresh date (set in the TimelineReloadPolicy ).但是,这仍然不可靠,因为理论上可以在下一个刷新日期之前刷新时间线(在TimelineReloadPolicy中设置)。

  2. Change the duration to be absolute , not relative .duration更改为绝对的,而不是相对的。 Instead of Double / Int you can make it to be Date .而不是Double / Int您可以将其设为Date This way you'll always now whether the timer is finished or not.这样一来,无论计时器是否完成,您现在都将始终知道。

Demo演示

在此处输入图像描述

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 { $0 > 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()
    }
}

Note笔记

Remember that widgets are not supposed to be refreshed more often than every couple of minutes).请记住,小部件的刷新频率不应超过每几分钟)。 Otherwise your widget will simply not work.否则,您的小部件将无法正常工作。 That's the limitation imposed by Apple.这就是 Apple 施加的限制。

Currently, the only possibility to see the date refreshing every second is to use style: .timer in Text (other styles may work as well).目前,查看每秒刷新的日期的唯一可能性是使用style: .timer in Text (其他 styles 也可以使用)。 This way you can refresh the widget only after the timer finishes.这样,您只能在计时器完成后刷新小部件。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM