How to refresh multiple timers in widget iOS14?

自古美人都是妖i 提交于 2021-02-19 04:19:31

问题


I'm currently developing an application using SwiftUI and trying to make a widget iOS 14 users can check a list of timers.

This Widget has multiple Text(Data(),style: .timer) to show some date data as timer. and when the rest of the value for the timer is over, I want to show it like this 00:00.

So I implemented some way in getTimeline function referring to this article 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.

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

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

When users add duration, setDurarion also saves the same value as the 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.

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.

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.


timerEntities?[0] .duration ?? 0 Does this mean these timers fire repeatedly every duration seconds?

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:

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:

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:

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)


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.

I made a flag of isDurationZero in SimpleEntry to make the condition to know the timer finishes with the value of duration only.

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.

    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

iOS: 14.0

Life Cycle: SwiftUI App


回答1:


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

Currently the duration describes seconds and is relative. So duration = 15 means that after 15 seconds the timer fires and should display "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?). After 15 seconds you just re-create the timeline and that's fine. 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.

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? Every time you read duration from Core Data it will be the same value (15 seconds). Even if one of the timers finishes, you'll read 15 seconds from Core Data without knowing of the timer's state. The status property won't help here as you can't set it to finished from inside the view nor pass it to 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.

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.

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. 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. 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). This, however, is still unreliable as the timeline can theoretically be refreshed before the next refresh date (set in the TimelineReloadPolicy).

  2. Change the duration to be absolute, not relative. Instead of Double/Int you can make it to be 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.

Currently, the only possibility to see the date refreshing every second is to use style: .timer in Text (other styles may work as well). This way you can refresh the widget only after the timer finishes.



来源:https://stackoverflow.com/questions/65670736/how-to-refresh-multiple-timers-in-widget-ios14

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!