State Restoration in SwiftUI
Learn how to use SceneStorage in SwiftUI to restore iOS app state. By Tom Elliott.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
State Restoration in SwiftUI
30 mins
- Getting Started
- Understanding Scene Storage
- Saving State
- Restoring All The Things
- State Restoration and the Navigation Stack
- Restoring Characters
- Handling Scene Changes
- Recognizing That Books Are Unique
- Updating the Scene Change
- Understanding Active Users
- Adding Window Dressing
- Where to Go From Here?
Restoring All The Things
In fact, it was so easy, why don't you restore state for a few more properties within the app?
Within any of the first three tabs, tap the View in Amazon button. A web view opens up showing the book in Amazon. Cold launch the app. As expected, the operating system doesn't restore the web view.
In Xcode, open BookView.swift. Find the property declaration for isShowingAmazonPage
, and update it as follows:
@SceneStorage("BookView.ShowingAmazonPage") var isShowingAmazonPage = false
Notice how the identifier is different this time.
Build and run the app again. Open the Amazon page for one of the apps. Perform a cold launch, and confirm the Amazon page shows automatically after the next launch.
Tap Done to close the Amazon web view. Write a quick note for the book, then tap Save. The list of notes displays your note for the book. Start typing a second note. This time, before tapping Save, perform a cold launch. When the app relaunches, notice how it didn't save your draft note. How annoying!
In Xcode, still in BookView.swift, find the declaration for newNote
:
@State var newNote: String = ""
And update it by adding the SceneStorage
attribute to the property:
@SceneStorage("BookView.newNote") var newNote: String = ""
Another SceneStorage
property, with another different identifier.
Build and run the app again. Write a draft note for a book, perform a cold start, and confirm that relaunching the app restores the draft note state.
Next, open CharacterView.swift. Make a similar change to update the newNote
property as well, being careful to provide a different key for the property wrapper:
@SceneStorage("CharacterView.newNote") var newNote: String = ""
Build and run the app. Navigate to any character, create a draft character note and perform a cold launch. Confirm SceneStorage
restores the draft note state.
State Restoration and the Navigation Stack
Tap any character to load the character detail screen. Perform a cold launch, and notice how the app didn't load the character detail screen automatically.
Hawk Notes handles navigation using a NavigationStack
. This is a brand new API for iOS 16. The app stores the state of the NavigationStack
in an array property called path
.
Given how easy it was to restore state so far in this tutorial, you're probably thinking it's simple to add state restoration to the path
property — just change the State
attribute to a SceneStorage
one. Unfortunately, that's not the case.
If you try it, the app will fail to compile with a fairly cryptic error message:
No exact matches in call to initializer
What's going on? Look at the definition for SceneStorage
, and notice that it's defined as a generic struct with a placeholder type called Value
:
@propertyWrapper public struct SceneStorage<Value>
Several initializers are defined for SceneStorage
, all of which put restrictions on the types that Value
can hold. For example, look at this initializer:
public init(wrappedValue: Value, _ key: String) where Value == Bool
This initializer can only be used if Value
is a Bool
.
Looking through the initializers available, you see that SceneStorage
can only save a small number of simple types — Bool
, Int
, Double
, String
, URL
, Data
and a few others. This helps ensure only small amounts of data are stored within scene storage.
The documentation for SceneStorage
gives a hint as to why this may be with the following description:
SceneStorage
is lightweight. Data of large size, such as model data, should not be stored in SceneStorage
, as poor performance may result."
This encourages us to not store large amounts of data within a SceneStorage
property. It's meant to be used only for small blobs of data like strings, numbers or Booleans.
Restoring Characters
The NavigationStack
API expects full model objects to be placed in its path
property, but the SceneStorage
API expects simple data. These two APIs don't appear to work well together.
Fear not! It is possible to restore the navigation stack state. It just takes a little more effort and a bit of a detour.
Open BookView.swift. Add a property to hold the current scene phase underneath the property definition for the model
:
@Environment(\.scenePhase) var scenePhase
SwiftUI views can use a ScenePhase
environment variable when they want to perform actions when the app enters the background or foreground.
Next, create a new optional String
property, attributed as scene storage:
@SceneStorage("BookView.SelectedCharacter") var encodedCharacterPath: String?
This property will store the ID for the currently shown character.
Handling Scene Changes
Finally, add a view modifier to the GeometryReader
view, immediately following the onDisappear
modifier toward the bottom of the file:
// 1
.onChange(of: scenePhase) { newScenePhase in
// 2
if newScenePhase == .inactive {
if path.isEmpty {
// 3
encodedCharacterPath = nil
}
// 4
if let currentCharacter = path.first {
encodedCharacterPath = currentCharacter.id.uuidString
}
}
// 5
if newScenePhase == .active {
// 6
if let characterID = encodedCharacterPath,
let characterUUID = UUID(uuidString: characterID),
let character = model.characterBy(id: characterUUID) {
// 7
path = [character]
}
}
}
This code may look like a lot, but it's very simple. Here's what it does:
- Add a view modifier that performs an action when the
scenePhase
property changes. - When the new scene phase is inactive — meaning the scene is no longer being shown:
- Set the
encodedCharacterPath
property tonil
if no characters are set in thepath
, or - Set the
encodedCharacterPath
to a string representation of the ID of the displayed character, if set. - Then, when the new scene phase is active again:
- Unwrap the optional
encodedCharacterPath
to a string, generate aUUID
from that string, and fetch the corresponding character from the model using that ID. - If a character is found, add it to the
path
.
Build and run the app. In the first tab, tap Agatha to navigate to her character detail view. Perform a cold launch, and this time when the app relaunches, the detail screen for Agatha shows automatically. Tap back to navigate back to the book screen for The Good Hawk.
Next, tap the tab for The Broken Raven. This doesn't look right. As soon as the app loads the tab, it automatically opens the character view for Agatha, even though she shouldn't be in the list for that book. What's going on?