Tour of Sharing: App Storage, Part 2

Episode #306 • Dec 9, 2024 • Free Episode

We show how the @Shared property wrapper, unlike @AppStorage, can be used anywhere, not just SwiftUI views. And we show how @Shared has some extra bells and whistles that make it easier to write maintainable Xcode previews and avoid potential bugs around “string-ly” typed keys and default values.

Previous episode
Tour of Sharing: App Storage, Part 2
Next episode
FreeThis episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

Brandon

We have now see the basics of using the @Shared property wrapper in a SwiftUI app, and in particular the appStorage persistence strategy that comes with the library. It gives you a tool that is similar to the @AppStorage property wrapper from SwiftUI, but it works outside of views, including observable models. And we even saw that it improves upon some key aspects, such as being animatable.

Stephen

And so this is all looking great, but it gets better. The @Shared property wrapper can be used in many more places than just observable models. It can be used in a SwiftUI view just like vanilla SwiftUI’s @AppStorage, but it can be used in UIKit view controllers. And it behaves the same in all these different contexts, including automatically re-computing the view when the state changes, as well as listening for changes to user defaults directly so that it can update its state.

Let’s explore this, and more…

Using @Shared everywhere

For example, let’s introduce another counter view, but this time we will hold onto the @Shared(.appStorage) directly in the view:

struct AnotherCounterView: View {
  @Shared(.appStorage("count")) var count = 0
  var body: some View {
    Text(count.description)
      .font(.largeTitle)
    Button("Decrement") {
      $count.withLock { $0 -= 1 }
    }
    Button("Increment") {
      $count.withLock { $0 += 1 }
    }
  }
}

We still do have to use the withLock method on $count, but beyond that this looks like regular vanilla SwiftUI.

Next let’s update our ManyCountersView so that it shows the CounterView and AnotherCounterView:

struct ManyCountersView: View {
  var body: some View {
    Form {
      Section {
        CounterView()
      }

      Section {
        AnotherCounterView()
      }

      Button("Reset") {
        withAnimation {
          UserDefaults.standard.set(0, forKey: "count")
        }
      }
    }
  }
}

And if we run the preview we will see that incrementing one of the counters immediately increments the other. So it doesn’t matter than one view is powered by an observable model that holds onto @Shared(.appStorage) and the other is a view without a model and holds onto @Shared(.appStorage). It all just works as you would expect.

And of course this @Shared property wrapper plays nicely with any existing @AppStorage properties you may have. To see this, let’s add yet another counter view, this time powered by @AppStorage:

struct AppStorageCounterView: View {
  @AppStorage("count") var count = 0
  var body: some View {
    Text(count.description)
      .font(.largeTitle)
    Button("Decrement") {
      count -= 1
    }
    Button("Increment") {
      count += 1
    }
  }
}

Let’s add this view to our ManyCountersView, and let’s go ahead and add some titles so that we know which counter is which:

Section {
  CounterView()
} header: {
  Text("@Shared in an observable model")
}
Section {
  AnotherCounterView()
} header: {
  Text("@Shared in a view")
}
Section {
  AppStorageCounterView()
} header: {
  Text("@AppStorage")
}

Running this in the simulator shows that everything works exactly as we would hope. Incrementing any one of these counters will increment all of the others. And reseting the count back to 0 resets all of the counters. This means that the @Shared and @AppStorage property wrappers are kept in sync, and this even happens when @Shared is held in a model or a view.

You can even hold a @Shared value in a UIKit controller and it will still work exactly as you would hope. I’m not going to type this from scratch, but here is a view controller that implements the behavior of a counter:

import Combine

final class CounterViewController: UIViewController {
  @Shared(.appStorage("count")) var count = 0
  var cancellables: Set<AnyCancellable> = []

  struct Representable: UIViewControllerRepresentable {
    func makeUIViewController(
      context: Context
    ) -> CounterViewController {
      CounterViewController()
    }
    func updateUIViewController(
      _ uiViewController: CounterViewController,
      context: Context
    ) {}
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let stackView = UIStackView()
    stackView.translatesAutoresizingMaskIntoConstraints = false
    stackView.distribution = .fillProportionally
    stackView.spacing = 8
    view.addSubview(stackView)

    let countLabel = UILabel()
    countLabel.textColor = .black
    countLabel.font = .preferredFont(forTextStyle: .largeTitle)
    stackView.addArrangedSubview(countLabel)

    let decrementButton = UIButton(type: .system)
    decrementButton.setTitle("Decrement", for: .normal)
    decrementButton.titleLabel?.font = .preferredFont(
      forTextStyle: .body
    )
    decrementButton.addAction(
      UIAction { [$count] _ in
        $count.withLock { $0 -= 1 }
      },
      for: .touchUpInside
    )
    stackView.addArrangedSubview(decrementButton)

