diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e14c6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/ReduxExample.xcworkspace/xcuserdata/alessandromartin.xcuserdatad/UserInterfaceState.xcuserstate +/ReduxExample.xcodeproj/xcuserdata/alessandromartin.xcuserdatad/xcschemes/xcschememanagement.plist +/Pods/Pods.xcodeproj/xcuserdata/alessandromartin.xcuserdatad/xcschemes/xcschememanagement.plist +/ReduxExample.xcworkspace/xcuserdata/alessandromartin.xcuserdatad diff --git a/ReduxExample.xcodeproj/project.pbxproj b/ReduxExample.xcodeproj/project.pbxproj index cfa8808..f596b45 100644 --- a/ReduxExample.xcodeproj/project.pbxproj +++ b/ReduxExample.xcodeproj/project.pbxproj @@ -16,6 +16,9 @@ 1CF6803220692DA30022C9E7 /* ReduxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF6803120692DA30022C9E7 /* ReduxTests.swift */; }; 1CF6803920692DC30022C9E7 /* Redux.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF6802720687A000022C9E7 /* Redux.swift */; }; 895901BBDECC2146CC7DEE8E /* Pods_ReduxTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 36947F3EFFA34BEFF75C6EDA /* Pods_ReduxTests.framework */; }; + BF6A98DA2069B5D700B4D05E /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6A98D92069B5D700B4D05E /* AppState.swift */; }; + BF6A98DC2069B5FB00B4D05E /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6A98DB2069B5FB00B4D05E /* User.swift */; }; + BF6A98DE2069B65A00B4D05E /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6A98DD2069B65A00B4D05E /* AppConfiguration.swift */; }; FDDE9FCDBEA4910EB4969E60 /* Pods_ReduxExample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0F7A592F2BBBDB070D9E1D6 /* Pods_ReduxExample.framework */; }; /* End PBXBuildFile section */ @@ -44,6 +47,9 @@ 36947F3EFFA34BEFF75C6EDA /* Pods_ReduxTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReduxTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 63E6A6F89869742E0F68463B /* Pods-ReduxExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReduxExample.release.xcconfig"; path = "Pods/Target Support Files/Pods-ReduxExample/Pods-ReduxExample.release.xcconfig"; sourceTree = ""; }; 8C3A44712CC3EA2BA6F64619 /* Pods-ReduxTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReduxTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-ReduxTests/Pods-ReduxTests.release.xcconfig"; sourceTree = ""; }; + BF6A98D92069B5D700B4D05E /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + BF6A98DB2069B5FB00B4D05E /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + BF6A98DD2069B65A00B4D05E /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; D12C02EA05B7841D17CB7E14 /* Pods-ReduxTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReduxTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ReduxTests/Pods-ReduxTests.debug.xcconfig"; sourceTree = ""; }; DC904AC289E2A89F33F26855 /* Pods-ReduxExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReduxExample.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ReduxExample/Pods-ReduxExample.debug.xcconfig"; sourceTree = ""; }; F0F7A592F2BBBDB070D9E1D6 /* Pods_ReduxExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReduxExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -99,6 +105,9 @@ 1CF6801C2066AF840022C9E7 /* LaunchScreen.storyboard */, 1CF6801F2066AF840022C9E7 /* Info.plist */, 1CF6802720687A000022C9E7 /* Redux.swift */, + BF6A98DD2069B65A00B4D05E /* AppConfiguration.swift */, + BF6A98D92069B5D700B4D05E /* AppState.swift */, + BF6A98DB2069B5FB00B4D05E /* User.swift */, ); path = ReduxExample; sourceTree = ""; @@ -354,9 +363,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BF6A98DE2069B65A00B4D05E /* AppConfiguration.swift in Sources */, 1CF680162066AF840022C9E7 /* ViewController.swift in Sources */, + BF6A98DC2069B5FB00B4D05E /* User.swift in Sources */, 1CF680142066AF840022C9E7 /* AppDelegate.swift in Sources */, 1CF6802820687A000022C9E7 /* Redux.swift in Sources */, + BF6A98DA2069B5D700B4D05E /* AppState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ReduxExample/AppConfiguration.swift b/ReduxExample/AppConfiguration.swift new file mode 100644 index 0000000..a05f075 --- /dev/null +++ b/ReduxExample/AppConfiguration.swift @@ -0,0 +1,74 @@ +// +// AppConfiguration.swift +// ReduxExample +// +// Created by Alessandro Martin on 03/27/2018. +// Copyright © 2018 Jonathan Cravotta. All rights reserved. +// + +import Foundation + +// Note: equatable conformance for types like this should be automatic in Swift 4.1 +struct AppConfiguration: Equatable { + var arePushNotificationsEnabled: Bool + var isDarkThemed: Bool + + static let `default` = AppConfiguration(arePushNotificationsEnabled: false, isDarkThemed: false) + + static func ==(lhs: AppConfiguration, rhs: AppConfiguration) -> Bool { + return lhs.arePushNotificationsEnabled == rhs.arePushNotificationsEnabled + && lhs.isDarkThemed == rhs.isDarkThemed + } +} + +//MARK: - Reducer +func appConfigurationReducer(_ action: Action, _ state: AppConfiguration) -> AppConfiguration { + var newState = state + + switch action { + case let action as TogglePushNotifications: + newState.arePushNotificationsEnabled = action.enable + case let action as ToggleDarkTheme: + newState.isDarkThemed = action.enable + case is LoadAppConfiguration: + newState = loadAppConfiguration() + case is SaveAppConfiguration: + saveAppConfiguration(newState) + default: + break + } + + return newState +} + +//MARK: - Actions +struct TogglePushNotifications: Action { + let enable: Bool +} + +struct ToggleDarkTheme: Action { + let enable: Bool +} + +struct LoadAppConfiguration: Action {} +private func loadAppConfiguration(from defaults: UserDefaults = .standard) -> AppConfiguration { + let pushNotificationsEnabled = defaults.bool(forKey: .pushNotificationsKey) + let darkThemeEnabled = defaults.bool(forKey: .darkThemeKey) + print("Configuration loaded!") + + return AppConfiguration(arePushNotificationsEnabled: pushNotificationsEnabled, + isDarkThemed: darkThemeEnabled) +} + +struct SaveAppConfiguration: Action {} +private func saveAppConfiguration(_ appConfiguration: AppConfiguration, to defaults: UserDefaults = .standard) { + defaults.set(appConfiguration.arePushNotificationsEnabled, forKey: .pushNotificationsKey) + defaults.set(appConfiguration.isDarkThemed, forKey: .darkThemeKey) + defaults.synchronize() + print("Configuration saved!") +} + +private extension String { + static let pushNotificationsKey = "PushNotificationsEnabled" + static let darkThemeKey = "DarkThemeEnabled" +} diff --git a/ReduxExample/AppDelegate.swift b/ReduxExample/AppDelegate.swift index a774566..8ee4d8d 100644 --- a/ReduxExample/AppDelegate.swift +++ b/ReduxExample/AppDelegate.swift @@ -8,6 +8,13 @@ import UIKit +// It's a var so it can be replaced and the app state fully reset +var store = Store(reducer: appReducer, state: .initial) + +func state() -> AppState { + return store.state.value +} + @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -15,7 +22,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + + store.dispatch(LoadAppConfiguration()) + return true } @@ -27,6 +36,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationDidEnterBackground(_ application: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + store.dispatch(SaveAppConfiguration()) } func applicationWillEnterForeground(_ application: UIApplication) { @@ -40,7 +50,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - - } diff --git a/ReduxExample/AppState.swift b/ReduxExample/AppState.swift new file mode 100644 index 0000000..1c0090f --- /dev/null +++ b/ReduxExample/AppState.swift @@ -0,0 +1,20 @@ +// +// AppState.swift +// ReduxExample +// +// Created by Alessandro Martin on 03/27/2018. +// Copyright © 2018 Jonathan Cravotta. All rights reserved. +// + +struct AppState: StateType { + let user: User + let appConfiguration: AppConfiguration + + static let initial = AppState(user: .anonymous, appConfiguration: .default) +} + +//MARK: - Reducer +func appReducer(_ action: Action, _ state: AppState) -> AppState { + return AppState(user: userReducer(action, state.user), + appConfiguration: appConfigurationReducer(action, state.appConfiguration)) +} diff --git a/ReduxExample/Redux.swift b/ReduxExample/Redux.swift index 23f4acf..83a2994 100644 --- a/ReduxExample/Redux.swift +++ b/ReduxExample/Redux.swift @@ -1,82 +1,53 @@ // -// GenericRedux.swift +// Redux.swift // ReduxExample // // Created by Jonathan Cravotta on 3/25/18. // Copyright © 2018 Jonathan Cravotta. All rights reserved. // -import Foundation import ReactiveSwift -protocol Store { - associatedtype State - associatedtype Action - - var state: MutableProperty { get } - func reducer(_ action: Action, _ state: State) -> State -} +protocol StateType {} +protocol Action {} -extension Store { - func dispatch(action: Action) { - state.value = reducer(action, state.value) - } -} +typealias Reducer = (Action, ReducerStateType) -> ReducerStateType -// Example: -struct User { - var name: String - var zipcode: Int - var sizes: [Int] -} +final class Store { -enum UserAction { - case updateName(String) - case updateZip(Int) - case updateSizes([Int]) - case updateZipAndSizes(zip: Int, sizes: [Int]) -} - -class UserStore: Store { - - typealias Action = UserAction - typealias State = User - - var state: MutableProperty + let reducer: Reducer + let state: MutableProperty - init(user: User) { - self.state = MutableProperty(user) - } - - func reducer(_ action: Action, _ state: User) -> User { - var newState = state - - switch action { - case .updateName(let name): newState.name = name - case .updateZip(let zip): newState.zipcode = zip - case .updateSizes(let sizes): newState.sizes = sizes - - case let .updateZipAndSizes(zip, sizes): - newState.zipcode = zip - newState.sizes = sizes - } - - return newState + init(reducer: @escaping Reducer, state: State) { + self.reducer = reducer + self.state = MutableProperty(state) } -} + func dispatch(_ action: Action) { + state.value = reducer(action, state.value) + } -//:D -extension MutableProperty { @discardableResult - public func observeProducer(_ value: @escaping (MutableProperty.Value) -> Void) -> Disposable { - return producer.startWithValues(value) + public func observeSignal(keyPath: KeyPath, action: @escaping (Part) -> Void) -> Disposable? { + return state.signal.combinePrevious().observeValues { previous, current in + let previousPart = previous[keyPath: keyPath] + let currentPart = current[keyPath: keyPath] + + if previousPart != currentPart { + action(currentPart) + } + } } - + @discardableResult - public func observeSignal(_ value: @escaping (MutableProperty.Value) -> Void) -> Disposable? { - return signal.observeValues(value) + public func observeProducer(keyPath: KeyPath, action: @escaping (Part) -> Void) -> Disposable { + return state.producer.combinePrevious().startWithValues { previous, current in + let previousPart = previous[keyPath: keyPath] + let currentPart = current[keyPath: keyPath] + + if previousPart != currentPart { + action(currentPart) + } + } } } - - diff --git a/ReduxExample/User.swift b/ReduxExample/User.swift new file mode 100644 index 0000000..25cd89e --- /dev/null +++ b/ReduxExample/User.swift @@ -0,0 +1,63 @@ +// +// User.swift +// ReduxExample +// +// Created by Alessandro Martin on 03/27/2018. +// Copyright © 2018 Jonathan Cravotta. All rights reserved. +// + +struct User { + var name: String + var zipcode: Int + var sizes: [Int] + + static let anonymous = User(name: "Anonymous", zipcode: 90210, sizes: []) +} + +// Note: equatable conformance for types like this should be automatic in Swift 4.1 +extension User: Equatable { + static func ==(lhs: User, rhs: User) -> Bool { + return lhs.name == rhs.name + && lhs.zipcode == rhs.zipcode + && lhs.sizes == rhs.sizes + } +} + +//MARK: - Reducer +func userReducer(_ action: Action, _ state: User) -> User { + var newState = state + + switch action { + case let action as UserName: + newState.name = action.name + case let action as UserZipCode: + newState.zipcode = action.zipcode + case let action as UserSizes: + newState.sizes = action.sizes + case let action as UserZipCodeAndSizes: + newState.zipcode = action.zipcode + newState.sizes = action.sizes + default: + break + } + + return newState +} + +//MARK: - Actions +struct UserName: Action { + let name: String +} + +struct UserZipCode: Action { + let zipcode: Int +} + +struct UserSizes: Action { + let sizes: [Int] +} + +struct UserZipCodeAndSizes: Action{ + let zipcode: Int + let sizes: [Int] +} diff --git a/ReduxExample/ViewController.swift b/ReduxExample/ViewController.swift index f9a93b7..d8d5bd8 100644 --- a/ReduxExample/ViewController.swift +++ b/ReduxExample/ViewController.swift @@ -8,36 +8,39 @@ import UIKit -class ViewController: UIViewController { +final class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. + let t = UserExample() t.test() } } -class UserExample { - - let userStore: UserStore +final class UserExample { init() { - let user = User(name: "Foo", zipcode: 10021, sizes: [4, 5]) - self.userStore = UserStore(user: user) observe() } func test() { - userStore.dispatch(action: .updateName("Baz")) - userStore.dispatch(action: .updateSizes([0, 2])) - userStore.dispatch(action: .updateZip(10014)) - userStore.dispatch(action: .updateZipAndSizes(zip: 10013, sizes: [4,5,6,8])) + store.dispatch(UserName(name: "Baz")) + store.dispatch(UserSizes(sizes: [0, 2])) + store.dispatch(UserZipCode(zipcode: 10014)) + store.dispatch(UserZipCodeAndSizes(zipcode: 10013, sizes: [4,5,6,8])) + let isDarkThemed = state().appConfiguration.isDarkThemed + print("Before toggling dark theme: \(isDarkThemed)") + store.dispatch(ToggleDarkTheme(enable: !isDarkThemed)) } func observe() { - userStore.state.observeProducer { (user) in + store.observeProducer(keyPath: \.user) { user in print("Name: \(user.name), Zip: \(user.zipcode), Sizes: \(user.sizes.map { $0 })") } + + store.observeProducer(keyPath: \.appConfiguration) { appConfiguration in + print("Push Notifications enabled: \(appConfiguration.arePushNotificationsEnabled), Dark Theme: \(appConfiguration.isDarkThemed)") + } } }