Refreshing widgets - policy and background tasks?

I have widgets providing their timeline using the .atEnd reload policy, i.e.:

// AppIntentTimelineProvider:
return Timeline(entries: entries, policy: .atEnd)

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

I can't seem to find any information on what happens after the end of the timeline. So, let's say I've got two days worth of entries, the dev docs for the reload policy say, "A policy that specifies that WidgetKit requests a new timeline after the last date in a timeline passes."

Great! But how does it request the new timeline? Does iOS launch my app in the background and simply re-run the timeline to generate another two days worth of entries? I doubt it.

I figure I need to implement some sort of background task, and the dev docs say how to do it with an Operation, but then I read that this is an old way of doing it? I've found some info online saying to use something like this, so this is what I've implemented:

let kBackgroundWidgetRefreshTask = "my.refresh.task.identifier"  // This has been registered in the info.plist correctly

class SchedulingService {
	static let shared = SchedulingService()

	func registerBackgroundTasks() {
		let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: kBackgroundWidgetRefreshTask, using: nil) { task in
			print("Background task is executing: \(task.identifier)") // This does print "true"
			self.handleWidgetRefresh(task: task as! BGAppRefreshTask)
		}
		print("Is the background task registered? \(isRegistered)")
	}

	func scheduleWidgetRefresh() {
		let request = BGAppRefreshTaskRequest(identifier: kBackgroundWidgetRefreshTask)
		// Fetch no earlier than 1 hour from now - test, will be two days
		request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)

		do {
			try BGTaskScheduler.shared.submit(request)
			print("Scheduled widget refresh for one hour from now")

		} catch {
			print("Could not schedule widget refresh: \(error)")
		}
	}

	private func handleWidgetRefresh(task: BGAppRefreshTask) {
		// Schedule a new refresh task
		scheduleWidgetRefresh()

		// Start refresh of the widget data
		let refreshTask = Task {
			do {
				print("Going to refresh widgets")
				try await self.backgroundRefreshWidgets()
				task.setTaskCompleted(success: true)
			} catch {
				print("Could not refresh widgets: \(error)")
				task.setTaskCompleted(success: false)
			}
		}

		// Provide the background task with an expiration handler that cancels the operation
		task.expirationHandler = {
			refreshTask.cancel()
		}
	}

	func backgroundRefreshWidgets() async throws {
		print("backgroundRefreshWidgets() called")
		definitelyRefreshWidgets()
	}
}

As I've commented above, the line print("Background task is executing: \(task.identifier)") does print true so the task has been registered correctly.

I've put the app into the background and left it for hours and nothing is printed to the console. I've implemented a logger that writes to a file in the app container, but that doesn't get anything either.

So, is there something I'm misunderstanding? Should I change the reload policy to .after(date)? But what makes the timeline reload?


As a second but linked issue, my widgets have countdown timers on them and the entire timeline shows that every entry is correct, but the widgets on the Home Screen simply fail to refresh correctly.

For example, with timeline entries for every hour for the next two days from 6pm today (so, 7pm, 8pm...) every entry in the preview in Xcode shows the right countdown timer. However, if you put the widget on the Home Screen, after about five hours the timer shows 25:12:34 (for example).

No entry in the timeline preview ever shows more than 24 hours because the entires are every hour, and the one that shows a timer starting at 23:00:00 should never get to 24:00:00 as the next entry would kick in from 0:00:00, so it should never show more than 23:59:59 on the timer. It's like the 23:00:00 timer is just left to run for hours instead of being replaced by the next entry.

It's as though the widget isn't refreshing correctly and entries aren't loaded? Given this is the Simulator - and my development device - and both are set to Developer Mode so widget refresh budgets aren't an issue, why is this happening? How do you get widgets to refresh properly? The dev docs are not very helpful (neither is the Backyard Birds example Apple keep pushing).

Thanks!

Answered by darkpaw in 818133022

I've extensivley tested this over the past two days. I tried timelines spanning one day, with:

  • 72 entries, every 20 minutes
  • 36 entries, every 40 minutes
  • 24 entries, every hour
  • 12 entries, every 2 hours
  • 6 entries, every 4 hours
  • 3 entries, every 8 hours
  • 2 entries, every 12 hours