    let incrementButton = UIButton(type: .system)
    incrementButton.setTitle("Increment", for: .normal)
    incrementButton.titleLabel?.font = .preferredFont(
      forTextStyle: .body
    )
    incrementButton.addAction(
      UIAction { [$count] _ in
        $count.withLock { $0 += 1 }
      },
      for: .touchUpInside
    )
    stackView.addArrangedSubview(incrementButton)

    NSLayoutConstraint.activate([
      stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      stackView.topAnchor.constraint(equalTo: view.topAnchor),
      stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
      stackView.heightAnchor.constraint(equalToConstant: 300)
    ])

    $count.publisher
      .sink {
        countLabel.text = $0.description
      }
      .store(in: &cancellables)
  }
}

There’s not too many interesting things in this code except for possibly this:

$count.publisher
  .sink { countLabel.text = $0.description }
  .store(in: &cancellables)

This is one way you can listen for changes in a shared value in order to update the UI. Every Shared instance exposes a publisher property so that you can subscribe to changes.

There is another way to subscribe to changes to a shared value that is a lot more ergonomic. The internals of @Shared are powered by Swift’s powerful observation framework, and this means you can instantly observe changes by simply accessing the state in an appropriate context. In SwiftUI that context is the body of a view, but in UIKit we have no such affordances.

Well, luckily for us we have a library called Swift Navigation that provides a powerful tool for observe changes to state. It allows you to rewrite this Combine publisher change as simply this:

observe { [weak self] in
  guard let self else { return }
  countLabel.text = count.description
}

Any state you access inside the observe trailing closure will be observed, and the closure will be invoked only when that state changes. If unrelated state is mutated then this closure will not be called.

This is a powerful tool, but it does belong in a separate library. We don’t want to force people to depend on that library just to use the @Shared property wrapper, so you will have to add it as a dependency if you want this tool. But we won’t do that for now:

// observe { [weak self] in
//   guard let self else { return }
//   countLabel.text = count.description
// }

And now we can include this UIKit-based counter right alongside our other variations of the SwiftUI counter using the a representable defined inside it:

Section {
  CounterViewController.Representable()
} header: {
  Text("@Shared in a view controller")
}

And amazingly this all works exactly as you would expect. We can run it in the simulator, and incrementing any of these counters will magically increment all of the others. And reseting the count back to zero by writing directly to user defaults also makes every single view update immediately. All 4 of these very different views are sharing state and fully synchronized.

And right now the UIKit-version of this counter does not animate when we reset the count back to zero, but we can easily fix that. If we want to animate that change we just need to wrap it in the UIKit animation tool:

Button(#"UserDefaults.set(0, forKey: "count")"#) {
  UIView.animate(withDuration: 0.35) {
    withAnimation {
      UserDefaults.standard.set(0, forKey: "count")
    }
  }
}

And now it magically animates. But note that still the @AppStorage version does not animate, and cannot animate due to the issues we described earlier.

Previews and type-safe keys

We have now seen that the @Shared property wrapper can essentially be used anywhere. It can be used in an observable model, it can be used in a UIKit view controller, and it can even be used directly in a SwiftUI view. All instances of @Shared are automatically kept in sync, even when some part of your code base decides to mutate the UserDefaults directly.

This is cool and all, but while you may see the benefits of using @Shared in an observable model, you may be wondering why would you ever use @Shared directly in the view. It seems that @AppStorage mostly works just fine, and we do agree that there are not many reasons to look for an alternative.

Brandon

But there are a few key reasons you may still want to use @Shared in a view, some of which we already covered:

  • It fixes a possible animation glitch where it is not possible to animate your views.

  • It works cross-process, such as in widgets and app extensions, whereas @AppStorage does not.

  • And it provides a single, unified interface to interacting with many types of persistence strategies, not just UserDefaults.

But there are a few more reasons, and they give us an opportunity to show off a few other super powers of the @Shared property wrapper. It has to do with how @Shared works in previews, as well as providing type safe keys for app storage.

Let’s dig in.

Let’s start by seeing how the @Shared property wrapper behaves in previews. We have purposely been running the app in the simulator because our previews currently behave in a way that may seem a little surprising.

If we run the preview for the ManyCountersView we will find that for some reason the @AppStorage counter seems to be separate from all the other counters. Incrementing it does not affect the others, and increments the others does not affect it.

