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

SwiftUI compatibility plan #455

Open
mjarvis opened this issue Oct 19, 2020 · 24 comments
Open

SwiftUI compatibility plan #455

mjarvis opened this issue Oct 19, 2020 · 24 comments

Comments

@mjarvis
Copy link
Member

mjarvis commented Oct 19, 2020

I thought it prudent to start an issue which can describe the necessary steps forward for releasing a SwiftUI compatible version of ReSwift.

At this time, I have a port of ReSwift in mjarvis/swiftui which conforms StoreType to ObservableObject. This allows for one to use @ObservedObject or @EnvironmentObject property wrappers to access a store, allowing direct usage of store.state.xyz in view bodies.

There are a number of concerns that need to be taken care of before this branch can be released in a supported manner.

We can split off individual issues and a milestone for completing the work below after #1 is agreed upon.

1. Implementation

Is my choice of implementation ideal? Perhaps there are other options than making StoreType: ObservableObject.
Maybe something around calling subscribe to create a publisher from an injected store?
Do others have suggestions? Or just need to get approvals for existing implementation.

2. Backwards-compatibility

We need to determine how we will include this functionality in a general ReSwift release. It is important that we maintain backwards compatibility for UIKit, and allow for bug fixes / feature release to continue for both paths.

3. Performance

Some general performance testing needs to be done to validate that this is an okay solution for a production environment.
Questions arise such as: How many/often state changes can occur before we overwhelm the SwiftUI diffing? Do we need to do some sort of diffing ourselves before?

4. Documentation & Examples

Documentation and examples need to be split and updated to account for UIKit vs SwiftUI paths.

@afitterling
Copy link

afitterling commented Oct 19, 2020