In every case, the policy is .atEnd, and in every case the timeline is not refreshed when it's supposed to be, which causes the widget to display the wrong timer values.

Due to the inflexibility of Text(date, style: .timer), I need to write code that calculates the correct timer value but shows less than a day. For example, if it's 11am today and the event date is tomorrow at noon, there are 25 hours between the two dates and the standard .timer would show 25:00:00, which is not what I want. So, I calculate the next occurrence of noon, and the widget shows "1 day" with a timer of "1:00:00".

Every timeline with a refresh policy of .atEnd caues the countdown timer to show the wrong values.

I've got it logging to a file, and I can see that the countdown dates are completely correct in every case. The widget is never refreshed at the right time, so the timers always show the wrong values.


The only way I've managed to get this to work is to have a timeline with one entry, and a refresh policy of .after(), i.e.

let date = Date()
let refreshDate = Calendar.autoupdatingCurrent.date(byAdding: .minute, value: 30, to: date)!
Timeline(entries: entries, policy: .after(refreshDate))

There is clearly something wrong with .atEnd.

Okay, so background tasks don't work at all, and apparently aren't the way to go with refreshing widgets because the refresh policy .atEnd is meant to simply re-run the timeline func again to get new entries. In my experience, this never works; it is never called, and the widgets never display the right entries from the initial timeline anyway. Xcode's previews show the right entries, so why the widgets deviate so much from them is anyone's guess.

I am 90% of the way to thinking of just removing widgets from my app as they simply do not work as advertised, and I could do without the migraines.

If an Apple developer is actively reading this, how about you help a guy out? I've raised feedback reports and they're still "open" with no other linked reports. I cannot be the only person in the world who can't get widgets to work properly. I have been trying to get these things to work properly since they were introduced years ago. They don't work, and Apple won't help.

Xcode preview of the timeline entries with a timeline entry date of now (2024-12-10 23:07, the second date in the view) shows a widget with 14 days, 9:52:xx seconds counting down to the event date of 2024-12-25 09:00, the first date in the view).

This is correct. From the entry date to the event date there are 14 days, 9 hours, 52 mins xx seconds to that time. All good.

Now look at that widget on my macOS desktop (exactly the same as on the iPhone): 14 days, 8:30:xx. It's an hour and 20 minutes out. Why?

The moment I relaunch the app on my iPhone it will update the widgets on the desktop and they'll show the correct countdown time.

Xcode previews show the right times, then when the app is deployed to a device they just forget that timeline altogether and make it up.

I obviously cannot ask the user to relaunch my app every couple of hours to make sure the widgets are correct. This should just work. Xcode's previews show that what I've done is correct - my timeline is correct - so why is it wrong on a device?

It's so frustrating.

Hi,

Once your last entry is called, your Widget process is woken (no background or foreground here since it is on springboard and not part ion an app lifecycle). It will call getTimeline and restart the process. If you use .never and use APNs, you'd reload your Widgets in your content or service extensions for notifications as they arrive. You also will only get 72 refreshes per day. After that the system will very likely ignore your reloads until 24 hours has passed. It is also not recommended to schedule more than 24 hours worth of entries at a time. Generally around midnight there is also a free (not counting against your budget) refresh.

Rico


WWDR | DTS | Software Engineer

Right, so 72 entries is a sort-of limit. That should maybe be mentioned somewhere in the developer documentation to avoid anyone hitting issues.

However, as this the Simulator and a development device those limits shouldn't apply.

Anyway, thanks, but that still doesn't help.

Even if I change the timeline to have just one day of 72 timeline entries (every 20 minutes for 24 hours), the widgets are still out of whack after just a few minutes.

Due to the way timers work in SwiftUI, we developers are having to write a ton of fragile code to work around Apple's limited implementation.

I want to show my users a countdown timer that has something like 12 days, 13:45:18. If I use the standard Text.init(date, style: .timer) I will get something like 301:45:18.

The developer docs show this example: Example output: 2:32 36:59:01

