State Restoration in SwiftUI

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

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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!