I am quite new to Swift but I am not new to Redux. I used it in other languages.
Seen from other frameworks and seen from functional reactive programming pattern which combines a iterator and a subscribable it should be fine IMHO. You can always have a look at this: https://redux.js.org/recipes/usage-with-typescript
I have not used the new SwiftUI states... my question is if any other thing like ReactiveSwift RxSwift can be wrapped around the SwiftUI. (https://github.com/ReactiveX/RxSwift) Having it decoupled means creater flexibility and longevity.
I owe you authors (and of the original source code) a lot. I just finished my app with ease.

@afitterling
Copy link

afitterling commented Oct 19, 2020

I was not sure you were using this already:
https://github.com/ReactiveX/RxSwift
https://github.com/RxSwiftCommunity/RxState

I just read over Apple's Documentation (https://developer.apple.com/documentation/swiftui/state). Your approach should be fine I guess with using their state handling and add-on the reducer stuff. As I understand it, Apples implementation of state is optional. So I as a developer would like still to setThings over functions... as setHidden(true/false)... or something.

Sorry, I wrote a bit more. I wanted to praise a bit of your work with a comment.
Correct me if I am wrong in some assumptions.

@dani-mp
Copy link
Contributor

dani-mp commented Oct 22, 2020

If we think about ReSwift as Redux, and as SwiftUI as React, it becomes clear that the code that glues both together should live in a different package, as we already did for ReSwift-Thunk. It would be the equivalent of https://react-redux.js.org/

I would only touch this main package to improve performance, fix bugs, improve the API, types, add more tests and maybe change its implementation using more modern Swift (in case it's necessary, of course).

In summary, I think the core package should contain the store, the reducers (I think a better way of composing reducers could be neat), and the middleware layer, and everything else on top must happen in opt-in, third party libs.

@DivineDominion
Copy link
Contributor

@dani-mp Your suggestion to make reducer composition nicer sounds interesting. You could create an issue with "help wanted" and "discussion needed" and propose something for others to pick up (including me eventually :))

@mjarvis
Copy link
Member Author

mjarvis commented Oct 22, 2020

@dani-mp Thats a good point. Creating a separate library, perhaps ReSwift-SwiftUI which contains this glue?

I'll look into how that would be possible, and if it would have the same kind of performance as the store refactor that I've done in my branch.

@dani-mp
Copy link
Contributor

dani-mp commented Oct 22, 2020

@DivineDominion done, sir: #457

@mjarvis I don't know much about SwiftUI because I mostly work with JavaScript and ClojureScript these days (although I still have a small tvOS project in production where I use ReSwift), but if for some reason, and to play nicely with SwiftUI, the API should expose any other kind of low-level primitives (thinking about Combine, for instance) instead of the current subscription, I think that'd be ok as long as people not using SwiftUI could use them as well from normal UIKit/AppKit code. AFAIK, react-redux uses a set of wrappers (HOCs and hooks) on top of the original Redux's listeners implementation, which I believe are similar to our subscription-based mechanism here.

@mjarvis
Copy link
Member Author

mjarvis commented Oct 22, 2020

Here is a minimal SwiftUI "glue" that I've managed to get working:

protocol ObservableStoreType: DispatchingStoreType, ObservableObject {

    associatedtype State: StateType

    /// The current state stored in the store.
    var state: State! { get }
}

class ObservableStore<State: StateType>: Store<State>, ObservableStoreType {

    override func _defaultDispatch(action: Action) {
        objectWillChange.send()
        super._defaultDispatch(action: action)
    }
}

Usage:

import SwiftUI
import ReSwift

protocol HasName {
    var name: String { get }
}

struct ChangeName: Action {
    let to: String
}

func nameReducer(action: Action, state: String?) -> String {
    switch action {
    case let action as ChangeName:
        return action.to
    default:
        return state ?? ""
    }
}

struct ContentView<S: ObservableStoreType>: View where S.State: HasName {
    @ObservedObject var store: S

    var body: some View {
        VStack {
            Text(store.state.name)
            TextField("Name", text: Binding(
                get: { self.store.state.name },
                set: { self.store.dispatch(ChangeName(to: $0)) }
            ))
        }
    }
}


struct ContentView_Previews : PreviewProvider {
    private struct MockState: StateType, HasName {
        var name: String = "mock"
    }

    private static func mockReducer(action: Action, state: MockState?) -> MockState {
        return MockState(
            name: nameReducer(action: action, state: state?.name)
        )
    }

    static var previews: some View {
        ContentView(store: ObservableStore(reducer: mockReducer, state: MockState()))
    }
}

This seems pretty nice. Simple minimal library, just need to replace Store(...) with ObservableStore(...) to get all the SwiftUI compatibility.

I'd like to figure out a way to use environment objects to pass the store around while maintaining the ability to keep views generic, but so far its feeling more awkward to go that route.

I've attached my small sample project. The relevant code is in SceneDelegate.swift and ContentView.swift.
ReSwiftUI.zip

@mjarvis
Copy link
Member Author

mjarvis commented Oct 22, 2020

Alternative, which allows for environment object and some nicer generic forms:
(omitting Subscriber and AnyObservableStore implementation details, but I have this working, see attached zip)

struct ContentView: View {
    var body: some View {
        Subscriber { (state: HasName, dispatch: @escaping DispatchFunction) in
            VStack {
                Text(state.name)
                TextField("Name", text: Binding(
                    get: { state.name },
                    set: { dispatch(ChangeName(to: $0)) }
                ))
            }
        }
    }
}

Preview replaced with:

    static var previews: some View {
        ContentView()
            .environmentObject(
                AnyObservableStore(store: ObservableStore(reducer: mockReducer, state: MockState()))
        )
    }

ReSwiftUI-StoreSubscriber.zip

The biggest downside with this approach is there is no compile-time safety on the state: HasName conformance at the Subscriber { ... call. One could put any type here and it will compile, but then crash at runtime. I'm not sure I can avoid this with the type-erasure required.

@pianostringquartet
Copy link

pianostringquartet commented Nov 8, 2020

Hi @mjarvis, thank you for your work!

I’m unfortunately unable to compile my project using your mjarvis/swiftui branch and the sample code from ReSwiftUI-StoreSubscriber.zip

I get the following build error:

Type 'ObservableStore<State>' does not conform to protocol 'ObservableStoreType'

Full code here: https://github.com/pianostringquartet/prototype/blob/add-reswift/prototype/ContentView.swift

Screen Shot 2020-11-08 at 10 52 27 AM

Note: I can run the ReSwiftUI-StoreSubscriber project locally and, working off the main ReSwift branch, I can also locally run the approach described here: #424 (comment))

EDIT: I'm using XCode 12, Swift 5, and SwiftUI (relevant code from your SceneDelegate.swift put instead in ContentView.swift).

@mjarvis
Copy link
Member Author

mjarvis commented Nov 9, 2020

@pianostringquartet ReSwiftUI-StoreSubscriber.zip should work using the latest release of ReSwift, rather than my SwiftUI branch.

@mjarvis
Copy link
Member Author

mjarvis commented Nov 9, 2020

For reference: My plan this week is to try to implement a shim that mixes the ideas above, while also resolving the issue in #461

My rules are:

  1. Views must be able to be generic over the store (only know about a subset of state)
  2. Views must be able to apply state selectors and modifiers before SwiftUI diffing is triggered
  3. It must be obvious and simple how to subscribe

Here are some possible interfaces that I'll explore (note: pseudocode, these do not compile)

1

struct MyView<S: PublishingStoreType>: View where S.State: HasTitle {
  let store: S

  var body: some View {
    Subscriber(store.statePublisher.map(\.title).removeDuplicates()) { title in
      Text(title)
    }
  }
}

2

struct MyView<S: PublishingStoreType>: View where S.State: HasTitle {
  let store: S

  var body: some View {
    store.subscribe {
      $0.map(\.title).removeDuplicates()
    } content: { title in
      Text(title)
    }
  }
}

3

struct MyView<S: PublishingStoreType>: View where S.State: HasTitle {
  let store: DispatchingStoreType
  @ObservableObject var state: String

  init(store: S) {
    self.store = store
    self._state = store.statePublisher.map(\.title).removeDuplicates()
  }

  var body: some View {
    Text(state)
  }
}

4

struct MyView<S, SubState>: SubscribingView<S, SubState> where S.State: HasTitle {
  
  let selector = { state in 
    state.map(\.title).removeDuplicates()
  }

  @ViewBuilder func body(state: SubState) -> some View {
    Text(state)
  }
}

Option 1 seems needlessly complicated with the subscriber view type needing to be explicitly known.
Option 2 looks the closest to existing ReSwift code, which seems like a very good thing.
Options 3 and 4 are nice in that they make the actual body code very clear, but add a lot of complexity elsewhere.

At this time option 2 seems like the best choice for me to experiment with.
I can also see passing dispatch into content: alongside state in order to avoid having to use self.store.dispatch(...)

All options may also allow for removal of explicit .removeDuplicates() by re-introducing automaticallySkipsRepeats functionality on Equatable substate selections.

All options I can see having an extremely nice way of isolating the view itself from subscription code, by having a raw, private view which takes the state directly: (Example here using option 2 above)

struct MyView<S: PublishingStoreType>: View where S.State: HasTitle {
  let store: S

  var body: some View {
    store.subscribe {
      $0.map(\.title).removeDuplicates()
    } content: { title in
      _MyView(title: title)
    }
  }
}

private struct _MyView: View {
  let title: String

  var body: some View {
    Text(title)
  }
}

This isolation has numerous benefits including testability, mocking, view/state replacement, etc. (And just raw clean readability)

@mjarvis
Copy link
Member Author

mjarvis commented Nov 9, 2020

Latest rendition: ReSwiftUI-PublishingStore.zip

Example View:

struct ContentView<S: PublishingStoreType>: View where S.State: HasName {
    let store: S

    var body: some View {
        store.subscribe(\.name) { name in
            VStack {
                Text(name)
                TextField("Name", text: Binding(
                    get: { name },
                    set: { self.store.dispatch(ChangeName(to: $0)) }
                ))
                Button(action: { self.store.dispatch(ChangeName(to: "Test")) }) {
                    Text("Update")
                }
            }
        }
    }
}

This includes functionality that supports automaticallySkipsRepeats on Equatable substate. (Update button included to tap to see in console that the onReceive is skipped)

Also available is an alternate func subscribe which takes a second transform: parameter, allowing one to manually transform the substate publisher. This alternative bypasses automaticallySkipsRepeats, allowing one to manually remove duplicates / filter updates, etc for more control over when updates come through.

@mjarvis
Copy link
Member Author

mjarvis commented Nov 12, 2020

Working on setting up https://github.com/ReSwift/ReSwift-SwiftUI which will start with the bindings from my last comment.
Once this is up and running all discussion + issues will move there.

We'll update this repo's README to include a link there once available.

mjarvis pushed a commit to ReSwift/ReSwift-SwiftUI that referenced this issue Nov 12, 2020
Includes potential base implementation from ReSwift/ReSwift#455 (comment)
@mjarvis
Copy link
Member Author

mjarvis commented Nov 19, 2020

After some riffing, heres another very minimal option:

class Subscriber<Value>: ObservableObject, StoreSubscriber {
    @Published private(set) var value: Value!

    init<S: StoreType>(_ store: S, transform: @escaping (ReSwift.Subscription<S.State>) -> ReSwift.Subscription<Value>) {
        store.subscribe(self, transform: transform)
    }

    init<S: StoreType>(_ store: S, transform: @escaping (ReSwift.Subscription<S.State>) -> ReSwift.Subscription<Value>) where Value: Equatable {
        store.subscribe(self, transform: transform)
    }

    func newState(state: Value) {
        value = state
    }
}

struct ContentView<S: StoreType>: View where S.State: HasName {
    private let store: S

    @ObservedObject private var name: Subscriber<String>

    init(store: S) {
        self.store = store
        name = Subscriber(store) { $0.select(\.name) }
    }

    var body: some View {
        VStack {
            Text(name.value)
            TextField("Name", text: Binding(
                get: { name.value },
                set: { store.dispatch(ChangeName(to: $0)) }
            ))
            Button(action: { store.dispatch(ChangeName(to: "Test")) }) {
                Text("Update")
            }
        }
    }
}

This seems more like idiomatic SwiftUI to me, more similar to the @FetchRequest type system for core data (A friend and I both tried to replicate something closer to that to integrate the subscribe into the property wrapper, but unfortunately apple does not publicly expose the capabilities for a property wrapper to indicate for the view to re-render)

This is also much cleaner glue -- Not even an extension on Store, just a simple observable object that deals with the subscription. The initializers are even not necessary -- I've included them only to force the user to subscribe immediately, rather than allow for a runtime error if one neglected to subscribe via the store.

The ideal situation here if the capabilities were added would be able to do something like @StoredState(\.name) var name: String and have the property wrapper subscribe + notify the view, but as mentioned above, we can't notify the view, and passing the store into this option would be quite awkward.

One downside to this option is it moves the subscriptions up to the root of the view, which means if we have multiple subscriptions, any one changing causes the entire view's tree to be diff'd by SwiftUI, whereas the other, inline subscribe functionality would reduce the diffing down to only the content within the subscription, but, as mentioned, this is how they intend for things to work with core data, etc. so maybe its an okay downside?

@marcoboerner
Copy link

@mjarvis I've deleted my previous comment as I had an error in my code. Your code works pretty well, however, somehow even though my States are Equatable it does not skip repeats in my code and keeps redrawing my views, especially causing an issue with a SwiftUI SpriteView Not sure what I do wrong.

I had to add a check in the newState method in order to make it work. I'm subscribing to individual values of substates like \.substateA.frame or complete states \.substateB at times.

That's how I use your above modified code in my app:

class Subscriber<Value: Equatable>: ObservableObject, StoreSubscriber {

	@Published private(set) var value: Value!

	init<S: StoreType>(_ store: S, transform: @escaping (ReSwift.Subscription<S.State>) -> ReSwift.Subscription<Value>) {
		store.subscribe(self, transform: transform)
	}

	func newState(state: Value) {
		if value != state {
			value = state
		}
	}
}

@mjarvis
Copy link
Member Author

mjarvis commented Mar 8, 2021

@marcoboerner Interesting. In my testing the secondary initializer I provided handled that case by passing equatable state onto the automatic skip system built into ReSwift. It could be some swift version has changed the handling of Generics such that that is not working as intended. I'll do some more testing when I have some time, but your solution looks fine as well for your use case.

@marcoboerner
Copy link

@mjarvis I've tried using a custom property wrapper solution in my app. But only works when passing the store as you mentioned, and if the store is passed into the view struct and not globally accessed it doesn't simplify the code much as it still needs to be initialized in the init method. Anyways, here is what I tried so far:

@propertyWrapper
struct StoreStateObject<S: StoreType, Value: Equatable>: DynamicProperty {

	let store: S
	var keyPath: KeyPath<S.State, Value>

	@StateObject private var value: Subscriber<S, Value>

	var wrappedValue: Value {
		get {
			value.value
		}
		nonmutating set {
			value.value = newValue
		}
	}

	init(_ store: S, _ keyPath: KeyPath<S.State, Value>) {
		self.store = store
		self.keyPath = keyPath
		self._value = StateObject(wrappedValue: Subscriber(store, keyPath) )
	}
}

class Subscriber<S: StoreType, Value: Equatable>: ObservableObject, StoreSubscriber {
	var value: Value {
		willSet {
			objectWillChange.send()
		}
	}

	init(_ store: S, _ keyPath: KeyPath<S.State, Value>) {
		self.value = store.state[keyPath: keyPath]
		store.subscribe(self, transform: {$0.select(keyPath)})
	}

	func newState(state: Value) {
		if value != state {
			DispatchQueue.main.async {
				self.value = state
			}
		}
	}
}

I'm using it like this inside the view structs:

@StoreStateObject(store, \.viewState.frame) private var frame
@StoreStateObject(store, \.questionsState) private var questionsState

@DivineDominion
Copy link
Contributor

@mjarvis I might have time to experiment with SwiftUI in the summer on a small-scale project, and would of course consider ReSwift underpinnings :) Do you need anything in particular (Re: the 'help wanted' tag)? What is the current situation of backwards-compatibility, cf. the initial comment's

We need to determine how we will include this functionality in a general ReSwift release. It is important that we maintain backwards compatibility for UIKit, and allow for bug fixes / feature release to continue for both paths.

@mjarvis
Copy link
Member Author

mjarvis commented Jun 2, 2021

@DivineDominion current solutions are completely stand-alone, and do not modify ReSwift source code at all.

Basically what needs to happen to complete this would be to test + determine which solution we want to publicize / release in some fashion, and how we want to release it. (Include in ReSwift? Separate repo? Only document solutions?)

@DivineDominion
Copy link
Contributor

I'm dabbling with SwiftUI and (maybe due to lack of experience) tend towards making the store available as an @EnvironmentObject to avoid having to pass it around in view initialization.

Especially to have a means to dispatch actions in e.g. toolbar buttons that don't need to read any state. But for that, I found a little action dispatcher to be helpful.

class Dispatcher: ObservableObject {
    private let store: AppStore

    init(store: AppStore) {
        self.store = store
    }

    func dispatch(_ action: ReSwift.Action) {
        store.dispatch(action)
    }
}

@main
struct MyApp: App {
    private let store = AppStore()
    private let dispatcher: Dispatcher

   init() {
        self.dispatcher = Dispatcher(store: store)
    }

    var body: some Scene {
        WindowGroup {
            NavigationView { ... }
            .environmentObject(dispatcher)
        }
    }
}

Places where I was just passing through the store to inner views now don't have to know about the store at all.

image

@mjarvis
Copy link
Member Author

mjarvis commented Jul 6, 2021

@DivineDominion How are you handling modularization / isolation with environment? The problem I had with doing that was that pulling an object from the environment requires knowing its entire type eg Store<AppState>, whereas I want to be able to isolate views to some sub-state, eg Store<S: HasSomeData>.

@DivineDominion
Copy link
Contributor

@mjarvis I initially started to go without beloved ReSwift from scratch and add state management piecemeal. I started with "Connectors" that map AppState to a per-view sub-state, and also map per-view actions to AppStore-compatible actions. Basically just two-way adapters to narrow down the focus. See: https://swiftwithmajid.com/2021/02/03/redux-like-state-container-in-swiftui-part4/

That looked very nice at first, but this breaks down with nested hierarchies. I attached a short comment from my own notes below with an example below the divider. E.g. an App that has a SidebarView (master pane) which has a ToolbarButton, you have 2 hops, and one "connector" per hop. The first hop looked great, but when I added the second hop to the inner view, the toolbar button, I noticed that I have to carry the toolbar-local state around in the sidebar just to make the connector. And what's worse, the toolbar button doesn't need state (so it's struct Empty: Equatable {}) but produces actions, so I had to have the button action bubble up to the sidebar and then bubble up to the app's store.

That sucked.

I'm not convinced that per-view action types are what I absolutely need, so an app-global action dispatcher is fine with me. That's what I proposed in my previous comment. It's state agnostic and just dispatches any ReSwift.Action to the store that was initially assigned, i.e. here the main AppStore.

Sub-state selection is another beast that this connector approach encapsulated really well. For now, since the transition to ReSwift and your code is not even a day old, I am rolling with your MyView<S: StateType>: View approach and inject the state and subscribe in there. But I do believe that I'd prefer to prepare the subscription object in advance, not inside the view's init.

I tried this out in this commit: ChristianTietze/NowNowNowApp@b168988 -- you can have a look, and I'm open to suggestions :)


Connectors increase boilerplate as view hierarchy grows

Connectors to bridge sub-states and per-component-actions to the main app state become a bit weird when you have nested view hierarchies.

Then you need to either:

  • pass through the sub-component's state through intermediary views, and bubble-up sub-component's actions, or
  • declare Store<AppState, AppAction> as an @EnvironmentObject and access it from deep in the view hierarchy.

It's simple when you start at the App and then hand a mapped Connector down one level:

struct MyApp: App {
    @StateObject var store = AppStore()
    var body: some Scene {
        WindowGroup {
            MyView(viewModel: store.connect(using: MyViewConnector())
        }
    }
}

But once you're inside that level with its narrowed-down ViewModel, you cannot create a connector that bridges directly from AppState -> SubSubState anymore.

You have to make do with a mapping of SubState -> SubSubState because this component's store (Store<SubState,_>) doesn't know the AppState at all:

struct MyView: View {
    struct SubState { ... }
    enum SubAction { ... }
    typealias ViewModel: Store<SubState, SubAction>
    @ObservedObject var viewModel: ViewModel

    var body: some Scene {
        MySubView(viewModel: ???)
    }
}

This, in turn, means that you:

  1. need to know both the sub-state and the sub-sub-state in the component so it can hand it down;
  2. need to bridge actions from the sub-sub-component upwards, too.
struct MyInnerView: View {
    struct SubSubState { let subSubValue: Int }
    enum SubSubAction { case subsub }
    @ObservedObject var viewModel: Store<SubSubState, SubSubActions>
    // ...
}

struct MyView: View {
    struct SubState { 
        let subValue: Double        // for this component
        let subSubValueBridge: Int  // for the inner component
    }
    enum SubAction {
        case subAction           // for this component
        case subSubActionBridge  // for the inner component
    }
    
    @ObservedObject var viewModel: Store<SubState, SubAction>

    var body: some Scene {
        MySubView(viewModel: viewModel.connect(MyInnerViewConnector()))
    }
}

/// Effectively bridges between MyView.ViewModel and MyInnerview.ViewModel
struct MyInnerViewConnector: Connector {
    func connect(state: MyView.SubState) -> MyInnerView.SubSubState {
        return .init(subSubValue: state.subSubValueBridge)
    }

    func connect(action: MyInnerView.SubSubAction) -> MyView.SubAction {
        switch action {
        case .subSubAction: return .subSubActionBridge
        }
    }
}

@DivineDominion
Copy link
Contributor

Originally posted by @sjmueller in #487 (comment)


Hi guys, here's the code -- it's fairly plug and play if you're using the latest ReSwift

One other useful part to mention:

  • support for animations when subscribed portion of state changes, the animation type can be passed in
  • it's compatible with onReceive too, so you're covered in more complex scenarios where adjacent state within the component needs to react:
ZStack {
}
.onReceive(entries.objectDidChange) { changeset in
    if let last = changeset.new {
        // do something with local @State
    }
}

We've used this code in production for awhile -- try it out below and lmk, happy to answer any questions!

// Sam Mueller, Blink Labs

import ReSwift
import SwiftUI
import ReSwiftRouter
import Combine

class ObservableState<T: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription {
    
    @Published fileprivate(set) var current: T
    let selector: (AppState) -> T
    fileprivate let animation: SwiftUI.Animation?
    fileprivate var isSubscribed: Bool = false
    fileprivate var cancellables = Set<AnyCancellable>()
    
    // MARK: Lifecycle
    
    public init(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil) {
        self.current = selector(store.state)
        self.selector = selector
        self.animation = animation
        self.subscribe()
    }
    
    func subscribe() {
        guard !isSubscribed else { return }
        store.subscribe(self, transform: { [self] in $0.select(selector) })
        isSubscribed = true
    }
    
    func unsubscribe() {
        guard isSubscribed else { return }
        store.unsubscribe(self)
        isSubscribed = false
    }
    
    deinit {
        unsubscribe()
    }
    
    public func newState(state: T) {
        guard self.current != state else { return }
        DispatchQueue.main.async {
            let old = self.current
            if let animation = self.animation {
                withAnimation(animation) {
                    self.current = state
                }
            } else {
                self.current = state
            }
            self.objectDidChange.send(DidChangeSubject(old: old, new: self.current))
        }
    }
    
    public let objectDidChange = PassthroughSubject<DidChangeSubject<T>,Never>()
    
    struct DidChangeSubject<T> {
        let old: T
        let new: T
    }
}

class ObservableThrottledState<T: Hashable>: ObservableState<T> {
    
    // MARK: Lifecycle
    
    public init(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil, throttleInMs: Int) {
        super.init(select: selector, animation: animation)
        
        objectThrottled
            .throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
            .sink { [weak self] in self?.current = $0 }
            .store(in: &cancellables)
    }
    
    override public func newState(state: T) {
        guard self.current != state else { return }
        DispatchQueue.main.async {
            let old = self.current
            if let animation = self.animation {
                withAnimation(animation) {
                    self.objectThrottled.send(state)
                }
            } else {
                self.objectThrottled.send(state)
            }
            self.objectDidChange.send(DidChangeSubject(old: old, new: self.current))
        }
    }
    
    private let objectThrottled = PassthroughSubject<T, Never>()
}


class ObservableDerivedState<Original: Hashable, Derived: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription {
    @Published public var current: Derived
    
    let selector: (AppState) -> Original
    let transform: (Original) -> Derived
    fileprivate let animation: SwiftUI.Animation?
    fileprivate var isSubscribed: Bool = false
    fileprivate var cancellables = Set<AnyCancellable>()
    
    // MARK: Lifecycle
    
    public init(select selector: @escaping (AppState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil) {
        self.current = transform(selector(store.state))
        self.selector = selector
        self.transform = transform
        self.animation = animation
        self.subscribe()
    }
    
    func subscribe() {
        guard !isSubscribed else { return }
        store.subscribe(self, transform: { [self] in $0.select(selector) })
        isSubscribed = true
    }
    
    func unsubscribe() {
        guard isSubscribed else { return }
        store.unsubscribe(self)
        isSubscribed = false
    }
    
    deinit {
        unsubscribe()
    }
    
    public func newState(state original: Original) {
        DispatchQueue.main.async {
            let old = self.current
            self.objectWillChange.send(ChangeSubject(old: old, new: self.current))
            
            if let animation = self.animation {
                withAnimation(animation) {
                    self.current = self.transform(original)
                }
            } else {
                self.current = self.transform(original)
            }
            self.objectDidChange.send(ChangeSubject(old: old, new: self.current))
        }
    }
    
    public let objectWillChange = PassthroughSubject<ChangeSubject<Derived>,Never>()
    public let objectDidChange = PassthroughSubject<ChangeSubject<Derived>,Never>()
    
    struct ChangeSubject<Derived> {
        let old: Derived
        let new: Derived
    }
}


class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashable>: ObservableDerivedState<Original, Derived> {
    
    // MARK: Lifecycle
    
    public init(select selector: @escaping (AppState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil, throttleInMs: Int) {
        super.init(select: selector, transform: transform, animation: animation)
        
        objectThrottled
            .throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
            .sink { [weak self] in
                self?.current = transform($0)
            }
            .store(in: &cancellables)
    }
    
    override public func newState(state original: Original) {
        let old = current
        if let animation = animation {
            withAnimation(animation) {
                objectThrottled.send(original)
            }
        } else {
            objectThrottled.send(original)
        }
        
        DispatchQueue.main.async { self.objectDidChange.send(ChangeSubject(old: old, new: self.current)) }
    }
    
    private let objectThrottled = PassthroughSubject<Original, Never>()
}

extension Store where State == AppState {
    
    func subscribe<T>(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil) -> ObservableState<T> {
        ObservableState(select: selector, animation: animation)
    }
    
    func subscribe<Original, Derived>(select selector: @escaping (AppState) -> (Original), transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil) -> ObservableDerivedState<Original, Derived> {
        ObservableDerivedState(select: selector, transform: transform, animation: animation)
    }
    
    func subscribeThrottled<T>(select selector: @escaping (AppState) -> (T), throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil) -> ObservableThrottledState<T> {
        ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs)
    }
    
    func subscribeThrottled<Original, Derived>(select selector: @escaping (AppState) -> (Original), transform: @escaping (Original) -> Derived, throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil) -> ObservableDerivedThrottledState<Original, Derived> {
        ObservableDerivedThrottledState(select: selector, transform: transform, animation: animation, throttleInMs: throttleInMs)
    }
}

protocol ObservableSubscription {
    func unsubscribe()
}

protocol Initializable {
    init()
}

@DivineDominion
Copy link
Contributor

It seems the ObservableState<S> route that uses a subscription is superior to exposing the Store itself as an ObservableObject:

Q: I have a class Store<State, Action>: ObservableObject that holds the whole app state. It lives as @StateObject in the root of the App lifecycle and passed via environment to all views. View send actions to the store and update as soon as store’s state updated. It allows me to keep the app consistent. Could you please mention any downsides of this approach from your prospective?

A: That approach makes every view in your app dependent on a single Observable object. Any change to a Published property forces every view that references the environment object to be updated.

via https://onmyway133.com/posts/wwdc-swiftui-lounge/#downside-of-single-app-state

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants