6

State Restoration in SwiftUI [FREE]

 1 year ago
source link: https://www.raywenderlich.com/34862236-state-restoration-in-swiftui
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Home iOS & Swift Tutorials

State Restoration in SwiftUI

Learn how to use SceneStorage in SwiftUI to restore iOS app state.

By Tom Elliott Oct 4 2022 · Article (30 mins) · Beginner

Version

StateRestorationInSwiftUI-feature.png

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.

The demo app - Hawk Notes

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.

Example of an app without state restoration

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:

  1. AppMain is of type App. The @main attribute signals to the runtime that this is the entry point for the entire app.
  2. The body property for an App returns a Scene 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.

Restoring the selected tab

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!

State restoration in action

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.

Restore Amazon state after relaunching

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.

Using state restoration to restore a draft note

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
Attempting to save a complex model object using Scene Storage generates a compiler error

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:

  1. Add a view modifier that performs an action when the scenePhase property changes.
  2. When the new scene phase is inactive — meaning the scene is no longer being shown:
  3. Set the encodedCharacterPath property to nil if no characters are set in the path, or
  4. Set the encodedCharacterPath to a string representation of the ID of the displayed character, if set.
  5. Then, when the new scene phase is active again:
  6. Unwrap the optional encodedCharacterPath to a string, generate a UUID from that string, and fetch the corresponding character from the model using that ID.
  7. 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?

Broken state restoration showing Agatha in every tab

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:

  1. You create a new immutable property, isCurrentlySelectedBook which will store if this book is the one currently being displayed.
  2. You add a new initializer that accepts a binding to a Book and the ID of the tab currently selected.
  3. The body of the initializer explicitly sets the book property before setting the isCurrentlySelectedBook property if the currentlySelectedTab 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:

  1. This time, the app only saves the character for the book it displays. The app ignores this logic for all other books.
  2. 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 a Character and a Book and returns a String formatted as b|book_id::c|character_id. book_id and character_id are the IDs of the book and character, respectively.
  3. Later, when the view is relaunched, the IDs for the book and character are decoded and then loaded from the model.
  4. 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.

Detail showing state restoration only occurs for the current tab

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.

Updating the Info.plist to support multiple windows

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:

  1. Adding an onDrag view modifier to each row in the list of characters. When a row is dragged, you're then:
  2. Creating a new NSUserActivity with the key defined earlier.
  3. Setting the title and content of the activity to represent the character being dragged.
  4. Setting the payload for the user activity to be the Character represented by that row. setTypedPayload(_:) takes any Encodable object and, along with its decoding counterpart typedPayload(_:), allows for type-safe encoding and decoding of types from the UserInfo dictionary.
  5. 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.

Selecting an iPad as a run destination

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.

Basic multi-window support

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:

  1. Register your BookView to receive any user activity with the key from earlier.
  2. Attempt to decode a Character instance from the payload, using the decoding half of the type-safe APIs discussed above.
  3. Then, set the path to be used by the NavigationStack to contain the Character you just decoded.
Deep linking to the correct Character when opening a second window

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:

  1. Registers ContentView to receive any user activity tagged with the viewingCharacterDetailActivityType type.
  2. Attempts to decode a Character from the user activity payload, then fetches the book that introduces that character.
  3. If a book is found, sets the appropriate tab.
Deep linking to the correct tab when opening a second window

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!

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK