Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce @SharedReader #2979

Merged
merged 14 commits into from
Apr 10, 2024
4 changes: 4 additions & 0 deletions Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CAA9ADCC2446615B0003A984 /* 03-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */; };
CABC4F3926AEE00C00D5FA2C /* 03-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */; };
CABC4F3B26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */; };
CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */; };
CADECDB62B5CA228009DC881 /* 02-SharedState-InMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */; };
CADECDB82B5CA425009DC881 /* 02-SharedState-FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */; };
CADECDBA2B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */; };
Expand Down Expand Up @@ -179,6 +180,7 @@
CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-LongLivingTests.swift"; sourceTree = "<group>"; };
CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Refreshable.swift"; sourceTree = "<group>"; };
CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-RefreshableTests.swift"; sourceTree = "<group>"; };
CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-Notifications.swift"; sourceTree = "<group>"; };
CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-InMemory.swift"; sourceTree = "<group>"; };
CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-FileStorage.swift"; sourceTree = "<group>"; };
CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedStateUserDefaultsTests.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -407,6 +409,7 @@
DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */,
CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */,
CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */,
CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */,
CADECDBF2B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift */,
CAA0CC3D2B8D3B4A00D7AF54 /* 02-SharedState-Sandboxing.swift */,
CA7BC8ED245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift */,
Expand Down Expand Up @@ -751,6 +754,7 @@
DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */,
DC072322244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift in Sources */,
DC89C45324465452006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift in Sources */,
CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */,
DCC68EE32447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift in Sources */,
CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */,
DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */,
Expand Down
9 changes: 9 additions & 0 deletions Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ struct RootView: View {
SharedStateSandboxingView(store: store)
}
}
NavigationLink("Notifications") {
Demo(
store: Store(
initialState: SharedStateNotifications.State()
) { SharedStateNotifications() }
) { store in
SharedStateNotificationsView(store: store)
}
}
Button("Sign up flow") {
isSignUpCaseStudyPresented = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ struct Stats: Codable, Equatable {
}
}

