-
Notifications
You must be signed in to change notification settings - Fork 517
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
Comments
I am quite new to Swift but I am not new to Redux. I used it in other languages. |
I was not sure you were using this already: 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. |
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. |
@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 :)) |
@dani-mp Thats a good point. Creating a separate library, perhaps 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. |
@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, |
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 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 |
Alternative, which allows for environment object and some nicer generic forms: 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()))
)
} The biggest downside with this approach is there is no compile-time safety on the |
Hi @mjarvis, thank you for your work! I’m unfortunately unable to compile my project using your I get the following build error:
Full code here: https://github.com/pianostringquartet/prototype/blob/add-reswift/prototype/ContentView.swift Note: I can run the EDIT: I'm using XCode 12, Swift 5, and SwiftUI (relevant code from your |
@pianostringquartet |
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:
Here are some possible interfaces that I'll explore (note: pseudocode, these do not compile) 1struct 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)
}
}
} 2struct 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)
}
}
} 3struct 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)
}
} 4struct 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. At this time option 2 seems like the best choice for me to experiment with. All options may also allow for removal of explicit 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) |
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 Also available is an alternate |
Working on setting up https://github.com/ReSwift/ReSwift-SwiftUI which will start with the bindings from my last comment. We'll update this repo's README to include a link there once available. |
Includes potential base implementation from ReSwift/ReSwift#455 (comment)
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 This is also much cleaner glue -- Not even an extension on The ideal situation here if the capabilities were added would be able to do something like 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? |
@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 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 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
}
}
} |
@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. |
@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 |
@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
|
@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?) |
I'm dabbling with SwiftUI and (maybe due to lack of experience) tend towards making the 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. |
@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 |
@mjarvis I initially started to go without beloved ReSwift from scratch and add state management piecemeal. I started with "Connectors" that map 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 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 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 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
Then you need to either:
It's simple when you start at the 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 You have to make do with a mapping of 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:
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
}
}
} |
Originally posted by @sjmueller in #487 (comment)
|
It seems the
via https://onmyway133.com/posts/wwdc-swiftui-lounge/#downside-of-single-app-state |
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
toObservableObject
. This allows for one to use@ObservedObject
or@EnvironmentObject
property wrappers to access a store, allowing direct usage ofstore.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.
The text was updated successfully, but these errors were encountered: