├── .github └── workflows │ └── swift.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── SwiftReactor │ ├── BaseReactor.swift │ ├── Bindings.swift │ ├── EnvironmentReactor.swift │ ├── Mutations.swift │ ├── Reactor.swift │ └── ReactorView.swift └── SwiftReactorUIKit │ ├── BaseReactorView.swift │ └── ReactorUIView.swift ├── SwiftReactorExample ├── SwiftReactor.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── SwiftReactorExample.xcscheme └── SwiftReactorExample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── ContentView.swift │ ├── ExampleReactor.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── SceneDelegate.swift └── Tests ├── LinuxMain.swift ├── SwiftReactorTests └── SwiftReactorTests.swift ├── SwiftReactorUIKitTests └── SwiftReactorUIKitTests.swift └── XCTestManifests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/swift,xcode,macos,swiftpm,swiftpackagemanager 3 | # Edit at https://www.gitignore.io/?templates=swift,xcode,macos,swiftpm,swiftpackagemanager 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Swift ### 34 | # Xcode 35 | # 36 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 37 | 38 | ## Build generated 39 | build/ 40 | DerivedData/ 41 | 42 | ## Various settings 43 | *.pbxuser 44 | !default.pbxuser 45 | *.mode1v3 46 | !default.mode1v3 47 | *.mode2v3 48 | !default.mode2v3 49 | *.perspectivev3 50 | !default.perspectivev3 51 | xcuserdata/ 52 | 53 | ## Other 54 | *.moved-aside 55 | *.xccheckout 56 | *.xcscmblueprint 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | *.ipa 61 | *.dSYM.zip 62 | *.dSYM 63 | 64 | ## Playgrounds 65 | timeline.xctimeline 66 | playground.xcworkspace 67 | 68 | # Swift Package Manager 69 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 70 | # Packages/ 71 | # Package.pins 72 | # Package.resolved 73 | .build/ 74 | # Add this line if you want to avoid checking in Xcode SPM integration. 75 | # .swiftpm/xcode 76 | 77 | # CocoaPods 78 | # We recommend against adding the Pods directory to your .gitignore. However 79 | # you should judge for yourself, the pros and cons are mentioned at: 80 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 81 | # Pods/ 82 | # Add this line if you want to avoid checking in source code from the Xcode workspace 83 | # *.xcworkspace 84 | 85 | # Carthage 86 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 87 | # Carthage/Checkouts 88 | 89 | Carthage/Build 90 | 91 | # Accio dependency management 92 | Dependencies/ 93 | .accio/ 94 | 95 | # fastlane 96 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 97 | # screenshots whenever they are needed. 98 | # For more information about the recommended setup visit: 99 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 100 | 101 | fastlane/report.xml 102 | fastlane/Preview.html 103 | fastlane/screenshots/**/*.png 104 | fastlane/test_output 105 | 106 | # Code Injection 107 | # After new code Injection tools there's a generated folder /iOSInjectionProject 108 | # https://github.com/johnno1962/injectionforxcode 109 | 110 | iOSInjectionProject/ 111 | 112 | ### SwiftPackageManager ### 113 | .swiftpm 114 | Packages 115 | xcuserdata 116 | 117 | 118 | ### SwiftPM ### 119 | 120 | 121 | ### Xcode ### 122 | # Xcode 123 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 124 | 125 | ## User settings 126 | 127 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 128 | 129 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 130 | 131 | ## Xcode Patch 132 | *.xcodeproj/* 133 | !*.xcodeproj/project.pbxproj 134 | !*.xcodeproj/xcshareddata/ 135 | !*.xcworkspace/contents.xcworkspacedata 136 | /*.gcno 137 | 138 | ### Xcode Patch ### 139 | **/xcshareddata/WorkspaceSettings.xcsettings 140 | 141 | # End of https://www.gitignore.io/api/swift,xcode,macos,swiftpm,swiftpackagemanager -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Julian Pomper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftReactor", 8 | platforms: [ 9 | .iOS(.v13), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | .macOS(.v10_15) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 16 | .library( 17 | name: "SwiftReactor", 18 | targets: ["SwiftReactor"]), 19 | .library( 20 | name: "SwiftReactorUIKit", 21 | targets: ["SwiftReactorUIKit"]), 22 | ], 23 | dependencies: [ 24 | // Dependencies declare other packages that this package depends on. 25 | // .package(url: /* package url */, from: "1.0.0"), 26 | ], 27 | targets: [ 28 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 29 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 30 | .target( 31 | name: "SwiftReactor", 32 | dependencies: [], 33 | path: "Sources/SwiftReactor"), 34 | .target( 35 | name: "SwiftReactorUIKit", 36 | dependencies: ["SwiftReactor"], 37 | path: "Sources/SwiftReactorUIKit"), 38 | .testTarget( 39 | name: "SwiftReactorTests", 40 | dependencies: ["SwiftReactor"]), 41 | .testTarget( 42 | name: "SwiftReactorUIKitTests", 43 | dependencies: ["SwiftReactorUIKit"]) 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftReactor 2 | 3 | A protocol which should help structure your data flow in SwiftUI (and UIKit). 4 | 5 | Inspired by [@devxoul](https://github.com/devxoul)´s [ReactorKit](https://www.github.com/ReactorKit/ReactorKit). 6 | 7 | Special thanks to [@oanhof](https://github.com/oanhof) for contributing. 8 | 9 | ## Concept 10 | 11 | This protocol helps to structure and maintain the ReactorKit architecture in your SwiftUI or UIKit (with Combine) project. 12 | I highly encourage you to read the concept of this architecture in the ReactorKit´s [README.md](https://github.com/ReactorKit/ReactorKit#basic-concept) 13 | 14 | ## Usage 15 | 16 | To see the SwiftReactor in action, clone this repository and try the [example project](https://github.com/julianpomper/SwiftReactor/tree/master/SwiftReactorExample) 17 | 18 | ### Reactor 19 | 20 | For a basic setup just: 21 | 22 | 1. inherit from the `BaseReactor` class 23 | 2. define your `Action`s, `Mutation`s and your `State` 24 | 3. implement the `mutate(action: Action)` and `reduce(state: State, mutation: Mutation)` method 25 | 26 | and you are ready to go. 27 | 28 |
29 | Click here to show an example 30 | 31 | ```swift 32 | class ExampleReactor: BaseReactor { 33 | enum Action { 34 | case enterText(String) 35 | case setSwitch(Bool) 36 | case setSwitchAsync(Bool) 37 | case colorChangePressed(Color) 38 | } 39 | 40 | enum Mutation { 41 | case setText(String) 42 | case setSwitch(Bool) 43 | case setBackgroundColor(Color) 44 | } 45 | 46 | struct State { 47 | var text = "initial text" 48 | var switchValue = false 49 | var backgroundColor = Color.white 50 | } 51 | 52 | init() { 53 | super.init(initialState: State()) 54 | } 55 | 56 | override func mutate(action: Action) -> Mutations { 57 | switch action { 58 | case .enterText(let text): 59 | return [.setText(text)] //is equal to: Mutations(sync: .setText(text)) 60 | case .setSwitch(let value): 61 | return [.setSwitch(value)] //is equal to: Mutations(sync: .setSwitch(value)) 62 | case .setSwitchAsync(let value): 63 | let mutation = Just(Mutation.setSwitch(!value)).delay(for: 2, scheduler: DispatchQueue.global()) 64 | .eraseToAnyPublisher() 65 | 66 | return Mutations(sync: .setSwitch(value), async: mutation) 67 | case .colorChangePressed(let color): 68 | return [.setBackgroundColor(color)] //is equal to: Mutations(sync: .setBackgroundColor(color)) 69 | } 70 | } 71 | 72 | override func reduce(state: State, mutation: Mutation) -> State { 73 | var newState = state 74 | 75 | switch mutation { 76 | case .setText(let text): 77 | newState.text = text 78 | case .setSwitch(let value): 79 | newState.switchValue = value 80 | case .setBackgroundColor(let color): 81 | newState.backgroundColor = color 82 | } 83 | 84 | return newState 85 | } 86 | 87 | override func transform(mutation: AnyPublisher) -> AnyPublisher { 88 | mutation 89 | .prepend(.setText("hello")) 90 | .eraseToAnyPublisher() 91 | } 92 | } 93 | ``` 94 |
95 | 96 | #### `mutate(action: Action)` 97 | This method takes an `Action` and transforms it synchronously or asynchronously into a mutation. 98 | **If you have any side effects do it here.** 99 | 100 | Return `sync` mutations if you want to mutate the state instantly 101 | and sychronously on the main thread. `Binding` and `withAnimation` require the state to be changed 102 | on the main thread synchronously. For that reason use `sync` mutations for 103 | these use cases. 104 | 105 | 106 | Return `async` mutations if you have to do async tasks (ex.: network requests) 107 | or expensive tasks on a background queue 108 | 109 | ```swift 110 | func mutate(action: Action) -> Mutations { 111 | switch action { 112 | case .noMutationNeededAction: 113 | return .none 114 | case .enterText(let text): 115 | return Mutations(sync: .setText(text)) 116 | case .setSwitchAsync(let value): 117 | let mutation = API.setSetting(value) 118 | .catch { _ in Just(.setSwitch(!value)) } 119 | 120 | return Mutations(sync: .setSwitch(value), async: mutation) 121 | } 122 | } 123 | ``` 124 | 125 | #### `reduce(state: State, mutation: Mutation)` 126 | This method takes a `State` and a `Mutation` and returns a new mutated `State`. 127 | **Don't perform any side effects in this method. Extract them to the `mutate(action: Action)` function** 128 | 129 | ```swift 130 | func reduce(state: State, mutation: Mutation) -> State { 131 | var newState = state 132 | 133 | switch mutation { 134 | case .setText(let text): 135 | newState.text = text 136 | } 137 | 138 | return newState 139 | } 140 | ``` 141 | 142 | #### `transform()` 143 | Use these methods to intersect the state stream. This is the best place to combine and insert global event streams into your reactor. 144 | They are being called once, when the state stream is created in the `createStateStream()` method. 145 | 146 | ```swift 147 | /// Transforms an action and can be used to combine it with other publishers. 148 | func transform(action: AnyPublisher) -> AnyPublisher 149 | 150 | /// Transforms an mutation and can be used to combine it with other publishers. 151 | func transform(mutation: AnyPublisher) -> AnyPublisher 152 | 153 | /// Transforms the state and can be used to combine it with other publishers. 154 | func transform(state: AnyPublisher) -> AnyPublisher 155 | ``` 156 | 157 | #### `Mutations` 158 | 159 | `Mutations` is a `struct` for a better separation of your `sync` and `async` mutations. 160 | 161 | - `sync` is an `Array` with `Mutation`s that mutate the state instantly and are always automatically forced on the main thread synchronously. Use them specifically for UI interactions like `Binding`s, especially if the change should be animated (ex.: `withAnimation`) 162 | 163 | - `async` is an `AnyPublisher` that contains mutations that happen asynchronously and can mutate the state at any given time (ex.: if a network request returns a result). The `state` is always mutated on the main thread asychronously, everything before that happens on the thread of your choice. 164 | 165 | You can initialize sync `Mutations` like an array. In this case `[.mySyncMutation]` is equal to `Mutations(sync: .mySyncMutation)` or `[.mySyncMutation, .mySecondSyncMutation]` is equal to `Mutations(sync: [.mySyncMutation, .mySecondSyncMutation])` . 166 | 167 | If you do not want to mutate the state with an `Action` just return `.none` that equals to `Mutations()` 168 | 169 | 170 | ### View 171 | 172 | ```swift 173 | struct ContentView: View { 174 | // access your reactor via the `@EnvironmentObject` property wrapper 175 | @EnvironmentObject 176 | var reactor: AppReactor 177 | 178 | // you can use this property wrapper to bind your value and action 179 | // it can be used and behaves like the `@State` property wrapper 180 | @ActionBinding(\AppReactor.self, keyPath: \.name, action: AppReactor.Action.nameChanged) 181 | private var name: String 182 | 183 | var body: some View { 184 | VStack { 185 | // access the value from the binding (the value from your current state) 186 | Text(name.wrappedValue) 187 | // bind your action to the changes of this textfield 188 | TextField("Name", text: $name) 189 | } 190 | } 191 | } 192 | ``` 193 | 194 | ## Advanced 195 | 196 | ### `Reactor` Nesting 197 | 198 |
199 | Click here to expand 200 | 201 | It is also possible to split your logic into different reactors but also ensure a single source of truth by nesting reactors states. 202 | 203 | ```swift 204 | class AppReactor: BaseReactor { 205 | 206 | [...] 207 | 208 | public enum Mutation { 209 | case setDetail(DetailReactor.State) 210 | } 211 | 212 | struct State { 213 | var detail: DetailReactor.State 214 | } 215 | 216 | let detailReactor: DetailReactor 217 | 218 | init() { 219 | 220 | detailReactor = DetailReactor() 221 | 222 | super.init( 223 | initialState: State( 224 | detail: detailReactor.state 225 | ) 226 | ) 227 | } 228 | 229 | override func reduce(state: State, mutation: Mutation) -> State { 230 | var newState = state 231 | 232 | switch mutation { 233 | case let .setDetail(state): 234 | newState.detail = state 235 | } 236 | 237 | return newState 238 | } 239 | 240 | // transform the state changes to mutations 241 | override func transform(mutation: AnyPublisher) -> AnyPublisher { 242 | let detail = detailReactor.$state 243 | .map { Mutation.setDetail($0) } 244 | 245 | return mutation 246 | .merge(with: detail) 247 | } 248 | } 249 | ``` 250 | 251 | To access or bind actions to nested reactors use the following property wrappers: 252 | 253 | ```swift 254 | // get the root Reactor 255 | @EnvironmentReactor() 256 | var reactor: AppReactor 257 | 258 | // get a nested reactor 259 | @EnvironmentReactor(\AppReactor.detailViewReactor) 260 | var reactor: DetailReactor 261 | 262 | // bind `Action`s using the root reactor 263 | @ActionBinding(\AppReactor.self, keyPath: \.name, action: AppReactor.Action.nameChanged) 264 | private var name: String 265 | 266 | // bind `Action`s using the nested reactor 267 | @ActionBinding(\AppReactor.detailViewReactor, keyPath: \.age, action: DetailReactor.Action.ageChanged) 268 | private var age: Int 269 | ``` 270 | 271 |
272 | 273 | ### Use the `Reactor` protocol 274 | 275 |
276 | Click here to expand 277 | 278 | If you do not want to inherit the `BaseReactor` class, you can also implement the `Reactor` protocol on your own. 279 | 280 | 1. add all necessary propeties 281 | 2. add `@Published` to your state property 282 | 3. call the `createStateStream()` method (ex.: in your `init()`) 283 | 284 | ```swift 285 | class CountingReactor: Reactor { 286 | 287 | enum Action { 288 | case countUp 289 | case countUpAsync 290 | } 291 | 292 | enum Mutation { 293 | case countUp 294 | } 295 | 296 | struct State { 297 | var currentCount: Int = 0 298 | } 299 | 300 | public let action = PassthroughSubject() 301 | 302 | public let mutation = PassthroughSubject() 303 | 304 | @Published 305 | public var state = State() 306 | 307 | public var cancellables = Set() 308 | 309 | public init() { 310 | createStateStream() 311 | } 312 | 313 | open func mutate(action: Action) -> Mutations { 314 | switch action { 315 | case .countUp: 316 | return [.countUp] 317 | case .countUpAsync: 318 | return Mutations(async: Just(.countUp).eraseToAnyPublisher()) 319 | } 320 | } 321 | 322 | open func reduce(state: State, mutation: Mutation) -> State { 323 | var newState = state 324 | 325 | switch mutation { 326 | case .countUp: 327 | newState.currentCount += 1 328 | } 329 | 330 | return newState 331 | } 332 | } 333 | ``` 334 |
335 | 336 | 337 | ### UIKit 338 | 339 |
340 | Click here to expand 341 | 342 | `SwiftReactor` is also compatible with UIKit if you need it. To use it, you have to select and install the additional library `SwiftReactorUIKit` when you add the SwiftPackage to your project. 343 | 344 | 1. inherit from the `BaseReactorView` or `BaseReactorViewController` class 345 | 2. set the `reactor` property somewhere (ex.: when the `UIView` or `UIViewController` is being created) 346 | 3. implement the `bind(reactor:)` method and add your bindings 347 | 348 |
349 | Click here to show an example 350 | 351 | ```swift 352 | let countingViewController = BaseCountingViewController() 353 | countingViewController.reactor = CountingReactor() 354 | ``` 355 | 356 | ```swift 357 | final class BaseCountingViewController: BaseReactorViewController { 358 | 359 | var label = UILabel() 360 | 361 | /// automatically called when you set the reactor 362 | override func bind(reactor: Reactor) { 363 | reactor.$state 364 | .map { String($0.currentCount) } 365 | .assign(to: \.label.text, on: self) 366 | .store(in: &cancellables) 367 | } 368 | } 369 | ``` 370 |
371 | 372 |
373 | 374 | ## TODOs 375 | - [ ] Improve example project 376 | - [ ] Add more tests 377 | - [ ] Improve README 378 | 379 | ## Installation 380 | 381 | ### Swift Package Manager 382 | 383 | The Swift Package Manager is a tool for automating the distribution of swift code and is integrated into the swift compiler. 384 | 385 | Once you have your Swift package set up (ex: with [this guide](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app)), adding SwiftReactor as a dependency is as easy as adding it to the dependencies value of your `Package.swift`. 386 | 387 | ```swift 388 | dependencies: [ 389 | .package(url: "https://github.com/julianpomper/SwiftReactor.git", from: "2.0.0") 390 | ] 391 | ``` 392 | 393 | ### Manually 394 | 395 | If you prefer not to use any of the aforementioned dependency managers, you can integrate it into your project manually. 396 | 397 | 398 | ## Requirements 399 | 400 | * Swift 5.1 401 | * iOS 13 402 | * watchOS 6 403 | * tvOS 13 404 | * macOS 10.15 405 | 406 | 407 | ## License 408 | 409 | SwiftReactor is released under the MIT license. [See LICENSE](https://github.com/julianpomper/SwiftReactor/blob/master/LICENSE) for details. 410 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/BaseReactor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseReactor.swift 3 | // 4 | // 5 | // Created by oanhof on 31.07.20. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | /// A base class that can be used to simplify 12 | /// the implementation of the `Reactor` protocol. 13 | /// 14 | /// It adds all necessary properties and calls the `createStateStream` function for you 15 | open class BaseReactor: Reactor { 16 | 17 | public let action = PassthroughSubject() 18 | 19 | public let mutation = PassthroughSubject() 20 | 21 | @Published 22 | public var state: State 23 | 24 | public var cancellables = Set() 25 | 26 | public init(initialState: State) { 27 | state = initialState 28 | createStateStream() 29 | } 30 | 31 | open func mutate(action: Action) -> Mutations { 32 | .none 33 | } 34 | 35 | open func reduce(state: State, mutation: Mutation) -> State { 36 | state 37 | } 38 | 39 | open func transform(action: AnyPublisher) -> AnyPublisher { 40 | action 41 | } 42 | 43 | open func transform(mutation: AnyPublisher) -> AnyPublisher { 44 | mutation 45 | } 46 | 47 | open func transform(state: AnyPublisher) -> AnyPublisher { 48 | state 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/Bindings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bindings.swift 3 | // 4 | // 5 | // Created by oanhof on 31.07.20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Reactor { 11 | func mutate(binding keyPath: KeyPath, _ action: @escaping (Value) -> Action) -> Binding { 12 | Binding( 13 | get: { self.state[keyPath: keyPath] }, 14 | set: { self.action(action($0)) } 15 | ) 16 | } 17 | 18 | func reduce(binding keyPath: KeyPath, _ mutation: @escaping (Value) -> Mutation) -> Binding { 19 | Binding( 20 | get: { self.state[keyPath: keyPath] }, 21 | set: { self.mutation.send(mutation($0)) } 22 | ) 23 | } 24 | } 25 | 26 | /// Property wrapper to get a binding to a state keyPath and a associated Action 27 | /// Can be used and behaves like the `@State` property wrapper 28 | @propertyWrapper 29 | public struct ActionBinding: DynamicProperty { 30 | let target: EnvironmentReactor 31 | 32 | let keyPath: KeyPath 33 | let action: (Value) -> Target.Action 34 | 35 | /** 36 | - Parameters: 37 | - reactorPath: The keyPath to the Reactor in the views environment. eg. \AppReactor.self or \AppReactor.detailViewReactor for nested reactors 38 | - keyPath: Keypath to the value in the reactor´s state 39 | - action: Action to perform in the reactor 40 | */ 41 | public init(_ reactorPath: KeyPath, keyPath: KeyPath, action: @escaping (Value) -> Target.Action) { 42 | target = EnvironmentReactor(reactorPath) 43 | self.keyPath = keyPath 44 | self.action = action 45 | } 46 | 47 | public var wrappedValue: Value { 48 | get { projectedValue.wrappedValue } 49 | nonmutating set { projectedValue.wrappedValue = newValue } 50 | } 51 | 52 | public var projectedValue: Binding { 53 | get { target.wrappedValue.mutate(binding: keyPath, action) } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/EnvironmentReactor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentReactor.swift 3 | // 4 | // 5 | // Created by oanhof on 08.08.20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A property wrapper to get a reactor or one of its nested reactors from the environment. 11 | @propertyWrapper 12 | public struct EnvironmentReactor: DynamicProperty { 13 | @EnvironmentObject 14 | private var root: Root 15 | 16 | let keyPath: KeyPath 17 | 18 | /** 19 | - Parameter keyPath: KeyPath to the desired Reactor 20 | 21 | # Example # 22 | ``` 23 | // get the root Reactor 24 | @EnvironmentReactor() 25 | var reactor: AppReactor 26 | 27 | // get a nested reactor 28 | @EnvironmentReactor(\AppReactor.detailViewReactor) 29 | var reactor: DetailReactor 30 | ``` 31 | */ 32 | public init(_ keyPath: KeyPath) { 33 | self.keyPath = keyPath 34 | } 35 | 36 | #if swift(>=5.3) 37 | public init() where Root == Target { 38 | keyPath = \.self 39 | } 40 | #endif 41 | 42 | public var wrappedValue: Target { 43 | root[keyPath: keyPath] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/Mutations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mutations.swift 3 | // 4 | // 5 | // Created by oanhof on 31.07.20. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | /// Holds `sync` and `async` Mutations 12 | /// 13 | /// # Properties: 14 | /// - `sync` are mutations that mutate the state instantly and 15 | /// are always automatically forced on the main thread synchronously. 16 | /// Use them specifically for UI interactions like Bindings, especially 17 | /// if the change should be animated (ex.: `withAnimation`) 18 | /// 19 | /// - `async` are mutations that happen asynchronously and can mutate the state 20 | /// at any given time (ex.: if a network request returns a result). 21 | /// The state is always mutated on the main thread asychronously, everything 22 | /// before that everything happens on the thread of your choice 23 | /// 24 | /// # Intitializing: 25 | /// Because it conforms to the `ExpressibleByArrayLiteral` 26 | /// it is possible to initialize it with `sync` mutations like an array 27 | /// 28 | /// ``` swift 29 | /// [.mySyncMutation] 30 | /// ``` 31 | /// 32 | /// For convenience the static property `.none` can be used 33 | /// if there should not be a state muatation 34 | /// ``` swift 35 | /// Mutations.none 36 | /// ``` 37 | /// 38 | public struct Mutations { 39 | 40 | /// `sync` are mutations that mutate the state instantly and 41 | /// are always automatically forced on the main thread synchronously. 42 | /// Use them specifically for UI interactions like Bindings, especially 43 | /// if the change should be animated (ex.: `withAnimation`) 44 | public let sync: [Mutation] 45 | 46 | /// `async` are mutations that happen asynchronously and can mutate the state 47 | /// at any given time (ex.: if a network request returns a result). 48 | /// The state is always mutated on the main thread asychronously, everything 49 | /// before that everything happens on the thread of your choice 50 | public let async: AnyPublisher 51 | 52 | public init(sync: Mutation, async: AnyPublisher = Empty().eraseToAnyPublisher()) { 53 | self.init(sync: [sync], async: async) 54 | } 55 | 56 | public init(sync: [Mutation] = [], async: AnyPublisher = Empty().eraseToAnyPublisher()) { 57 | self.sync = sync 58 | self.async = async 59 | } 60 | } 61 | 62 | public extension Mutations { 63 | /// intializes without any mutations 64 | static var none: Mutations { [] } 65 | } 66 | 67 | extension Mutations: ExpressibleByArrayLiteral { 68 | /// initialize with an array of sync `Mutation`s 69 | public init(arrayLiteral elements: Mutation...) { 70 | self.init(sync: elements) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/Reactor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reactor.swift 3 | // 4 | // 5 | // Created by Julian Pomper on 26.12.19. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | /// A protocol to structure your data flow in SwiftUI 12 | /// 13 | /// - Important: call the `createStateStream` method at some point to 14 | /// make sure all actions are passed to the proper methods 15 | /// 16 | public protocol Reactor: ObservableObject { 17 | 18 | /// An action represents user actions. 19 | associatedtype Action 20 | 21 | /// A mutation represents state changes. 22 | associatedtype Mutation 23 | 24 | /// A State represents the current state of a section in the app. 25 | associatedtype State 26 | 27 | /// Passes all receiving actions down the state stream which is 28 | /// defined in the `createStateStream` method 29 | /// 30 | /// - Important: call the `createStateStream` method at some point to 31 | /// make sure all actions are passed to the proper methods 32 | /// 33 | var action: PassthroughSubject { get } 34 | 35 | var mutation: PassthroughSubject { get } 36 | 37 | /// The State represents the current state of a section in the app. 38 | /// 39 | /// - Warning: if you do not add @Published to this property 40 | /// you cannot subscribe to state changes 41 | /// 42 | /// - Important: add @Published to this property to be 43 | /// able to subscribe to changes in the state 44 | var state: State { get set } 45 | 46 | /// Stores all type-erasing cancellable instances for this reactor 47 | var cancellables: Set { get set } 48 | 49 | /// Use the `action(Action)` method to start the state stream, to ensure the state is mutated properly. 50 | /// Transforms a user action to a state mutation. 51 | /// 52 | /// - Important: If you have any side effects do it here. 53 | /// 54 | /// - Important: `Binding` and `withAnimation` require the state to be changed 55 | /// on the main thread synchronously. For that reason use `sync` mutations for 56 | /// this use cases 57 | /// 58 | /// 59 | /// # Usage: 60 | /// 61 | /// return `sync` mutations if you want to mutate the state instantly 62 | /// and sychronously on the main thread. Use them for `Binding` or 63 | /// if you want state changes to be animated in SwiftUI (ex.: `withAnimation`) 64 | /// 65 | /// 66 | /// return `async` mutations if you have to do async tasks (ex.: network requests) 67 | /// or expensive tasks on a background queue 68 | /// 69 | /// 70 | /// ```swift 71 | /// func mutate(action: Action) -> Mutations { 72 | /// switch action { 73 | /// case .noMutationNeededAction: 74 | /// return .none 75 | /// case .enterText(let text): 76 | /// return Mutations(sync: .setText(text)) 77 | /// case .setSwitchAsync(let value): 78 | /// let mutation = Just(Mutation.setSwitch(!value) 79 | /// .delay(for: 2, scheduler: DispatchQueue.global()) 80 | /// .eraseToAnyPublisher() 81 | /// 82 | /// return Mutations(sync: .setSwitch(value), async: mutation) 83 | /// } 84 | /// } 85 | /// ``` 86 | /// 87 | func mutate(action: Action) -> Mutations 88 | 89 | /// Mutates the state based on the given mutation. 90 | /// 91 | /// - Warning: There should not be any side effects in this method. 92 | /// 93 | /// # Usage: 94 | /// ```swift 95 | /// func reduce(state: State, mutation: Mutation) -> State { 96 | /// var newState = state 97 | /// 98 | /// switch mutation { 99 | /// case .myMutation(let text): 100 | /// newState.text = text 101 | /// } 102 | /// 103 | /// return newState 104 | /// } 105 | /// ``` 106 | /// 107 | func reduce(state: State, mutation: Mutation) -> State 108 | 109 | /// Bind values to actions 110 | func mutate(binding keyPath: KeyPath, _ action: @escaping (Value) -> Action) -> Binding 111 | 112 | /// Bind values to mutations 113 | func reduce(binding keyPath: KeyPath, _ mutation: @escaping (Value) -> Mutation) -> Binding 114 | 115 | /// Transforms an action and can be used to combine it with other publishers. 116 | /// It is called once when the state stream is created in the `createStateStream` method. 117 | func transform(action: AnyPublisher) -> AnyPublisher 118 | 119 | /// Transforms an mutation and can be used to combine it with other publishers. 120 | /// It is called once when the state stream is created in the `createStateStream` method. 121 | func transform(mutation: AnyPublisher) -> AnyPublisher 122 | 123 | /// Transforms the state and can be used to combine it with other publishers. 124 | /// It is called once when the state stream is created in the `createStateStream` method. 125 | func transform(state: AnyPublisher) -> AnyPublisher 126 | } 127 | 128 | private enum MutationEvent { 129 | case mutation(Mutation) 130 | case state(State) 131 | } 132 | 133 | private struct InternalState { 134 | let state: State 135 | let forward: Bool 136 | } 137 | 138 | public extension Reactor { 139 | 140 | /// A convenience method to send actions to the `action` subject 141 | func action(_ action: Action) { 142 | self.action.send(action) 143 | } 144 | 145 | /// Creates the state stream to properly call all methods on 146 | /// their dedicated threads. 147 | /// 148 | /// - Warning: This methods should only be called once when 149 | /// the reactor is initialized 150 | /// 151 | func createStateStream() { 152 | let stateLock = NSLock() 153 | 154 | let syncMutationResults = PassthroughSubject() 155 | 156 | let action = self.action 157 | .eraseToAnyPublisher() 158 | 159 | let transformedAction = transform(action: action) 160 | 161 | let initialState = self.state 162 | 163 | let mutation = transformedAction 164 | .flatMap { [weak self] action -> AnyPublisher in 165 | guard let self = self else { return Empty().eraseToAnyPublisher() } 166 | let mutations = self.mutate(action: action) 167 | let asyncMutations = mutations.async.eraseToAnyPublisher() 168 | 169 | guard !mutations.sync.isEmpty else { 170 | return asyncMutations 171 | } 172 | 173 | stateLock.lock() 174 | self.processSyncMutations(mutations.sync) 175 | syncMutationResults.send(self.state) 176 | stateLock.unlock() 177 | 178 | return asyncMutations 179 | } 180 | .eraseToAnyPublisher() 181 | 182 | let transformedMutation = syncMutationResults 183 | .map { MutationEvent.state($0) } 184 | .merge(with: transform(mutation: mutation) 185 | .merge(with: self.mutation) 186 | .map { MutationEvent.mutation($0) }) 187 | 188 | let state = transformedMutation 189 | .scan(InternalState(state: initialState, forward: true)) { [weak self] internalState, mutation -> InternalState in 190 | guard let self = self else { return internalState } 191 | switch mutation { 192 | case .mutation(let mutation): 193 | return InternalState(state: self.reduce(state: internalState.state, mutation: mutation), forward: true) 194 | case .state(let state): 195 | // merge results of sync mutations into the internal state, dont forward these downstream 196 | return InternalState(state: state, forward: false) 197 | } 198 | } 199 | .filter { $0.forward } 200 | .map { $0.state } 201 | .eraseToAnyPublisher() 202 | 203 | transform(state: state) 204 | .sink(receiveValue: { [weak self] state in 205 | if Thread.current.isMainThread { 206 | self?.state = state 207 | } else { 208 | DispatchQueue.main.sync { 209 | self?.state = state 210 | } 211 | } 212 | }) 213 | .store(in: &cancellables) 214 | } 215 | 216 | private func processSyncMutations(_ mutations: [Mutation]) { 217 | mutations.forEach { mutation in 218 | if Thread.current.isMainThread { 219 | state = reduce(state: state, mutation: mutation) 220 | } else { 221 | DispatchQueue.main.sync { 222 | state = reduce(state: state, mutation: mutation) 223 | } 224 | } 225 | } 226 | } 227 | 228 | func transform(action: AnyPublisher) -> AnyPublisher { 229 | action 230 | } 231 | 232 | func transform(mutation: AnyPublisher) -> AnyPublisher { 233 | mutation 234 | } 235 | 236 | func transform(state: AnyPublisher) -> AnyPublisher { 237 | state 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /Sources/SwiftReactor/ReactorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactorView.swift 3 | // 4 | // 5 | // Created by oanhof on 23.11.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | Used to create a view, where the Reactor lifetime is tied to the views lifecycle. 12 | 13 | Example usage: 14 | ``` 15 | .sheet(isPresented: $sheetPresented) { 16 | ReactorView(SheetReactor()) { 17 | SheetContentView() 18 | } 19 | } 20 | ``` 21 | */ 22 | @available(watchOS 7.0, *) 23 | @available(tvOS 14.0, *) 24 | @available(macOS 11.0, *) 25 | @available(iOS 14.0, *) 26 | public struct ReactorView: View { 27 | let content: Content 28 | 29 | @StateObject 30 | private var reactor: R 31 | 32 | public init(_ reactor: @escaping @autoclosure () -> R, @ViewBuilder content: () -> Content) { 33 | _reactor = StateObject(wrappedValue: reactor()) 34 | self.content = content() 35 | } 36 | 37 | public var body: some View { 38 | content 39 | .environmentObject(reactor) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SwiftReactorUIKit/BaseReactorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseReactorView.swift 3 | // 4 | // 5 | // Created by Julian Pomper on 08.08.20. 6 | // 7 | 8 | #if canImport(UIKit) && !os(watchOS) 9 | 10 | import UIKit 11 | import Combine 12 | import SwiftReactor 13 | 14 | /// A base class that can be used to simplify 15 | /// the implementation of the `ReactorUIView` protocol. 16 | /// 17 | /// It adds all necessary properties and calls the `bind(reactor:)` method for you, when the `reactor` is being set 18 | open class BaseReactorView: UIView, ReactorUIView { 19 | 20 | public var reactor: Reactor? { 21 | didSet { 22 | guard let reactor = reactor else { return } 23 | cancellables = [] 24 | bind(reactor: reactor) 25 | } 26 | } 27 | 28 | public var cancellables: Set = [] 29 | 30 | open func bind(reactor: Reactor) { } 31 | } 32 | 33 | /// A base class that can be used to simplify 34 | /// the implementation of the `ReactorUIView` protocol. 35 | /// 36 | /// It adds all necessary properties and calls the `bind(reactor:)` method for you, when the `reactor` is being set 37 | open class BaseReactorViewController: UIViewController, ReactorUIView { 38 | public var reactor: Reactor? { 39 | didSet { 40 | guard let reactor = reactor else { return } 41 | cancellables = [] 42 | bind(reactor: reactor) 43 | } 44 | } 45 | 46 | public var cancellables: Set = [] 47 | 48 | open func bind(reactor: Reactor) { } 49 | } 50 | 51 | #endif 52 | -------------------------------------------------------------------------------- /Sources/SwiftReactorUIKit/ReactorUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactorUIView.swift 3 | // 4 | // 5 | // Created by Julian Pomper on 08.08.20. 6 | // 7 | 8 | #if canImport(UIKit) 9 | 10 | import UIKit 11 | import Combine 12 | 13 | import SwiftReactor 14 | 15 | /// A protocol to use the `Reactor` with UIKit 16 | /// 17 | /// - Important: call the `setAndBind(reactor:)` method to set the reactor and call the `bind(reactor:)` method 18 | /// 19 | public protocol ReactorUIView: AnyObject { 20 | associatedtype Reactor = SwiftReactor.Reactor 21 | /** 22 | use `setAndBind` to set the reactor and call the `bind(reactor:)` method 23 | otherwise you can set your custom `didSet` for your reactor variable 24 | ~~~ 25 | { 26 | didSet { 27 | guard let reactor = reactor else { return } 28 | cancellables = Set() 29 | bind(reactor: reactor) 30 | } 31 | } 32 | ~~~ 33 | */ 34 | var reactor: Reactor? { get set } 35 | 36 | /// Stores all type-erasing cancellable instances for this reactor 37 | var cancellables: Set { get set } 38 | 39 | /** 40 | Bind/Assign state values and actions 41 | 42 | # Usage: 43 | ```swift 44 | func bind(reactor: CountingReactor) { 45 | reactor.$state 46 | .map { String($0.currentCount) } 47 | .assign(to: \.label.text, on: self) 48 | .store(in: &cancellables) 49 | } 50 | ``` 51 | */ 52 | func bind(reactor: Reactor) 53 | func setAndBind(reactor: Reactor) 54 | } 55 | 56 | public extension ReactorUIView { 57 | func setAndBind(reactor: Reactor) { 58 | self.reactor = reactor 59 | bind(reactor: reactor) 60 | } 61 | } 62 | 63 | #endif 64 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactor.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 7F4AFFE424E6AAA200447937 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2D8C624E6A78100B2D417 /* SceneDelegate.swift */; }; 11 | 7F4AFFE524E6AAA200447937 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2D8C424E6A78100B2D417 /* ContentView.swift */; }; 12 | 7F4AFFE624E6AAA200447937 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2D8C224E6A78100B2D417 /* AppDelegate.swift */; }; 13 | 7F4AFFE724E6AAA200447937 /* ExampleReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2D8C324E6A78100B2D417 /* ExampleReactor.swift */; }; 14 | 7FB2D8C824E6A79E00B2D417 /* SwiftReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 7FB2D8C724E6A79E00B2D417 /* SwiftReactor */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | 7FB2D8BD24E6A78100B2D417 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 19 | 7FB2D8BF24E6A78100B2D417 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 20 | 7FB2D8C124E6A78100B2D417 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 21 | 7FB2D8C224E6A78100B2D417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 22 | 7FB2D8C324E6A78100B2D417 /* ExampleReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleReactor.swift; sourceTree = ""; }; 23 | 7FB2D8C424E6A78100B2D417 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 24 | 7FB2D8C524E6A78100B2D417 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25 | 7FB2D8C624E6A78100B2D417 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 26 | B42CA97E24D0349A00C0526E /* SwiftReactorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftReactorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | B42CA99624D035B900C0526E /* SwiftUIReactor */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SwiftUIReactor; path = ..; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | B42CA97B24D0349A00C0526E /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | 7FB2D8C824E6A79E00B2D417 /* SwiftReactor in Frameworks */, 36 | ); 37 | runOnlyForDeploymentPostprocessing = 0; 38 | }; 39 | /* End PBXFrameworksBuildPhase section */ 40 | 41 | /* Begin PBXGroup section */ 42 | 7FB2D8BC24E6A78100B2D417 /* SwiftReactorExample */ = { 43 | isa = PBXGroup; 44 | children = ( 45 | 7FB2D8BD24E6A78100B2D417 /* Assets.xcassets */, 46 | 7FB2D8BE24E6A78100B2D417 /* Preview Content */, 47 | 7FB2D8C024E6A78100B2D417 /* LaunchScreen.storyboard */, 48 | 7FB2D8C224E6A78100B2D417 /* AppDelegate.swift */, 49 | 7FB2D8C324E6A78100B2D417 /* ExampleReactor.swift */, 50 | 7FB2D8C424E6A78100B2D417 /* ContentView.swift */, 51 | 7FB2D8C524E6A78100B2D417 /* Info.plist */, 52 | 7FB2D8C624E6A78100B2D417 /* SceneDelegate.swift */, 53 | ); 54 | path = SwiftReactorExample; 55 | sourceTree = ""; 56 | }; 57 | 7FB2D8BE24E6A78100B2D417 /* Preview Content */ = { 58 | isa = PBXGroup; 59 | children = ( 60 | 7FB2D8BF24E6A78100B2D417 /* Preview Assets.xcassets */, 61 | ); 62 | path = "Preview Content"; 63 | sourceTree = ""; 64 | }; 65 | B42CA97524D0349A00C0526E = { 66 | isa = PBXGroup; 67 | children = ( 68 | 7FB2D8BC24E6A78100B2D417 /* SwiftReactorExample */, 69 | B42CA99624D035B900C0526E /* SwiftUIReactor */, 70 | B42CA97F24D0349A00C0526E /* Products */, 71 | B42CA99724D035CE00C0526E /* Frameworks */, 72 | ); 73 | sourceTree = ""; 74 | }; 75 | B42CA97F24D0349A00C0526E /* Products */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | B42CA97E24D0349A00C0526E /* SwiftReactorExample.app */, 79 | ); 80 | name = Products; 81 | sourceTree = ""; 82 | }; 83 | B42CA99724D035CE00C0526E /* Frameworks */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | ); 87 | name = Frameworks; 88 | sourceTree = ""; 89 | }; 90 | /* End PBXGroup section */ 91 | 92 | /* Begin PBXNativeTarget section */ 93 | B42CA97D24D0349A00C0526E /* SwiftReactorExample */ = { 94 | isa = PBXNativeTarget; 95 | buildConfigurationList = B42CA99224D0349C00C0526E /* Build configuration list for PBXNativeTarget "SwiftReactorExample" */; 96 | buildPhases = ( 97 | B42CA97A24D0349A00C0526E /* Sources */, 98 | B42CA97B24D0349A00C0526E /* Frameworks */, 99 | B42CA97C24D0349A00C0526E /* Resources */, 100 | ); 101 | buildRules = ( 102 | ); 103 | dependencies = ( 104 | ); 105 | name = SwiftReactorExample; 106 | packageProductDependencies = ( 107 | 7FB2D8C724E6A79E00B2D417 /* SwiftReactor */, 108 | ); 109 | productName = SwiftUIReactorExample; 110 | productReference = B42CA97E24D0349A00C0526E /* SwiftReactorExample.app */; 111 | productType = "com.apple.product-type.application"; 112 | }; 113 | /* End PBXNativeTarget section */ 114 | 115 | /* Begin PBXProject section */ 116 | B42CA97624D0349A00C0526E /* Project object */ = { 117 | isa = PBXProject; 118 | attributes = { 119 | LastSwiftUpdateCheck = 1160; 120 | LastUpgradeCheck = 1200; 121 | ORGANIZATIONNAME = "Dominik Arnhof"; 122 | TargetAttributes = { 123 | B42CA97D24D0349A00C0526E = { 124 | CreatedOnToolsVersion = 11.6; 125 | }; 126 | }; 127 | }; 128 | buildConfigurationList = B42CA97924D0349A00C0526E /* Build configuration list for PBXProject "SwiftReactor" */; 129 | compatibilityVersion = "Xcode 9.3"; 130 | developmentRegion = en; 131 | hasScannedForEncodings = 0; 132 | knownRegions = ( 133 | en, 134 | Base, 135 | ); 136 | mainGroup = B42CA97524D0349A00C0526E; 137 | productRefGroup = B42CA97F24D0349A00C0526E /* Products */; 138 | projectDirPath = ""; 139 | projectRoot = ""; 140 | targets = ( 141 | B42CA97D24D0349A00C0526E /* SwiftReactorExample */, 142 | ); 143 | }; 144 | /* End PBXProject section */ 145 | 146 | /* Begin PBXResourcesBuildPhase section */ 147 | B42CA97C24D0349A00C0526E /* Resources */ = { 148 | isa = PBXResourcesBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | ); 152 | runOnlyForDeploymentPostprocessing = 0; 153 | }; 154 | /* End PBXResourcesBuildPhase section */ 155 | 156 | /* Begin PBXSourcesBuildPhase section */ 157 | B42CA97A24D0349A00C0526E /* Sources */ = { 158 | isa = PBXSourcesBuildPhase; 159 | buildActionMask = 2147483647; 160 | files = ( 161 | 7F4AFFE624E6AAA200447937 /* AppDelegate.swift in Sources */, 162 | 7F4AFFE524E6AAA200447937 /* ContentView.swift in Sources */, 163 | 7F4AFFE424E6AAA200447937 /* SceneDelegate.swift in Sources */, 164 | 7F4AFFE724E6AAA200447937 /* ExampleReactor.swift in Sources */, 165 | ); 166 | runOnlyForDeploymentPostprocessing = 0; 167 | }; 168 | /* End PBXSourcesBuildPhase section */ 169 | 170 | /* Begin PBXVariantGroup section */ 171 | 7FB2D8C024E6A78100B2D417 /* LaunchScreen.storyboard */ = { 172 | isa = PBXVariantGroup; 173 | children = ( 174 | 7FB2D8C124E6A78100B2D417 /* Base */, 175 | ); 176 | name = LaunchScreen.storyboard; 177 | sourceTree = ""; 178 | }; 179 | /* End PBXVariantGroup section */ 180 | 181 | /* Begin XCBuildConfiguration section */ 182 | B42CA99024D0349C00C0526E /* Debug */ = { 183 | isa = XCBuildConfiguration; 184 | buildSettings = { 185 | ALWAYS_SEARCH_USER_PATHS = NO; 186 | CLANG_ANALYZER_NONNULL = YES; 187 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 188 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 189 | CLANG_CXX_LIBRARY = "libc++"; 190 | CLANG_ENABLE_MODULES = YES; 191 | CLANG_ENABLE_OBJC_ARC = YES; 192 | CLANG_ENABLE_OBJC_WEAK = YES; 193 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 194 | CLANG_WARN_BOOL_CONVERSION = YES; 195 | CLANG_WARN_COMMA = YES; 196 | CLANG_WARN_CONSTANT_CONVERSION = YES; 197 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 198 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 199 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 200 | CLANG_WARN_EMPTY_BODY = YES; 201 | CLANG_WARN_ENUM_CONVERSION = YES; 202 | CLANG_WARN_INFINITE_RECURSION = YES; 203 | CLANG_WARN_INT_CONVERSION = YES; 204 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 205 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 206 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 207 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 208 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 209 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 210 | CLANG_WARN_STRICT_PROTOTYPES = YES; 211 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 212 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 213 | CLANG_WARN_UNREACHABLE_CODE = YES; 214 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 215 | COPY_PHASE_STRIP = NO; 216 | DEBUG_INFORMATION_FORMAT = dwarf; 217 | ENABLE_STRICT_OBJC_MSGSEND = YES; 218 | ENABLE_TESTABILITY = YES; 219 | GCC_C_LANGUAGE_STANDARD = gnu11; 220 | GCC_DYNAMIC_NO_PIC = NO; 221 | GCC_NO_COMMON_BLOCKS = YES; 222 | GCC_OPTIMIZATION_LEVEL = 0; 223 | GCC_PREPROCESSOR_DEFINITIONS = ( 224 | "DEBUG=1", 225 | "$(inherited)", 226 | ); 227 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 228 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 229 | GCC_WARN_UNDECLARED_SELECTOR = YES; 230 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 231 | GCC_WARN_UNUSED_FUNCTION = YES; 232 | GCC_WARN_UNUSED_VARIABLE = YES; 233 | IPHONEOS_DEPLOYMENT_TARGET = 13.6; 234 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 235 | MTL_FAST_MATH = YES; 236 | ONLY_ACTIVE_ARCH = YES; 237 | SDKROOT = iphoneos; 238 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 239 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 240 | }; 241 | name = Debug; 242 | }; 243 | B42CA99124D0349C00C0526E /* Release */ = { 244 | isa = XCBuildConfiguration; 245 | buildSettings = { 246 | ALWAYS_SEARCH_USER_PATHS = NO; 247 | CLANG_ANALYZER_NONNULL = YES; 248 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 249 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 250 | CLANG_CXX_LIBRARY = "libc++"; 251 | CLANG_ENABLE_MODULES = YES; 252 | CLANG_ENABLE_OBJC_ARC = YES; 253 | CLANG_ENABLE_OBJC_WEAK = YES; 254 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 255 | CLANG_WARN_BOOL_CONVERSION = YES; 256 | CLANG_WARN_COMMA = YES; 257 | CLANG_WARN_CONSTANT_CONVERSION = YES; 258 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 259 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 260 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 261 | CLANG_WARN_EMPTY_BODY = YES; 262 | CLANG_WARN_ENUM_CONVERSION = YES; 263 | CLANG_WARN_INFINITE_RECURSION = YES; 264 | CLANG_WARN_INT_CONVERSION = YES; 265 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 266 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 267 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 268 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 269 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 270 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 271 | CLANG_WARN_STRICT_PROTOTYPES = YES; 272 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 273 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 274 | CLANG_WARN_UNREACHABLE_CODE = YES; 275 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 276 | COPY_PHASE_STRIP = NO; 277 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 278 | ENABLE_NS_ASSERTIONS = NO; 279 | ENABLE_STRICT_OBJC_MSGSEND = YES; 280 | GCC_C_LANGUAGE_STANDARD = gnu11; 281 | GCC_NO_COMMON_BLOCKS = YES; 282 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 283 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 284 | GCC_WARN_UNDECLARED_SELECTOR = YES; 285 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 286 | GCC_WARN_UNUSED_FUNCTION = YES; 287 | GCC_WARN_UNUSED_VARIABLE = YES; 288 | IPHONEOS_DEPLOYMENT_TARGET = 13.6; 289 | MTL_ENABLE_DEBUG_INFO = NO; 290 | MTL_FAST_MATH = YES; 291 | SDKROOT = iphoneos; 292 | SWIFT_COMPILATION_MODE = wholemodule; 293 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 294 | VALIDATE_PRODUCT = YES; 295 | }; 296 | name = Release; 297 | }; 298 | B42CA99324D0349C00C0526E /* Debug */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 302 | CODE_SIGN_STYLE = Automatic; 303 | DEVELOPMENT_ASSET_PATHS = "\"SwiftReactorExample/Preview Content\""; 304 | ENABLE_PREVIEWS = YES; 305 | INFOPLIST_FILE = SwiftReactorExample/Info.plist; 306 | LD_RUNPATH_SEARCH_PATHS = ( 307 | "$(inherited)", 308 | "@executable_path/Frameworks", 309 | ); 310 | PRODUCT_BUNDLE_IDENTIFIER = com.oanhof.SwiftReactorExample; 311 | PRODUCT_NAME = "$(TARGET_NAME)"; 312 | SWIFT_VERSION = 5.0; 313 | TARGETED_DEVICE_FAMILY = "1,2"; 314 | }; 315 | name = Debug; 316 | }; 317 | B42CA99424D0349C00C0526E /* Release */ = { 318 | isa = XCBuildConfiguration; 319 | buildSettings = { 320 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 321 | CODE_SIGN_STYLE = Automatic; 322 | DEVELOPMENT_ASSET_PATHS = "\"SwiftReactorExample/Preview Content\""; 323 | ENABLE_PREVIEWS = YES; 324 | INFOPLIST_FILE = SwiftReactorExample/Info.plist; 325 | LD_RUNPATH_SEARCH_PATHS = ( 326 | "$(inherited)", 327 | "@executable_path/Frameworks", 328 | ); 329 | PRODUCT_BUNDLE_IDENTIFIER = com.oanhof.SwiftReactorExample; 330 | PRODUCT_NAME = "$(TARGET_NAME)"; 331 | SWIFT_VERSION = 5.0; 332 | TARGETED_DEVICE_FAMILY = "1,2"; 333 | }; 334 | name = Release; 335 | }; 336 | /* End XCBuildConfiguration section */ 337 | 338 | /* Begin XCConfigurationList section */ 339 | B42CA97924D0349A00C0526E /* Build configuration list for PBXProject "SwiftReactor" */ = { 340 | isa = XCConfigurationList; 341 | buildConfigurations = ( 342 | B42CA99024D0349C00C0526E /* Debug */, 343 | B42CA99124D0349C00C0526E /* Release */, 344 | ); 345 | defaultConfigurationIsVisible = 0; 346 | defaultConfigurationName = Release; 347 | }; 348 | B42CA99224D0349C00C0526E /* Build configuration list for PBXNativeTarget "SwiftReactorExample" */ = { 349 | isa = XCConfigurationList; 350 | buildConfigurations = ( 351 | B42CA99324D0349C00C0526E /* Debug */, 352 | B42CA99424D0349C00C0526E /* Release */, 353 | ); 354 | defaultConfigurationIsVisible = 0; 355 | defaultConfigurationName = Release; 356 | }; 357 | /* End XCConfigurationList section */ 358 | 359 | /* Begin XCSwiftPackageProductDependency section */ 360 | 7FB2D8C724E6A79E00B2D417 /* SwiftReactor */ = { 361 | isa = XCSwiftPackageProductDependency; 362 | productName = SwiftReactor; 363 | }; 364 | /* End XCSwiftPackageProductDependency section */ 365 | }; 366 | rootObject = B42CA97624D0349A00C0526E /* Project object */; 367 | } 368 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactor.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactor.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactor.xcodeproj/xcshareddata/xcschemes/SwiftReactorExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftReactorExample 4 | // 5 | // Created by oanhof on 28.07.20. 6 | // Copyright © 2020 oanhof. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | 33 | 34 | } 35 | 36 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftReactorExample 4 | // 5 | // Created by oanhof on 28.07.20. 6 | // Copyright © 2020 oanhof. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftReactor 11 | 12 | struct ContentView: View { 13 | @ActionBinding(\ExampleReactor.self, keyPath: \.text, action: ExampleReactor.Action.enterText) 14 | private var text: String 15 | 16 | @ActionBinding(\ExampleReactor.self, keyPath: \.switchValue, action: ExampleReactor.Action.setSwitch) 17 | private var switchValue: Bool 18 | 19 | @ActionBinding(\ExampleReactor.self, keyPath: \.switchValue, action: ExampleReactor.Action.setSwitchAsync) 20 | private var switchValueAsync: Bool 21 | 22 | @ActionBinding(\ExampleReactor.self, keyPath: \.backgroundColor, action: ExampleReactor.Action.colorChangePressed) 23 | private var backgroundColor: Color 24 | 25 | @State 26 | private var sheetPresented = false 27 | 28 | var body: some View { 29 | VStack(spacing: 8) { 30 | TextField("Text", text: $text) 31 | .textFieldStyle(RoundedBorderTextFieldStyle()) 32 | Text(text) 33 | 34 | Toggle(isOn: $switchValue, label: { Text("Switch \(String(switchValue))") }) 35 | 36 | Toggle(isOn: $switchValueAsync, label: { Text("Switch async \(String(switchValueAsync))") }) 37 | 38 | Button(action: { 39 | withAnimation(.spring()) { 40 | self.backgroundColor = [Color.red, .orange, .green].randomElement() ?? .white 41 | } 42 | }, label: { 43 | Text("Random Color") 44 | }) 45 | 46 | if #available(iOS 14.0, *) { 47 | NavigationLink("Navigate") { 48 | ReactorView(ExampleReactor()) { 49 | ContentView() 50 | } 51 | } 52 | 53 | Button("Present") { 54 | sheetPresented = true 55 | } 56 | } 57 | } 58 | .padding() 59 | .background(backgroundColor) 60 | .sheet(isPresented: $sheetPresented) { 61 | if #available(iOS 14.0, *) { 62 | ReactorView(ExampleReactor()) { 63 | ContentView() 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | struct ContentView_Previews: PreviewProvider { 71 | static let reactor = ExampleReactor() 72 | 73 | static var previews: some View { 74 | ContentView() 75 | .environmentObject(reactor) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/ExampleReactor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleReactor.swift 3 | // SwiftReactorExample 4 | // 5 | // Created by oanhof on 28.07.20. 6 | // Copyright © 2020 oanhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftReactor 11 | import Combine 12 | import SwiftUI 13 | 14 | class ExampleReactor: BaseReactor { 15 | enum Action { 16 | case enterText(String) 17 | case setSwitch(Bool) 18 | case setSwitchAsync(Bool) 19 | case colorChangePressed(Color) 20 | } 21 | 22 | enum Mutation { 23 | case setText(String) 24 | case setSwitch(Bool) 25 | case setBackgroundColor(Color) 26 | } 27 | 28 | struct State { 29 | var text = "initial text" 30 | var switchValue = false 31 | var backgroundColor = Color.white 32 | } 33 | 34 | init() { 35 | super.init(initialState: State()) 36 | } 37 | 38 | override func mutate(action: Action) -> Mutations { 39 | switch action { 40 | case .enterText(let text): 41 | return [.setText(text)] 42 | case .setSwitch(let value): 43 | return [.setSwitch(value)] 44 | case .setSwitchAsync(let value): 45 | let mutation = Just(Mutation.setSwitch(!value)).delay(for: 2, scheduler: DispatchQueue.global()) 46 | .eraseToAnyPublisher() 47 | 48 | return Mutations(sync: .setSwitch(value), async: mutation) 49 | case .colorChangePressed(let color): 50 | return [.setBackgroundColor(color)] 51 | } 52 | } 53 | 54 | override func reduce(state: State, mutation: Mutation) -> State { 55 | var newState = state 56 | 57 | switch mutation { 58 | case .setText(let text): 59 | newState.text = text 60 | case .setSwitch(let value): 61 | newState.switchValue = value 62 | case .setBackgroundColor(let color): 63 | newState.backgroundColor = color 64 | } 65 | 66 | return newState 67 | } 68 | 69 | override func transform(action: AnyPublisher) -> AnyPublisher { 70 | action 71 | .prepend(.setSwitchAsync(true)) 72 | .eraseToAnyPublisher() 73 | } 74 | 75 | override func transform(mutation: AnyPublisher) -> AnyPublisher { 76 | mutation 77 | .prepend(.setText("hello")) 78 | .eraseToAnyPublisher() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftReactorExample/SwiftReactorExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // SwiftReactorExample 4 | // 5 | // Created by oanhof on 28.07.20. 6 | // Copyright © 2020 oanhof. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 18 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 19 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 20 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 21 | 22 | // Create the SwiftUI view that provides the window contents. 23 | let contentView = ContentView() 24 | .environmentObject(ExampleReactor()) 25 | 26 | let navigationView = NavigationView { 27 | contentView 28 | } 29 | 30 | // Use a UIHostingController as window root view controller. 31 | if let windowScene = scene as? UIWindowScene { 32 | let window = UIWindow(windowScene: windowScene) 33 | window.rootViewController = UIHostingController(rootView: navigationView) 34 | self.window = window 35 | window.makeKeyAndVisible() 36 | } 37 | } 38 | 39 | func sceneDidDisconnect(_ scene: UIScene) { 40 | // Called as the scene is being released by the system. 41 | // This occurs shortly after the scene enters the background, or when its session is discarded. 42 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 43 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 44 | } 45 | 46 | func sceneDidBecomeActive(_ scene: UIScene) { 47 | // Called when the scene has moved from an inactive state to an active state. 48 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 49 | } 50 | 51 | func sceneWillResignActive(_ scene: UIScene) { 52 | // Called when the scene will move from an active state to an inactive state. 53 | // This may occur due to temporary interruptions (ex. an incoming phone call). 54 | } 55 | 56 | func sceneWillEnterForeground(_ scene: UIScene) { 57 | // Called as the scene transitions from the background to the foreground. 58 | // Use this method to undo the changes made on entering the background. 59 | } 60 | 61 | func sceneDidEnterBackground(_ scene: UIScene) { 62 | // Called as the scene transitions from the foreground to the background. 63 | // Use this method to save data, release shared resources, and store enough scene-specific state information 64 | // to restore the scene back to its current state. 65 | } 66 | 67 | 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftReactorTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftReactorTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftReactorTests/SwiftReactorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftReactor 3 | import Combine 4 | 5 | final class SwiftReactorTests: XCTestCase { 6 | var reactor: CountingReactor! 7 | var transformReactor: TransformCountingReactor! 8 | 9 | var cancellables = Set() 10 | 11 | override func setUp() { 12 | reactor = CountingReactor() 13 | transformReactor = TransformCountingReactor() 14 | } 15 | 16 | func testConcurrentAction() { 17 | let amount = 10000 18 | 19 | let exp = expectation(description: "counted") 20 | 21 | reactor.$state 22 | .sink { state in 23 | if state.currentCount == amount { 24 | exp.fulfill() 25 | } 26 | } 27 | .store(in: &cancellables) 28 | 29 | for _ in (1...amount) { 30 | DispatchQueue.global().async { 31 | self.reactor.action(.countUp) 32 | } 33 | } 34 | 35 | waitForExpectations(timeout: 10, handler: nil) 36 | 37 | XCTAssertEqual(reactor.state.currentCount, amount) 38 | } 39 | 40 | func testConcurrentAsyncAction() { 41 | let amount = 10000 42 | 43 | let exp = expectation(description: "counted") 44 | 45 | reactor.$state 46 | .sink { state in 47 | if state.currentCount == amount { 48 | exp.fulfill() 49 | } 50 | } 51 | .store(in: &cancellables) 52 | 53 | for _ in (1...amount) { 54 | DispatchQueue.global().async { 55 | self.reactor.action(.countUpAsync) 56 | } 57 | } 58 | 59 | waitForExpectations(timeout: 3, handler: nil) 60 | 61 | XCTAssertEqual(reactor.state.currentCount, amount) 62 | } 63 | 64 | func testConcurrentMixedAction() { 65 | let amount = 10000 66 | 67 | let exp = expectation(description: "counted") 68 | 69 | reactor.$state 70 | .sink { state in 71 | XCTAssertTrue(Thread.current.isMainThread) 72 | if state.currentCount == amount { 73 | exp.fulfill() 74 | } 75 | } 76 | .store(in: &cancellables) 77 | 78 | for idx in (1...amount) { 79 | DispatchQueue.global().async { 80 | if idx % 2 == 0 { 81 | self.reactor.action(.countUp) 82 | } else { 83 | self.reactor.action(.countUpAsync) 84 | } 85 | } 86 | } 87 | 88 | waitForExpectations(timeout: 3, handler: nil) 89 | 90 | XCTAssertEqual(reactor.state.currentCount, amount) 91 | } 92 | 93 | func testCount() { 94 | let amount = 1000 95 | for _ in (1...amount) { 96 | reactor.action(.countUp) 97 | } 98 | XCTAssertEqual(reactor.state.currentCount, amount) 99 | } 100 | 101 | func testTransforms() { 102 | let exp = expectation(description: "counted") 103 | exp.expectedFulfillmentCount = 2 104 | 105 | transformReactor.$state 106 | .sink { state in 107 | XCTAssertTrue(Thread.current.isMainThread) 108 | print("currentCount", state.currentCount) 109 | exp.fulfill() 110 | } 111 | .store(in: &cancellables) 112 | 113 | transformReactor.action(.countUp) 114 | 115 | waitForExpectations(timeout: 3, handler: nil) 116 | 117 | XCTAssertEqual(transformReactor.state.currentCount, 5) 118 | } 119 | 120 | func testInitialState() { 121 | let exp = expectation(description: "initial") 122 | 123 | reactor.$state 124 | .sink { state in 125 | XCTAssertTrue(Thread.current.isMainThread) 126 | XCTAssertEqual(state.currentCount, 0) 127 | exp.fulfill() 128 | } 129 | .store(in: &cancellables) 130 | 131 | waitForExpectations(timeout: 3, handler: nil) 132 | 133 | XCTAssertEqual(reactor.state.currentCount, 0) 134 | } 135 | } 136 | 137 | final class CountingReactor: BaseReactor { 138 | 139 | enum Action { 140 | case countUp 141 | case countUpAsync 142 | } 143 | 144 | enum Mutation { 145 | case countUp 146 | } 147 | 148 | struct State { 149 | var currentCount: Int = 0 150 | } 151 | 152 | init() { 153 | super.init(initialState: State()) 154 | } 155 | 156 | override func mutate(action: Action) -> Mutations { 157 | switch action { 158 | case .countUp: 159 | return [.countUp] 160 | case .countUpAsync: 161 | return Mutations(async: Just(.countUp).eraseToAnyPublisher()) 162 | } 163 | } 164 | 165 | override func reduce(state: State, mutation: Mutation) -> State { 166 | var newState = state 167 | 168 | switch mutation { 169 | case .countUp: 170 | newState.currentCount += 1 171 | } 172 | 173 | return newState 174 | } 175 | } 176 | 177 | final class TransformCountingReactor: BaseReactor { 178 | 179 | enum Action { 180 | case countUp 181 | case countUpTwo 182 | case countUpAsync 183 | } 184 | 185 | enum Mutation { 186 | case countUp 187 | case countUpTwo 188 | } 189 | 190 | struct State { 191 | var currentCount: Int = 0 192 | } 193 | 194 | init() { 195 | super.init(initialState: State()) 196 | } 197 | 198 | override func mutate(action: Action) -> Mutations { 199 | switch action { 200 | case .countUp: 201 | return [.countUp] 202 | case .countUpTwo: 203 | return [.countUpTwo] 204 | case .countUpAsync: 205 | return Mutations(async: Just(.countUp).eraseToAnyPublisher()) 206 | } 207 | } 208 | 209 | override func reduce(state: State, mutation: Mutation) -> State { 210 | var newState = state 211 | 212 | switch mutation { 213 | case .countUp: 214 | newState.currentCount += 1 215 | case .countUpTwo: 216 | newState.currentCount += 2 217 | } 218 | 219 | return newState 220 | } 221 | 222 | override func transform(action: AnyPublisher) -> AnyPublisher { 223 | let merge = Just(Action.countUpAsync) 224 | return action 225 | .prepend(.countUpTwo) 226 | .merge(with: merge) 227 | .eraseToAnyPublisher() 228 | } 229 | 230 | override func transform(mutation: AnyPublisher) -> AnyPublisher { 231 | mutation 232 | .prepend(.countUp) 233 | .eraseToAnyPublisher() 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /Tests/SwiftReactorUIKitTests/SwiftReactorUIKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftReactorUIKitTests.swift 3 | // 4 | // 5 | // Created by Julian Pomper on 08.08.20. 6 | // 7 | 8 | #if canImport(UIKit) && !os(watchOS) 9 | 10 | import UIKit 11 | import Combine 12 | import XCTest 13 | @testable import SwiftReactor 14 | @testable import SwiftReactorUIKit 15 | 16 | final class SwiftReactorUIKitTests: XCTestCase { 17 | var reactor: CountingReactor! 18 | 19 | var cancellables = Set() 20 | 21 | override func setUp() { 22 | reactor = CountingReactor() 23 | } 24 | 25 | func testSetAndBind() { 26 | let countingViewController = CountingViewController() 27 | countingViewController.setAndBind(reactor: reactor) 28 | 29 | XCTAssertNotNil(countingViewController.reactor) 30 | 31 | reactor.action(.countUp) 32 | 33 | XCTAssertEqual(countingViewController.label.text, "1") 34 | } 35 | 36 | func testBaseReactorView() { 37 | let countingView = BaseCountingView() 38 | countingView.reactor = reactor 39 | 40 | reactor.action(.countUp) 41 | 42 | XCTAssertEqual(countingView.label.text, "1") 43 | } 44 | 45 | func testBaseReactorViewController() { 46 | let countingViewController = BaseCountingViewController() 47 | countingViewController.reactor = reactor 48 | 49 | reactor.action(.countUp) 50 | 51 | XCTAssertEqual(countingViewController.label.text, "1") 52 | } 53 | } 54 | 55 | final class BaseCountingView: BaseReactorView { 56 | 57 | var label = UILabel() 58 | 59 | override func bind(reactor: Reactor) { 60 | reactor.$state 61 | .map { String($0.currentCount) } 62 | .assign(to: \.label.text, on: self) 63 | .store(in: &cancellables) 64 | } 65 | } 66 | 67 | final class BaseCountingViewController: BaseReactorViewController { 68 | 69 | var label = UILabel() 70 | 71 | override func bind(reactor: Reactor) { 72 | reactor.$state 73 | .map { String($0.currentCount) } 74 | .assign(to: \.label.text, on: self) 75 | .store(in: &cancellables) 76 | } 77 | } 78 | 79 | final class CountingViewController: UIViewController, ReactorUIView { 80 | typealias Reactor = CountingReactor 81 | 82 | var reactor: Reactor? 83 | var cancellables: Set = [] 84 | 85 | var label = UILabel() 86 | 87 | func bind(reactor: Reactor) { 88 | reactor.$state 89 | .map { String($0.currentCount) } 90 | .assign(to: \.label.text, on: self) 91 | .store(in: &cancellables) 92 | } 93 | } 94 | 95 | final class CountingReactor: BaseReactor { 96 | 97 | enum Action { 98 | case countUp 99 | } 100 | 101 | enum Mutation { 102 | case countUp 103 | } 104 | 105 | struct State { 106 | var currentCount: Int = 0 107 | } 108 | 109 | init() { 110 | super.init(initialState: State()) 111 | } 112 | 113 | override func mutate(action: Action) -> Mutations { 114 | switch action { 115 | case .countUp: 116 | return [.countUp] 117 | } 118 | } 119 | 120 | override func reduce(state: State, mutation: Mutation) -> State { 121 | var newState = state 122 | 123 | switch mutation { 124 | case .countUp: 125 | newState.currentCount += 1 126 | } 127 | 128 | return newState 129 | } 130 | 131 | } 132 | 133 | #endif 134 | -------------------------------------------------------------------------------- /Tests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SwiftReactorTests.allTests), 7 | #if canImport(UIKit) 8 | testCase(SwiftReactorUIKitTests.allTests) 9 | #endif 10 | ] 11 | } 12 | #endif 13 | --------------------------------------------------------------------------------