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?
Recognizing That Books Are Unique
The key to understanding this bug is recognizing that each tab in the app uses the same key for any property attributed with the SceneStorage
property wrapper, and thus, all tabs share the property.
In fact, you can see this same issue with all the other items the app has saved for state restoration already. Try adding a draft note to any of the books. Perform a cold launch and navigate to all three of the books. Notice how the app saves a draft for all of them.
Depending on the functionality of your app, this may or may not be a problem. But for the character restoration, it most certainly is a problem. Time to fix it!
First, open ContentView.swift and update the initialization of BookView
to pass in the currently selected tab:
BookView(book: $book, currentlySelectedTab: selectedTab)
This will create a warning — but don't worry — you'll fix that next.
Navigate back to BookView.swift, and add the following code immediately under the book
property:
// 1
let isCurrentlySelectedBook: Bool
// 2
init(book: Binding<Book>, currentlySelectedTab: String) {
// 3
self._book = book
self.isCurrentlySelectedBook = currentlySelectedTab == book.id.uuidString
}
In this code:
- You create a new immutable property,
isCurrentlySelectedBook
which will store if this book is the one currently being displayed. - You add a new initializer that accepts a binding to a
Book
and the ID of the tab currently selected. - The body of the initializer explicitly sets the
book
property before setting theisCurrentlySelectedBook
property if thecurrentlySelectedTab
matches the ID for the book represented by this screen.
Finally, update the preview at the bottom of the file:
BookView(
book: .constant(Book(
identifier: UUID(),
title: "The Good Hawk",
imagePrefix: "TGH_Cover",
tagline: "This is a tagline",
synopsis: "This is a synopsis",
notes: [],
amazonURL: URL(string: "https://www.amazon.com/Burning-Swift-Shadow-Three-Trilogy/dp/1536207497")!,
characters: []
)),
currentlySelectedTab: "1234"
)
The only difference with the previous preview is the addition of the currentlySelectedTab
argument.
Build the app, and now it will compile without any problems.
Updating the Scene Change
Still in BookView.swift, remove the onChange
view modifier you added in the previous section, and replace it with the following:
.onChange(of: scenePhase) { newScenePhase in
if newScenePhase == .inactive {
// 1
if isCurrentlySelectedBook {
if path.isEmpty {
encodedCharacterPath = nil
}
// 2
if let currentCharacter = path.first {
encodedCharacterPath = model.encodePathFor(character: currentCharacter, from: book)
}
}
}
if newScenePhase == .active {
if let characterPath = encodedCharacterPath,
// 3
let (stateRestoredBook, stateRestoredCharacter) =
try? model.decodePathForCharacterFromBookUsing(characterPath) {
// 4
if stateRestoredBook.id == book.id {
// 5
path = [stateRestoredCharacter]
}
}
}
}
The structure of the above is very similar to the last one you added, with some important differences:
- This time, the app only saves the character for the book it displays. The app ignores this logic for all other books.
- Next, rather than saving the ID of the character into scene storage, you call
encodePathFor(character:from:)
on the book model. You can view this method by opening BookModel.swift. It's just a simple function that takes aCharacter
and aBook
and returns aString
formatted asb|book_id::c|character_id
.book_id
andcharacter_id
are the IDs of the book and character, respectively. - Later, when the view is relaunched, the IDs for the book and character are decoded and then loaded from the model.
- If successful, the app checks the restored book ID against the book ID for this tab. If they match, it updates the
path
.
Build and run the app.
This time, navigate to the first character in each of the three books. Perform a cold launch from the third tab. When the app relaunches, it selects the tab for The Burning Swift and shows the detail view for Lady Beatrice. Navigate to both the other book tabs and notice that the book view rather than a character view is shown.
Understanding Active Users
So far, you've focused on restoring state from a previous session when an app launches. Another type of state restoration is also common for iOS apps — restoring from a user activity.
You'll use user activity, represented by the NSUserActivity
class, to restore state when moving from outside your app back into it. Examples include loading a particular view from a Siri search result, deep linking from a Quick Note or performing a Handoff to another iOS or macOS device.
In each of these cases, when iOS launches your app, and a user activity is presented, your app can use the information from the outside app to set your state appropriately.
Adding Window Dressing
Now, you'll add support for multiple windows to Hawk Notes and use NSUserActivity
to load the correct content when the app launches a new window.
First, you need to tell iOS that your app supports multiple windows. Open the Info.plist file. Find the row with the key Application Scene Manifest, and use the disclosure indicator on the far left of the row to open the contents of the array. Update the value for Enable Multiple Windows to YES
.
Next, hover over the little up/down arrow in the center of the last row until a plus icon appears, and click that to create a new row.
Name the key NSUserActivityTypes
, and set its type to Array.
Use the disclosure indicator on the far left of the row to open the — currently empty — array. Then, click the plus icon again. This time, Xcode creates a new item within the NSUserActivityTypes
array called Item 0. Set the value of this row to:
com.raywenderlich.hawknotes.staterestore.characterDetail
This registers a new user activity type with iOS and tells it to open Hawk Notes when the app launches from a user activity with this key.
Next, open BookView.swift.
At the very top of the BookView
declaration, immediately before defining the model
, add the following line:
static let viewingCharacterDetailActivityType = "com.raywenderlich.hawknotes.staterestore.characterDetail"
This is the same key that you used in Info.plist earlier.
Next, locate the initialization of the CharacterListRowView
view, and add a new onDrag
view modifier to it:
// 1
.onDrag {
// 2
let userActivity = NSUserActivity(activityType: BookView.viewingCharacterDetailActivityType)
// 3
userActivity.title = character.name
userActivity.targetContentIdentifier = character.id.uuidString
// 4
try? userActivity.setTypedPayload(character)
// 5
return NSItemProvider(object: userActivity)
}
With this code, you're:
- Adding an
onDrag
view modifier to each row in the list of characters. When a row is dragged, you're then: - Creating a new
NSUserActivity
with the key defined earlier. - Setting the title and content of the activity to represent the character being dragged.
- Setting the payload for the user activity to be the
Character
represented by that row.setTypedPayload(_:)
takes anyEncodable
object and, along with its decoding counterparttypedPayload(_:)
, allows for type-safe encoding and decoding of types from the UserInfo dictionary. - Finally, returning an
NSItemProvider
from the drag modifier.NSItemProvider
is simply a wrapper for passing information between windows.
Using the device selector in Xcode, update your run destination to an iPad Pro. Build and run your app.
Once running, if the iPad is in portrait mode, rotate it to landscape mode using Device ▸ Rotate Left from the menu bar.
Drag a character to the left edge of the iPad to trigger a new window before dropping the row.
Your app now supports multiple windows but, unfortunately, doesn't navigate to the selected character.
To fix that, open BookView.swift and add a new view modifier to the GeometryReader
:
// 1
.onContinueUserActivity(
BookView.viewingCharacterDetailActivityType
) { userActivity in
// 2
if let character = try? userActivity.typedPayload(Character.self) {
// 3
path = [character]
}
}
With this code, you:
- Register your
BookView
to receive any user activity with the key from earlier. - Attempt to decode a
Character
instance from the payload, using the decoding half of the type-safe APIs discussed above. - Then, set the path to be used by the
NavigationStack
to contain theCharacter
you just decoded.
Finally, open ContentView.swift and repeat the above, but this time, restoring the state for which book the app should display in the tab view.
Add the following view modifier to the TabView
:
// 1
.onContinueUserActivity(BookView.viewingCharacterDetailActivityType) { userActivity in
// 2
if let character = try? userActivity.typedPayload(Character.self), let book = model.book(introducing: character) {
// 3
selectedTab = book.id.uuidString
}
}
This code:
- Registers
ContentView
to receive any user activity tagged with theviewingCharacterDetailActivityType
type. - Attempts to decode a
Character
from the user activity payload, then fetches the book that introduces that character. - If a book is found, sets the appropriate tab.
Build and run your app. Select the second tab. Drag any character to create a new window and confirm the correct tab displays when it opens.
You did it! That's the end of the tutorial and you've learned all about state restoration with SwiftUI!