Pro to SwiftUI: Animate widget changes with intermediate state
Preface
This article is part of the "Pro to SwiftUI" series, mainly sharing some insights, experiences, and pitfalls encountered in developing several apps using SwiftUI. Hope it can help you.
This article talk about how to animate widget changes with intermediate state in iOS 17.0, iPadOS 17.0 or macOS 14.0, which has the same effect of the Apple reminder app.
You can find the demo project at the end of this article.
Widget view is stateless
Before discussing further about the widget animation, we should first clarify that the view of a widget is stateless, which means that:
We can’t store any states in the view using
@State
,@StateObject
or other property wrappers.
What we do in Widget view is to provide a view structure that are correspond to the current environment state, like the
TimelineEntry
provided by us or theWidgetFamily
by the system.
Also, widget views are transited by the system when the timeline advances. Normally the transition is the fade-in and fade-out between the whole views.
As iOS 17 and macOS 14 announced in 2023, the system can bring transition to individual views, as long as their identities is recognized. But this doesn’t change the fact that views in widget are still stateless. For example, if you run this code:
You will find that each time you reload the timeline, the uuid
will be different since no state is preserved.
Animate individual views in iOS 17/macOS 14
Starting from iOS 17 and macOS 14 we can provide animations to the individual views in a widget. When the timeline advance and a new view is constructed, the system knows how to transit the changes with animation.
To more specified:
SwiftUI provides a default fade-in and fade-out animation.
You can specify how the view is transiting using
transition
view modifier.
You can use
animation(_:value:)
view modifier to specify the timing curve, duration or other behaviors of your animation.
However you can’t use
transaction(_:)
to initiate an animation because the view in widget has no state. Likewise you can’t animate and change state usingwithAnimation
.
For some views like
Text
view, you can usecontentTransition(_:)
to modify the view to use a given transition as its method of animating changes to the contents of its views. For example, you better use thenumericText(countsDown:)
to animate when the texts are changed.
To know about this, please refer to the Apple documentation:
https://developer.apple.com/videos/play/wwdc2023/10028/
Use intermediate state to perform “completed” animation
Given the knowledge above, let’s dive in how to perform the “done” animation as what the Apple Reminder does in Widget.
The Apple reminder app has adopted the interactive widget API in iOS 17 and the widget has those behaviors:
It shows a unfilled circle button at the leading of a reminder.
When the user click the circle button, it changes to the filled style and later the reminder marked as completed will dismiss with a removal animation in the list.
It’s quite simple to achieve this effect except that we need a intermediate state to present the filled circle state so that as clicking the complete button, the user has some time to check which reminder is clicked to mark completed.
The whole states are depicted like this:
How to achieve this effect? Let’s implement this step by step with a simplified the model.
First we need to define our reminder model. It contains a text and a nullable completed date. When the user mark it as completed state the completed date will be set to the current time(you may not want to use a simple bool because we need the exact completed time).
The model data is saved using SwiftData. However you can use Core Data or any other database frameworks you like. The fetching and saving details won’t be discussed here and Xcode already had a good template project for this and you should check on that.
To display reminders in a widget view:
It displays a VStack
containing a HStack
with a complete button and the text view.
When the button is clicked, the perform method of ReminderIntent
which is an implementation of AppIntent will be invoked and you can update the completed date:
After the perform
method returns with no throw, the system will need to create a timeline containing the entries to be displayed. The getTimeline(in:completion:)
method of your TimelineProvider
will be invoked soon. Before showing how to implement the detail of etTimeline(in:completion:)
method, we need to talk about the states flow of the widget.
Regarding to the states flow of widget, the whole process is depicted as the following:
The key point is that we need to display the reminders which completed date is nil(which means it’s not done yet) or the completed date is less than 2 seconds from now(which means it’s marked completed recently).
The timeline flows from top to bottom.
In state 0, it displays two reminders, and both completedDate are nil.
At the same time the user click the complete button of first row, and now the completedDate of the first reminder is set to the current time.
In state 1 which it’s one second right after state 0, the list still has two reminders. The first reminder is still here because its completedDate is less than 2 seconds from now. In this case for the first reminder, we display a filled circle to the leading edge of the item.
In state 2 which it’s two seconds after state 1, the display list contains one reminder.
After understanding the states flow of the widget, here is how to create a timeline:
Please note that:
The timeline contains two entries, one for displaying recently completed reminders and one for uncompleted items
The second entry is scheduled at 0.5 seconds later from now. You can set this to any time you want but Widget update won’t be as frequent as you may want. You can try setting to different values and check which is perfect for you.
When the timeline advances and the next entry is displayed, the WidgetKit and SwiftUI can animate properly as we discussed before.
Demo Project
I made a demo for this. You can run this with Xcode 15 and devices or simulators with iOS 17.0 (macOS 14.0 also works). Please refer to this repo:
https://github.com/JuniperPhoton/Widget-Intermediate-Animation
This project uses SwiftData as backing store and currently it has one issue that the changes being performed in an
AppIntent
won’t be written to the disk until the app is killed and launched. The repo has an issue tracking this problem and I think it’s due to the SwiftData.