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.
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…
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.
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.
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 @Shared
s 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 @AppStorage
s for the same key use different defaults, the first one wins, which can add uncertainty to your application.
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.
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!