extension PersistenceKey where Self == FileStorageKey<Stats> {
extension PersistenceReaderKey where Self == FileStorageKey<Stats> {
fileprivate static var stats: Self {
fileStorage(.documentsDirectory.appending(path: "stats.json"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ private struct ProfileTabView: View {
)
}

extension PersistenceKey where Self == InMemoryKey<Stats> {
extension PersistenceReaderKey where Self == InMemoryKey<Stats> {
fileprivate static var stats: Self {
inMemory("stats")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import ComposableArchitecture
import SwiftUI

private let readMe = """
This application demonstrates how to use the `@SharedReader` tool to introduce a piece of \
read-only state to your feature whose true value lives in an external system. In this case, \
the state is the number of times a screenshot is taken, which is counted from the \
`userDidTakeScreenshotNotification` notification.

Run this application in the simulator, and take a few screenshots by going to \
*Device › Trigger Screenshot* in the menu, and observe that the UI counts the number of times \
that happens.

The `@SharedReader` state will update automatically when the screenshot notification is posted \
by the system, and further you can use the `.publisher` property on `@SharedReader` to listen \
for any changes to the data.
"""

@Reducer
struct SharedStateNotifications {
@ObservableState
struct State: Equatable {
var fact: String?
@SharedReader(.screenshotCount) var screenshotCount = 0
}
enum Action {
case factResponse(Result<String, Error>)
case onAppear
}
@Dependency(\.factClient) var factClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .factResponse(.success(fact)):
state.fact = fact
return .none

case .factResponse(.failure):
return .none

case .onAppear:
return .run { [screenshotCount = state.$screenshotCount] send in
for await count in screenshotCount.publisher.values {
await send(.factResponse(Result { try await factClient.fetch(count) }))
}
}
}
}
}
}

struct SharedStateNotificationsView: View {
let store: StoreOf<SharedStateNotifications>

var body: some View {
Form {
Section {
AboutView(readMe: readMe)
}

Text("A screenshot of this screen has been taken \(store.screenshotCount) times.")
.font(.headline)

if let fact = store.fact {
Text("\(fact)")
}
}
.navigationTitle("Long-living effects")
.task { await store.send(.onAppear).finish() }
}
}

extension PersistenceReaderKey where Self == NotificationReaderKey<Int> {
static var screenshotCount: Self {
NotificationReaderKey(
initialValue: 0,
name: MainActor.assumeIsolated {
UIApplication.userDidTakeScreenshotNotification
}
) { value, _ in
value += 1
}
}
}

struct NotificationReaderKey<Value: Sendable>: PersistenceReaderKey {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something like this could be moved into library code, but for now I've kept it in this case study.

let name: Notification.Name
private let transform: @Sendable (Notification) -> Value

init(
initialValue: Value,
name: Notification.Name,
transform: @Sendable @escaping (inout Value, Notification) -> Void
) {
self.name = name
let value = LockIsolated(initialValue)
self.transform = { notification in
value.withValue { [notification = UncheckedSendable(notification)] in
transform(&$0, notification.wrappedValue)
}
return value.value
}
}

func load(initialValue: Value?) -> Value? { nil }

func subscribe(
initialValue: Value?,
didSet: @Sendable @escaping (Value?) -> Void
) -> Shared<Value>.Subscription {
let token = NotificationCenter.default.addObserver(
forName: name,
object: nil,
queue: nil,
using: { notification in
didSet(self.transform(notification))
}
)
return Shared.Subscription {
NotificationCenter.default.removeObserver(token)
}
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ struct SharedStateSandboxingView: View {
}
}

extension PersistenceKey where Self == AppStorageKey<Int> {
extension PersistenceReaderKey where Self == AppStorageKey<Int> {
static var appStorageCount: Self {
Self("appStorageCount")
}
}
extension PersistenceKey where Self == FileStorageKey<Int> {
extension PersistenceReaderKey where Self == FileStorageKey<Int> {
static var fileStorageCount: Self {
Self(url: URL.documentsDirectory.appending(path: "fileStorageCount.json"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private struct ProfileTabView: View {
}
}

extension PersistenceKey where Self == AppStorageKey<Int> {
extension PersistenceReaderKey where Self == AppStorageKey<Int> {
fileprivate static var count: Self {
appStorage("sharedStateDemoCount")
}
Expand Down
2 changes: 1 addition & 1 deletion Examples/SyncUps/SyncUps/SyncUpsList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ struct SyncUpsList_Previews: PreviewProvider {
)
}

extension PersistenceKey where Self == FileStorageKey<IdentifiedArrayOf<SyncUp>> {
extension PersistenceReaderKey where Self == FileStorageKey<IdentifiedArrayOf<SyncUp>> {
static var syncUps: Self {
fileStorage(.documentsDirectory.appending(component: "sync-ups.json"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ And then define a static function on the ``PersistenceKey`` protocol for creatin
persistence strategy:

```swift
extension PersistenceKey {
extension PersistenceReaderKey {
public static func custom<Value>(/*...*/) -> Self
where Self == CustomPersistence<Value> {
CustomPersistence(/* ... */)
Expand Down Expand Up @@ -663,7 +663,7 @@ To add some type-safety and reusability to this process you can extend the ``Fil
to add a static variable for describing the details of your persistence:

```swift
extension PersistenceKey where Self == FileStorageKey<IdentifiedArrayOf<User>> {
extension PersistenceReaderKey where Self == FileStorageKey<IdentifiedArrayOf<User>> {
static let users: Self {
fileStorage(URL(/* ... */))
}
Expand Down Expand Up @@ -694,7 +694,7 @@ This technique works for all types of persistence strategies. For example, a typ
key can be constructed like so:

```swift
extension PersistenceKey where Self == InMemoryKey<IdentifiedArrayOf<User>> {
extension PersistenceReaderKey where Self == InMemoryKey<IdentifiedArrayOf<User>> {
static var users: Self {
inMemory("users")
}
Expand All @@ -704,7 +704,7 @@ extension PersistenceKey where Self == InMemoryKey<IdentifiedArrayOf<User>> {
And a type-safe `.appStorage` key can be constructed like so:

```swift
extension PersistenceKey where Self == AppStorageKey<Int> {
extension PersistenceReaderKey where Self == AppStorageKey<Int> {
static var count: Self {
appStorage("count")
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/ComposableArchitecture/Internal/DefaultSubscript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
final class DefaultSubscript<Value>: Hashable {
var value: Value
init(_ value: Value) {
self.value = value
}
static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool {
lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}

extension Optional {
subscript(default defaultSubscript: DefaultSubscript<Wrapped>) -> Wrapped {
get { self ?? defaultSubscript.value }
set {
defaultSubscript.value = newValue
if self != nil { self = newValue }
}
}
}

extension RandomAccessCollection where Self: MutableCollection {
subscript(
position: Index, default defaultSubscript: DefaultSubscript<Element>
) -> Element {
get { self.indices.contains(position) ? self[position] : defaultSubscript.value }
set {
defaultSubscript.value = newValue
if self.indices.contains(position) { self[position] = newValue }
}
}
}
38 changes: 21 additions & 17 deletions Sources/ComposableArchitecture/SharedState/PersistenceKey.swift
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
/// A type that can persist shared state to an external storage.
///
/// Conform to this protocol to express persistence to some external storage by describing how to
/// save to and load from the external storage, and providing a stream of values that represents
/// when the external storage is changed from the outside. It is only necessary to conform to
/// this protocol if the ``appStorage(_:)-2ntx6``, ``fileStorage(_:)`` or ``inMemory(_:)``
/// strategies are not sufficient for your use case.
///
/// See the article <doc:SharingState> for more information, in particular the
/// <doc:SharingState#Custom-persistence> section.
public protocol PersistenceKey<Value>: Hashable {
public protocol PersistenceReaderKey<Value>: Hashable {
associatedtype Value

/// Loads the freshest value from storage. Returns `nil` if there is no value in storage.
func load(initialValue: Value?) -> Value? // TODO: Should this be throwing?

/// Saves a value to storage.
func save(_ value: Value)

/// Subscribes to external updates.
func subscribe(
initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void
initialValue: Value?,
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
) -> Shared<Value>.Subscription
}

extension PersistenceKey {
extension PersistenceReaderKey {
public func subscribe(
initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void
initialValue: Value?,
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
) -> Shared<Value>.Subscription {
Shared.Subscription {}
}
}

/// A type that can persist shared state to an external storage.
///
/// Conform to this protocol to express persistence to some external storage by describing how to
/// save to and load from the external storage, and providing a stream of values that represents
/// when the external storage is changed from the outside. It is only necessary to conform to
/// this protocol if the ``appStorage(_:)-2ntx6``, ``fileStorage(_:)`` or ``inMemory(_:)``
/// strategies are not sufficient for your use case.
///
/// See the article <doc:SharingState> for more information, in particular the
/// <doc:SharingState#Custom-persistence> section.
public protocol PersistenceKey<Value>: PersistenceReaderKey {
/// Saves a value to storage.
func save(_ value: Value)
}

extension Shared {
public class Subscription {
let onCancel: () -> Void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Dependencies
import Foundation

extension PersistenceKey {
extension PersistenceReaderKey {
/// Creates a persistence key that can read and write to a boolean user default.
///
/// - Parameter key: The key to read and write the value to in the user defaults store.
Expand Down Expand Up @@ -288,7 +288,8 @@ extension AppStorageKey: PersistenceKey {
}

public func subscribe(
initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void
initialValue: Value?,
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making didSet sendable since it will typically be used in concurrent situations.

) -> Shared<Value>.Subscription {
let userDefaultsDidChange = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Dependencies
import Foundation

extension PersistenceKey {
extension PersistenceReaderKey {
/// Creates a persistence key for sharing data in user defaults given a key path.
///
/// For example, one could initialize a key with the date and time at which the application was
Expand Down Expand Up @@ -47,7 +47,8 @@ extension AppStorageKeyPathKey: PersistenceKey {
}

public func subscribe(
initialValue: Value?, didSet: @escaping (_ newValue: Value?) -> Void
initialValue: Value?,
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
) -> Shared<Value>.Subscription {
let observer = self.store.observe(self.keyPath, options: .new) { _, change in
guard
Expand Down
Loading