The reason this is happening is because Sharing’s .appStorage uses a dependency under the hood to control which UserDefaults is used. This may sound similar to @AppStorage, which has an environment value called defaultAppStorage to control which UserDefaults is used under the hood. However, there is an important distinction.

SwiftUI’s default app storage is the shared reference to UserDefaults.standard, which means all previews technically share the same user defaults. This means that change made in one preview will also change every other preview.

For example, suppose we had two previews set up for the AppStorageCounterView:

#Preview("AppStorageCounterView: Version 1") {
  AppStorageCounterView()
}
#Preview("AppStorageCounterView: Version 2") {
  AppStorageCounterView()
}

If we increment in one and then switch to another we will see that the other preview was changed. This can be really annoying in practice. You may want one preview to run in a very specific state, and that will be difficult now that every other preview can mutate this shared state.

This forces us to reset the user defaults for each key that we are interested in for the preview:

#Preview("AppStorageCounterView: Large Count") {
  let _ = UserDefaults.standard.set(1_000_000, forKey: "count")
  Form { AppStorageCounterView() }
}

But really we have to remember to do this for every preview if we don’t want some unrelated preview overwriting our data:

#Preview("AppStorageCounterView") {
  let _ = UserDefaults.standard.set(0, forKey: "count")
  Form { AppStorageCounterView() }
}

And that is a serious pain.

So that is why the default app storage for @Shared works differently. When the code is executing in a preview context, the default app storage points to a temporary file on disk. We can even see that by hoping over to how the dependency is defined:

static var testValue: UncheckedSendable<UserDefaults> {
  UncheckedSendable(
    UserDefaults(
      suiteName: """
        \(NSTemporaryDirectory())\
        co.pointfree.Sharing.\(UUID().uuidString)
        """
    )!
  )
}
static var previewValue: UncheckedSendable<UserDefaults> {
  testValue
}

By pointing the suite name to a file in the temporary directory and giving it a unique name we guarantee that each run of a preview will get a whole new user defaults that is completely empty. And those suites will ultimately be deleted by the operating system.

This completely fixes the problem. If we have two previews running the CounterView:

#Preview("CounterView: Version 1") {
  Form { CounterView() }
}
#Preview("CounterView: Version 2") {
  Form { CounterView() }
}

…any changes made by one will not affect the other.

And further we still have the ability to run one of these previews in a very specific state by overriding the .appStorage in the #Preview:

#Preview("CounterView: Large count") {
  @Shared(.appStorage("count")) var count = 1_000_000
  Form { CounterView() }
}
#Preview("CounterView") {
  Form { CounterView() }
}

But that change is only visible to that one preview. No other preview will see that change.

So, that is why we are seeing the counts differ in the ManyCountersView. The three sub-views using @Shared are technically powered by a different user defaults than the @AppStorage.

There are a few ways you can fix. You can override the defaultAppStorage dependency that powers @Shared to put in the live, standard user defaults:

#Preview(
  traits: .dependency(\.defaultAppStorage, .standard)
) {
  ManyCountersView()
}

Now everything works as you expect, but it does mean changes made in this preview will bleed over into any other preview using UserDefaults.standard.

Or, on the flip side, we could get the temporary user defaults used by @Shared and put it in the environment:

#Preview {
  @Dependency(\.defaultAppStorage) var store
  ManyCountersView()
    .defaultAppStorage(store)
}

And now this works as you would expect, and the changes made in this preview will not bleed over into other previews. Feel free to use either of these techniques, but also we don’t think there is really that big of a use case for using @AppStorage if you are willing to make use of our Sharing library. You might as well just use @Shared anywhere you are willing to use @AppStorage. And this preview quarantining functionality holds for all the persistence strategies that come with the library, such as file and in-memory storage, so that previews using those strategies will not bleed over to other previews. And you can even provide this functionality for your own custom persistence strategies if you choose to implement one, and we will even look into that in more detail later on in this series.

There is still one thing off with our view and that is that the reset functionality doesn’t seem to work in previews. This is again a dependency problem, and we in fact have an uncontrolled dependency in our view:

UserDefaults.standard.set(0, forKey: "count")

We are reaching out to the uncontrolled UserDefaults.standard dependency in our view, and that isn’t the user defaults @Shared uses when run in previews.

To fix this we need the view to depend on the default app storage that @Shared uses:

struct ManyCountersView: View {
  @Dependency(\.defaultAppStorage) var store
  …
} 

And then use that in the view instead of using an uncontrolled dependency:

Button(#"UserDefaults.set(0, forKey: "count")"#) {
  UIView.animate(withDuration: 0.35) {
    withAnimation {
      store.set(0, forKey: "count")
    }
  }
}

