How to refresh multiple timers in widget iOS14?
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:
(Unrecommended) Store the information of timers that are about to finish in
UserDefaults
and exclude them in the next iteration (and setstatus
tofinished
). This, however, is still unreliable as the timeline can theoretically be refreshed before the next refresh date (set in theTimelineReloadPolicy
).Change the
duration
to be absolute, not relative. Instead ofDouble
/Int
you can make it to beDate
. 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.
How to refresh Widget data?
You can't use the ObservedObject
like you'd normally use in your App.
In Widgets you use a TimelineProvider
which creates an Entry
for your view.
- Add another property to your
TimelineEntry
, let's call itclubName
:
struct SimpleEntry: TimelineEntry {
let date: Date
let clubName: String
}
- Update the
NetworkManager
and return results in thecompletion
:
class NetworkManager {
func fetchData(completion: @escaping ([Post]) -> Void) {
...
URLSession(configuration: .default).dataTask(with: url) { data, _, error in
...
let result = try JSONDecoder().decode(Results.self, from: data)
completion(result.data)
...
}
.resume()
}
}
- Use the
NetworkManager
in theTimelineProvider
and create timelines entries when thefetchData
completes:
struct Provider: TimelineProvider {
var networkManager = NetworkManager()
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), clubName: "Club name")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
let entry = SimpleEntry(date: Date(), clubName: "Club name")
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
networkManager.fetchData { posts in
let entries = [
SimpleEntry(date: Date(), clubName: posts[0].home_name)
]
let timeline = Timeline(entries: entries, policy: .never)
completion(timeline)
}
}
}
- Use
entry.clubName
in the view body:
struct WidgetNeuEntryView: View {
var entry: Provider.Entry
var body: some View {
VStack {
Text(entry.date, style: .time)
Text("Club: \(entry.clubName)")
}
}
}
Note that in the above example the reload policy is set to never
to only load the data once.
You can easily change it to atEnd
or after(date:)
if you want to reload the timeline automatically.
If you need to reload the timeline manually at any point you can just call:
WidgetCenter.shared.reloadAllTimelines()
This will work in both App and Widget.
Here is a GitHub repository with different Widget examples including the Network Widget.
How to refresh Widget when Main App is used?
WidgetCenter.shared.reloadAllTimelines()
is independent from the UI framework you use. You can use it from SwiftUI or UIKit.
If you're worried about making too many requests you could call that method only once your app goes to the background.
Can't refresh WidgetView every minute
I also tried refreshing every second with only refresing the date to
show current time, but its stops refreshing after couple of seconds.
You only have a limited number of refreshes available to your Widget. If you call WidgetCenter.shared.reloadAllTimelines()
every second, your Widget is likely to run out of available updates very quickly.
Also, you shouldn't call reloadAllTimelines()
in getTimeline()
:
...
completion(timeline)
WidgetCenter.shared.reloadAllTimelines() // remove this
Then I tried this code below from @pawello2222 but widgets are not
loading correctly with it
I assume you're referring to this answer: Updating time text label each minute in WidgetKit
Note that in your code guard let
may not pass through:
for offset in 0 ..< 60 * 24 {
guard let widget = try? JSONDecoder().decode([myWidgets].self, from: widgetData) // you need `else { ... return }` here
let entryDate = Calendar.current.date(byAdding: .minute, value: offset, to: midnight)!
entries.append(widgetEntry(date: entryDate, widget: widget[0]))
}
See: When to use guard let rather than if let
SwiftUI iOS 14 Widget CountDown
Here is how you can create a countdown for 1 minute
with the text color changing to red when it's 10 seconds
left. When the time is over the countdown starts again.
- Create an Entry where
displayDate
is the total time (here 1 minute):
struct SimpleEntry: TimelineEntry {
let date: Date
let displayDate: Date
var isDateClose = false
}
- In your Provider create two Entries - one for the standard color and one for the isClose color (here red):
struct SimpleProvider: TimelineProvider {
...
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
let currentDate = Date()
let firstDate = Calendar.current.date(byAdding: .second, value: 50, to: currentDate)!
let secondDate = Calendar.current.date(byAdding: .second, value: 60, to: currentDate)!
let entries = [
SimpleEntry(date: currentDate, displayDate: secondDate),
SimpleEntry(date: firstDate, displayDate: secondDate, isDateClose: true),
]
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
- Use it in your view:
struct SimpleWidgetEntryView: View {
var entry: SimpleProvider.Entry
var body: some View {
Text(entry.displayDate, style: .timer)
.foregroundColor(entry.isDateClose ? .red : .primary)
}
}
Here is a GitHub repository with different Widget examples including the Countdown Widget.
Related Topics
Swift 4.2 - _Shared Attribute Near Type
Possible to Write Swift Println Logs into File Too
How to Capitalize First Word in Every Sentence with Swift
Swift: Dictionaries Inside Array
Swift 'Unable to Dequeue a Cell with Identifier Intervalcellidentifier
Swift: Label Text --> "Fatal Error: Unexpectedly Found Nil While Unwrapping an Optional Value"
Resizing Uimage When Using Sf Symbols - Uiimage(Systemname:)
Why Can't Swift Automatically Convert a Generic Type Parameter to Its Superclass
How to Create a Circle with Rounded Ends for Each Quadrant
Passing a Variable Through a Segue? Xcode 8 Swift 3
Nstableview Get Indexpath Having the Cell
Concatenate Literal with Optional String
Data Ranged Subscribe Strange Behavior
Uisegment Value Changing When Tableview Get Scrolled
How to Avoid Duplicate Key Error in Swift When Iterating Over a Dictionary
Does the Initializer of an 'Open' Class Need to Be Open as Well