Who in their right mind would ever want to see a countdown of 36 hours 59 minutes 1 second? No one. Users want to see "1 day 12 hours 59 minutes 1 second", or a friendlier "1 day, 12:59:01". And I can't just modulus the hours to remove 24 hour chunks because that doesn't cover different timezones and calendars. Even if I could, there's no way of intercepting the 36 hours bit of the timer output and changing it anyway.

I have to write code that figures out how many hours, minutes and seconds should be displayed - which should never be more than 23:59:59 - to then be applied to the countdown timer, then display the larger units separately, i.e. "1 day".

Apple's current implementation does not allow for this, so I'm having to write code that figures this out. And I've done it. And that code runs correctly. When I view the previews in Xcode I can see the correct timeline entries.

Using Christmas at 9:00:00am as an example:

  • A timeline entry is added with a date of Date.now, which for argument's sake is 11th December at 11:33:00pm.
  • The event date - the actual date of the event, i.e. 9am on 25th December - is therefore 13 days, 9 hours and 27 minutes away.
  • My widget should show 13 days 9:27:00, and it does.

(Ignore the slight discrepancy between typing this and taking a screenshot, but you can see that the first entry in the timeline is 13 days 9:26:19.)

Look at entry 3: the entry date is one minute later than 11:33:00pm, so it's showing a countdown timer of 13 days 9:25:19, i.e. one minute later. If I scroll through the preview entries I see each entry is 20 minutes closer to Christmas than before. This is correct.

Now, when I deploy this to a device or even the Simulator, I see completely Whacko-Jacko timers.

Here's a screenshot of a macOS Sequoia desktop widget (which is the same as that seen on my iPhone), showing 13 days 1:22:14 taken at 23:42:46:

If you add 13 days 1:22:14 to 11th Dec 23:42:46 you get 25th Dec at 1:05:00am. How is that a valid countdown to 9:00:00am on Christmas Day?!

Oh, and here's one that's apparently being refreshed every 20 minutes but has still managed to go over 23:59:59:

As I said, I'm very close to just removing widgets altogether because they simply do not work as advertised, and your countdown timers are inflexible and unusable.

I feel I also need to point out how you've managed to invent an extra second with your timers... When a normal timer is counting down to 0:00 and then counts into negative time, it goes: 0:03... 0:02... 0:01... 0:00... -0.01... -0.02... -0:03

Your timers go: 0:03... 0:02... 0:01... 0:00... 0:00... -0.01... -0.02... -0:03

How about I just send you a zip file of my entire application and you can figure out how to get it to work properly?

I apologise if you feel this is an attack, but I have been dealing with this crud for years and I'm getting nowhere. There is likely a really simple fix, and if one of you wishes to spend some time writing a function that calculates the difference between two dates and puts those values into a user-friendly countdown timer, then please do so. I would be immensely grateful.

Accepted Answer

I've extensivley tested this over the past two days. I tried timelines spanning one day, with:

  • 72 entries, every 20 minutes
  • 36 entries, every 40 minutes
  • 24 entries, every hour
  • 12 entries, every 2 hours
  • 6 entries, every 4 hours
  • 3 entries, every 8 hours
  • 2 entries, every 12 hours

In every case, the policy is .atEnd, and in every case the timeline is not refreshed when it's supposed to be, which causes the widget to display the wrong timer values.

Due to the inflexibility of Text(date, style: .timer), I need to write code that calculates the correct timer value but shows less than a day. For example, if it's 11am today and the event date is tomorrow at noon, there are 25 hours between the two dates and the standard .timer would show 25:00:00, which is not what I want. So, I calculate the next occurrence of noon, and the widget shows "1 day" with a timer of "1:00:00".

Every timeline with a refresh policy of .atEnd caues the countdown timer to show the wrong values.

I've got it logging to a file, and I can see that the countdown dates are completely correct in every case. The widget is never refreshed at the right time, so the timers always show the wrong values.


The only way I've managed to get this to work is to have a timeline with one entry, and a refresh policy of .after(), i.e.

let date = Date()
let refreshDate = Calendar.autoupdatingCurrent.date(byAdding: .minute, value: 30, to: date)!
Timeline(entries: entries, policy: .after(refreshDate))

There is clearly something wrong with .atEnd.

Refreshing widgets - policy and background tasks?
 
 
Q