Learn how to use SceneStorage in SwiftUI to restore iOS app state.
In almost any app imaginable, some state will be defined by actions the user has taken — the tab the user last selected, the items the user added to a basket or the contents of a draft message.
It would be confusing for users if they were to stop using your app for a short while, then come back to find the state lost. But this is the default behavior for iOS apps. Why? Because after a user puts an app into the background, the operating system may choose to terminate it at any time. When this happens, the system discards in-memory state.
There is a feature in iOS where the operating system can restore state when an app is re-launched. This is known as state restoration.
In this tutorial, you’ll learn:
- How to add state restoration to your SwiftUI apps.
- The
@SceneStorage
property wrapper for saving state of simple data. - Using
NSUserActivity
to pass state when launching an app.
Time to get started!
Getting Started
Download the project materials by clicking the Download Materials button at the top or bottom of this tutorial. The materials contain a project called Hawk Notes. This tutorial builds an app for making study notes for the Shadow Skye series, a trilogy of epic fantasy books.
Open the starter project. Now, build and run the app using Product ▸ Run from the toolbar or by clicking the Run arrow at the top of the Project Navigator. Once running, the app displays four tabs: one for each of the three books and a fourth for your favorite characters.
Within each of the first three tabs, you’ll see four things:
- An overview of the book.
- A link to view it on Amazon.
- A list of study notes you can make about the book, which is currently empty.
- A list of the book’s main characters.
Tap a character, and the app navigates to a character detail screen. This screen contains a synopsis for the character as well as a list of notes you can add about that character. You can also tap the heart to mark this character as one of your favorites.
Finally, tap the Favorites tab. There, the app lists all the characters, split into two sections: one for your favorites and another for all the others.
Switch back to Xcode and take a look around the code. Open ContentView.swift. This is the entry point into the app proper. Notice how it defines a BookModel
environment object. This model contains the information for each book and is the primary data source for the app. The content view itself displays a tab view with the four tabs from above — one for each book plus the favorites tab.
Next, open BookView.swift. This is the view for displaying a book. The view comprises a vertical stack containing an overview, a link to view the book on Amazon, a list of notes and finally, a list of characters for this book.
Next, open CharacterView.swift. Here, a ScrollView
contains a VStack
showing views for the character’s avatar, a toggle switch for marking the character as a favorite, a synopsis for the character and finally, the notes for the character.
Finally, open FavoritesView.swift. This view shows a list of all the main characters for the three books split into two sections: first, a list of your favorite characters, and secondly, a list of all the other characters.
Switch to the Simulator and select the third tab for The Burning Swift. Now, put the app in the background by selecting Device ▸ Home. Next, switch back to Xcode and stop the app from running by selecting Product ▸ Stop in the menu. Build and run the app again.
Note: You’ll perform the process of putting the app in the background before terminating it many times throughout the rest of this tutorial. From this point on, if the tutorial asks you to perform a cold launch of the app, this is what you should do.
Once the app restarts, note how the third tab is no longer selected. This is an example of an app that doesn’t restore state.
It’s time to learn a little more about how a SwiftUI app’s Scene works now.
Understanding Scene Storage
In SwiftUI, a Scene
is a container for views that have their lifecycle managed by the operating system. All iOS apps start with a single Scene
. Open AppMain.swift, and you can see this for yourself.
// 1
@main
struct AppMain: App {
private var booksModel = BooksModel()
// 2
var body: some Scene {
WindowGroup("Hawk Notes", id: "hawk.notes") {
ContentView()
.environmentObject(booksModel)
}
}
}
In the code above, which is already in your app:
-
AppMain
is of typeApp
. The@main
attribute signals to the runtime that this is the entry point for the entire app. - The
body
property for anApp
returns aScene
that acts as the container for all views in the app.
To make state restoration really easy, Apple provides a new attribute you can add to a property: @SceneStorage
.
SceneStorage
is a property wrapper that works very similarly to the State
property wrapper, which you may have used already. Like State
, your code can both read and write to a property attributed with @SceneStorage
, and SwiftUI automatically updates any parts of your app that read from it.
SwiftUI also saves the value of properties attributed with @SceneStorage
into persistent storage — a database — when the app is sent to the background. Then, it automatically retrieves and initializes the property with that value when the app enters the foreground again.
Because of this, SceneStorage
is perfect for adding state restoration to your apps.
It really is that simple! So let’s now start coding.
Saving State
It’s time to add some state restoration goodness to the Hawk Notes app. Open ContentView.swift.
Near the top of the view, find the line that defines the selected tab for the app:
@State var selectedTab = ""
Update this line to use the SceneStorage
property wrapper like so:
@SceneStorage("ContentView.CurrentTab") var selectedTab = ""
With this change, you’ve updated the selectedTab
property to use SceneStorage
— rather than State
— with an identifier to use as its storage key: ContentView.CurrentTab
. The identifier should be unique within your app. This allows you to create multiple SceneStorage
variables which won’t clash with each other.
Build and run the app. Once running, switch to the third tab again. Then perform a cold launch of the app that you learned how to perform earlier.
How easy was that! By simply changing the attribute on the selectedTab
property from @State
to @SceneStorage(...)
, your app now automatically restores the state correctly when launched. That was easy!
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:
“Ensure that the data you use with 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?
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!
Where to Go From Here?
You can use the Download Materials button at the top or bottom of this tutorial to download the starter and final projects.
Congratulations! You’ve learned how easy it is to add state restoration to your app using the SceneStorage
modifier and NSUserActivity
.
You’ve seen how powerful SceneStorage
can be for restoring simple data types, but also how you have a little more work to do if you plan to reuse the same View
in multiple places, like tabs in a TabView
, or if you need to restore complex types like model objects.
Along the way, you touched on some more advanced topics such as generics and property declaration attributes like @State
and @SceneStorage
.
You’ve also used the new NavigationStack
introduced with iOS 16, and seen one way to work around problems caused when an API with stronger type safety, NavigationStack
, interacts with an API that prefers simple data types, SceneStorage
.
And most importantly, you’ve been introduced to Jamie, Agatha, Sigrid and all the most important characters from the Shadow Skye trilogy!
We hope you enjoyed this tutorial, and if you have any questions or comments, please join the forum discussion below!