And now everything works exactly as it did before. This all shows that @Shared has some really powerful functionality when it comes to previews. By default @Shareds used across previews are quarantined from each other, and this can even work for custom persistence strategies that the library does not come with. And if you do need to provide some very specific state for your shared state, you can still do that.

There’s another aspect of @AppStorage that @Shared improves upon, and that is providing a type-safe key for the storage. @AppStorage is a string-based API in that you provide a string for the key:

@AppStorage("count") var count = 0

Here we are saying that the count variable is powered by the "count" key in user defaults. And we further want to specify that the value held in user defaults is an integer.

However, nothing about this API is enforcing any of these conditions. Nothing is stopping us from accidentally having a typo in the key name somewhere else in our app:

@AppStorage("counter") var count = 0

This compiles just fine and we will not know that technically we have two completely disconnected pieces of state until we notice the bug when running our app.

Now one thing we could do to fix this is define a static property on string to have a single place to define the key:

extension String {
  static var count: String { "count" }
}

And now if we do this everywhere:

@AppStorage(.count) var count = 0

…we reduce the risk of typos.

But still, there is nothing that ties the type of the “count” key to an integer. We are still free to store any kind of data type in this field. For example, we could have another view that accidentally specifies a boolean for this key:

struct ToggleView: View {
  @AppStorage(.count) var count = false
  var body: some View {
    HStack {
      Text("\(count)")
      Button("Toggle") { count.toggle() }
    }
  }
}

This is perfectly valid code, and if we ran ToggleView in isolation it would exactly as we expect. But, if we embed this view in the AppStorageCounterView:

struct AppStorageCounterView: View {
  …
  var body: some View {
    …

    ToggleView()
  }
}

…things no longer work. When mutating the count as an integer it resets the boolean, and mutating the boolean resets the integer. These two @AppStorage instances are now fighting with each other. And there is no easy fix to this problem.

Our Sharing library provides a solution to this. One can define a single static symbol that simultaneously describes the string key used in user defaults and the type of the value stored:

extension SharedKey where Self == AppStorageKey<Int> {
  static var count: Self {
    .appStorage("count")
  }
}

And everywhere we used .appStorage("count") before we can now just use .count:

// @Shared(.appStorage("count"))
@Shared(.count) 

Everything works exactly as it did before, but now we are protected against typos in the string key and mismatches of the type:

@Shared(.count) var count = false

 Cannot convert value of type ‘Bool’ to expected argument type ’Int’

There is even a way to bake the default value into this key:

extension SharedKey where Self == AppStorageKey<Int>.Default {
  static var count: Self {
    Self[.appStorage("count"), default: 0]
  }
}

That makes it so that we can even drop the default value from all declarations:

@Shared(.count) var count  // = 0

Though note that we still can provide a default where it makes sense, like previews:

#Preview("CounterView: Large count") {
  @Shared(.count) var count = 1_000_000
  CounterView()
}

Everything still compiles and works exactly as it did before, but we now have one single static symbol representing our persisted value. It encapsulates the string-y key, the type of data stored, and even the default value.

And these are all improvements on @AppStorage, which provides no way to have a statically type-safe key, and always requires a default at each site, and so if multiple @AppStorages for the same key use different defaults, the first one wins, which can add uncertainty to your application.

Next time: file storage

We have now given a very brief tour of what our new Sharing library has to offer. At its core it provides a @Shared property wrapper that can be used basically anywhere. It can be used in SwiftUI views, in observable models, in UIKit controllers, and more. And it works exactly as you expect. Changes to the state cause the view to update automatically, and if someone updates the state externally then the @Shared value also immediately updates.

But storing simple values in user defaults is barely even scratching the surface of what the @Shared property has to offer. There are other persistence strategies provided by the library beyond just .appStorage, and everything we have learned so far equally applies to those strategies.

Stephen

The next one we will consider is the .fileStorage strategy. It allows you to model shared state in your application whose source-of-truth ultimate lies on the file system. It is appropriate to use for more complex pieces of state that can be serialized to bytes because user defaults is really meant for simple data types, such as strings, booleans, integers, and so on.

We are going to make use of this persistence strategy by building a simple feature that we have explored a number of times on Point-Free, most recently when we explored cross-platform Swift. We are going to make it so that we can request a fact about the number our counter is set to, and then make it so that we can save our favorite facts. We would like those facts to be persisted across launches of the app, and so let’s see how that can be accomplished with the @Shared property wrapper. It may seem like a silly demo to build, but it helps explore the foundational concepts of side effects and persistence without getting bogged down in too many superfluous details.

Let’s dig in…next time!


References

Downloads

Sample code

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 68 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing