Setting the Timelineprovider Refresh Interval For Widget

Setting the TimelineProvider refresh interval for Widget

You shouldn't rely on the TimelineReloadPolicy - it specifies the earliest date when the Timeline is refreshed, it is not guaranteed that it reloads at that specific time.

From my observations, a Widget is more likely to reload the timeline with the atEnd policy.

A policy that specifies that WidgetKit requests a new timeline
after the last date in a timeline passes.

Here is a possible solution:

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
print("getTimeline")
let entries = [
SimpleEntry(date: Date()),
SimpleEntry(date: Calendar.current.date(byAdding: .minute, value: 1, to: Date())!),
]

let timeline = Timeline( entries: entries, policy: .atEnd)
completion(timeline)
}

Note that the last entry may or may not be used. If the timeline is refreshed immediately after the second entry's date, the second entry won't be shown. However, sometimes the timeline may be reloaded with a delay - then the second entry will be visible (until the timeline is refreshed).

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.



  1. Add another property to your TimelineEntry, let's call it clubName:
struct SimpleEntry: TimelineEntry {
let date: Date
let clubName: String
}

  1. Update the NetworkManager and return results in the completion:
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()
}
}

  1. Use the NetworkManager in the TimelineProvider and create timelines entries when the fetchData 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)
}
}
}

  1. 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 do I make my widget update more frequently or update when an action happens within the app

You can always update your widget from your app with the code block below. Please be aware you need to import WidgetKit to your class for calling this function.

WidgetCenter.shared.reloadAllTimelines()

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:

  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

Sample Image

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.



Related Topics



Leave a reply



Submit