├── Sources ├── ComposableArchitecture │ ├── Internal │ │ ├── Exports.swift │ │ ├── Locking.swift │ │ ├── Throttling.swift │ │ ├── Diff.swift │ │ └── Debug.swift │ ├── Effects │ │ ├── Debouncing.swift │ │ ├── Timer.swift │ │ └── Cancellation.swift │ ├── UIKit │ │ ├── Alert+UIKit.swift │ │ └── IfLetUIKit.swift │ ├── ViewStore.swift │ ├── Debugging │ │ ├── ReducerInstrumentation.swift │ │ └── ReducerDebugging.swift │ ├── Helpers │ │ └── Alert.swift │ ├── Store.swift │ └── Effect.swift └── ComposableCoreLocation │ ├── Internal │ └── Exports.swift │ ├── Models │ ├── Region.swift │ ├── Beacon.swift │ ├── Visit.swift │ ├── Heading.swift │ └── Location.swift │ ├── Live.swift │ └── Mock.swift ├── .gitignore ├── Examples └── CaseStudies │ ├── UIKitCaseStudies │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── AppIcon.png │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Internal │ │ ├── ActivityIndicatorViewController.swift │ │ └── IfLetStoreController.swift │ ├── SceneDelegate.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── ListsOfState.swift │ ├── CounterViewController.swift │ ├── RootViewController.swift │ ├── NavigateAndLoad.swift │ └── LoadThenNavigate.swift │ ├── README.md │ ├── UIKitCaseStudiesTests │ ├── UIKitCaseStudiesTests.swift │ └── Info.plist │ └── CaseStudies.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── CaseStudies (UIKit).xcscheme ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── ComposableCoreLocation.xcscheme │ ├── ComposableArchitecture.xcscheme │ └── rxswift-composable-architecture-Package.xcscheme ├── ComposableArchitecture.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ └── bug_report.md ├── workflows │ ├── ci.yml │ └── format.yml └── CODE_OF_CONDUCT.md ├── Makefile ├── Tests ├── ComposableArchitectureTests │ ├── Helpers │ │ └── TestScheduler.swift │ ├── MemoryManagementTests.swift │ ├── EffectDebounceTests.swift │ ├── TimerTests.swift │ ├── Internal │ │ └── EffectThrottleTests.swift │ ├── ComposableArchitectureTests.swift │ ├── EffectTests.swift │ ├── ReducerTests.swift │ ├── EffectCancellationTests.swift │ └── StoreTests.swift └── ComposableCoreLocationTests │ └── ComposableCoreLocationTests.swift ├── LICENSE ├── Package.swift └── ComposableArchitecture.podspec /Sources/ComposableArchitecture/Internal/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import CasePaths 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | Package.resolved 7 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Internal/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import ComposableArchitecture 2 | @_exported import CoreLocation 3 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/CaseStudies/README.md: -------------------------------------------------------------------------------- 1 | # Composable Architecture Case Studies 2 | 3 | This project includes a number of digestible examples of how to solve common problems using the Composable Architecture. 4 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import UIKitCaseStudies 4 | 5 | class UIKitCaseStudiesTests: XCTestCase { 6 | func testExample() { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dannyhertz/rxswift-composable-architecture/HEAD/Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ComposableArchitecture.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Have a question about the Composable Architecture? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | The Composable Architecture uses GitHub issues for bugs. For more general discussion and help, please use [the Swift forum](https://forums.swift.org/c/related-projects/swift-composable-architecture) first. 11 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Internal/Locking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UnsafeMutablePointer where Pointee == os_unfair_lock_s { 4 | @inlinable @discardableResult 5 | func sync(_ work: () -> R) -> R { 6 | os_unfair_lock_lock(self) 7 | defer { os_unfair_lock_unlock(self) } 8 | return work() 9 | } 10 | } 11 | 12 | extension NSRecursiveLock { 13 | @inlinable @discardableResult 14 | func sync(work: () -> R) -> R { 15 | self.lock() 16 | defer { self.unlock() } 17 | return work() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | library: 13 | runs-on: macOS-latest 14 | strategy: 15 | matrix: 16 | xcode: 17 | - 11.4 18 | - 11.5 19 | - 11.6 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Select Xcode ${{ matrix.xcode }} 23 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 24 | - name: Run tests 25 | run: make test 26 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | swift_format: 10 | name: swift-format 11 | runs-on: macOS-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install 15 | run: brew install swift-format 16 | - name: Format 17 | run: make format 18 | - uses: stefanzweifel/git-auto-commit-action@v4 19 | with: 20 | commit_message: Run swift-format 21 | branch: 'master' 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,name=iPhone 11 Pro Max 2 | PLATFORM_MACOS = macOS 3 | PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4K (at 1080p) 4 | 5 | default: test 6 | 7 | test: 8 | instruments -s devices 9 | xcodebuild test \ 10 | -scheme ComposableArchitecture \ 11 | -destination platform="$(PLATFORM_IOS)" 12 | xcodebuild test \ 13 | -scheme ComposableArchitecture \ 14 | -destination platform="$(PLATFORM_MACOS)" 15 | xcodebuild test \ 16 | -scheme ComposableArchitecture \ 17 | -destination platform="$(PLATFORM_TVOS)" 18 | xcodebuild test \ 19 | -scheme "CaseStudies (UIKit)" \ 20 | -destination platform="$(PLATFORM_IOS)" 21 | 22 | format: 23 | swift format --in-place --recursive ./Package.swift ./Sources ./Tests 24 | 25 | .PHONY: format test -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/Helpers/TestScheduler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | import RxTest 4 | 5 | private let testSchedulerResultion = 0.01 6 | 7 | extension TestScheduler { 8 | 9 | public static func `default`(withInitialClock initialClock: Int = 0) -> TestScheduler { 10 | TestScheduler( 11 | initialClock: initialClock, resolution: testSchedulerResultion, simulateProcessingDelay: false 12 | ) 13 | } 14 | 15 | public func advance(by: TimeInterval = 0) { 16 | self.advanceTo(self.clock + Int(by * (1 / testSchedulerResultion))) 17 | } 18 | 19 | public func tick() { 20 | self.advanceTo(self.clock + 1) 21 | } 22 | 23 | public func run() { 24 | self.advanceTo(Int(Date.distantFuture.timeIntervalSince1970)) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/Internal/ActivityIndicatorViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ActivityIndicatorViewController: UIViewController { 4 | override func viewDidLoad() { 5 | super.viewDidLoad() 6 | 7 | self.view.backgroundColor = .white 8 | 9 | let activityIndicator = UIActivityIndicatorView() 10 | activityIndicator.startAnimating() 11 | activityIndicator.translatesAutoresizingMaskIntoConstraints = false 12 | self.view.addSubview(activityIndicator) 13 | 14 | NSLayoutConstraint.activate([ 15 | activityIndicator.centerXAnchor.constraint( 16 | equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), 17 | activityIndicator.centerYAnchor.constraint( 18 | equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), 19 | ]) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Give a clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Zip up a project that reproduces the behavior and attach it by dragging it here. 15 | 16 | ```swift 17 | // And/or enter code that reproduces the behavior here. 18 | 19 | ``` 20 | 21 | **Expected behavior** 22 | Give a clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Environment** 28 | - Xcode [e.g. 11.4.1] 29 | - Swift [e.g. 5.2.2] 30 | - OS (if applicable): [e.g. iOS 13] 31 | 32 | **Additional context** 33 | Add any more context about the problem here. 34 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudiesTests/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 | 22 | 23 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene( 7 | _ scene: UIScene, 8 | willConnectTo session: UISceneSession, 9 | options connectionOptions: UIScene.ConnectionOptions 10 | ) { 11 | self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:)) 12 | self.window?.rootViewController = UINavigationController( 13 | rootViewController: RootViewController()) 14 | self.window?.makeKeyAndVisible() 15 | } 16 | } 17 | 18 | @UIApplicationMain 19 | class AppDelegate: UIResponder, UIApplicationDelegate { 20 | func application( 21 | _ application: UIApplication, 22 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 23 | ) -> Bool { 24 | true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Point-Free, Inc. 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 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Models/Region.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | /// A value type wrapper for `CLRegion`. This type is necessary so that we can do equality checks 4 | /// and write tests against its values. 5 | public struct Region: Hashable { 6 | public let rawValue: CLRegion? 7 | 8 | public var identifier: String 9 | public var notifyOnEntry: Bool 10 | public var notifyOnExit: Bool 11 | 12 | init(rawValue: CLRegion) { 13 | self.rawValue = rawValue 14 | 15 | self.identifier = rawValue.identifier 16 | self.notifyOnEntry = rawValue.notifyOnEntry 17 | self.notifyOnExit = rawValue.notifyOnExit 18 | } 19 | 20 | init( 21 | identifier: String, 22 | notifyOnEntry: Bool, 23 | notifyOnExit: Bool 24 | ) { 25 | self.rawValue = nil 26 | 27 | self.identifier = identifier 28 | self.notifyOnEntry = notifyOnEntry 29 | self.notifyOnExit = notifyOnExit 30 | } 31 | 32 | public static func == (lhs: Self, rhs: Self) -> Bool { 33 | lhs.identifier == rhs.identifier 34 | && lhs.notifyOnEntry == rhs.notifyOnEntry 35 | && lhs.notifyOnExit == rhs.notifyOnExit 36 | } 37 | 38 | public func hash(into hasher: inout Hasher) { 39 | hasher.combine(self.identifier) 40 | hasher.combine(self.notifyOnExit) 41 | hasher.combine(self.notifyOnEntry) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "rxswift-composable-architecture", 7 | platforms: [ 8 | .iOS(.v12), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "ComposableArchitecture", 16 | targets: ["ComposableArchitecture"] 17 | ), 18 | .library( 19 | name: "ComposableCoreLocation", 20 | targets: ["ComposableCoreLocation"] 21 | ), 22 | ], 23 | dependencies: [ 24 | .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.1.1"), 25 | .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "5.0.0"), 26 | ], 27 | targets: [ 28 | .target( 29 | name: "ComposableArchitecture", 30 | dependencies: [ 31 | "CasePaths", "RxSwift", "RxRelay", 32 | ] 33 | ), 34 | .testTarget( 35 | name: "ComposableArchitectureTests", 36 | dependencies: [ 37 | "ComposableArchitecture", "RxTest", 38 | ] 39 | ), 40 | .target( 41 | name: "ComposableCoreLocation", 42 | dependencies: [ 43 | "ComposableArchitecture" 44 | ] 45 | ), 46 | .testTarget( 47 | name: "ComposableCoreLocationTests", 48 | dependencies: [ 49 | "ComposableCoreLocation" 50 | ] 51 | ), 52 | ] 53 | ) 54 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/MemoryManagementTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import RxSwift 3 | import RxTest 4 | import XCTest 5 | 6 | final class MemoryManagementTests: XCTestCase { 7 | var disposeBag = DisposeBag() 8 | 9 | func testOwnership_ScopeHoldsOntoParent() { 10 | let counterReducer = Reducer { state, _, _ in 11 | state += 1 12 | return .none 13 | } 14 | let store = Store(initialState: 0, reducer: counterReducer, environment: ()) 15 | .scope(state: { "\($0)" }) 16 | .scope(state: { Int($0)! }) 17 | let viewStore = ViewStore(store) 18 | 19 | var count = 0 20 | viewStore.publisher 21 | .subscribe(onNext: { count = $0 }) 22 | .disposed(by: disposeBag) 23 | 24 | XCTAssertEqual(count, 0) 25 | viewStore.send(()) 26 | XCTAssertEqual(count, 1) 27 | } 28 | 29 | func testOwnership_ViewStoreHoldsOntoStore() { 30 | let counterReducer = Reducer { state, _, _ in 31 | state += 1 32 | return .none 33 | } 34 | let viewStore = ViewStore(Store(initialState: 0, reducer: counterReducer, environment: ())) 35 | 36 | var count = 0 37 | viewStore.publisher 38 | .subscribe(onNext: { count = $0 }) 39 | .disposed(by: disposeBag) 40 | 41 | XCTAssertEqual(count, 0) 42 | viewStore.send(()) 43 | XCTAssertEqual(count, 1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ComposableArchitecture.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'ComposableArchitecture' 3 | s.version = '0.8.0' 4 | s.summary = 'A RxSwift fork of The Composable Architecture.' 5 | 6 | s.description = <<-DESC 7 | Point-Free’s The Composable Architecture uses Apple's Combine framework as the basis of its Effect type. Unfortunately, Combine is only available on iOS 13 and macOS 10.15 and above. In order to be able to use it with earlier versions of the OSes, this fork has adapted The Composable Architecture to use RxSwift as the basis for the Effect type. Much of this work was also inspired by the wonderful ReactiveSwift port of this project as well. 8 | DESC 9 | 10 | s.homepage = 'https://github.com/dannyhertz/rxswift-composable-architecture' 11 | s.author = { 'Danny Hertz' => 'me@dannyhertz.com' } 12 | s.source = { :git => 'https://github.com/dannyhertz/rxswift-composable-architecture.git', :tag => s.version.to_s } 13 | s.license = { :type => 'MIT' } 14 | 15 | s.ios.deployment_target = '12.0' 16 | s.swift_version = '5.2' 17 | 18 | s.source_files = 'Sources/ComposableArchitecture/**/*.swift' 19 | 20 | s.dependency 'CasePaths' 21 | s.dependency 'Overture' 22 | s.dependency 'RxSwift', '~> 5.1.1' 23 | s.dependency 'RxRelay' 24 | s.xcconfig = { 'ENABLE_BITCODE' => 'NO' } 25 | end 26 | 27 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Models/Beacon.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | /// A value type wrapper for `CLBeacon`. This type is necessary so that we can do equality checks 4 | /// and write tests against its values. 5 | @available(macOS, unavailable) 6 | @available(tvOS, unavailable) 7 | @available(watchOS, unavailable) 8 | public struct Beacon: Equatable { 9 | public let rawValue: CLBeacon? 10 | 11 | public var accuracy: CLLocationAccuracy 12 | public var major: NSNumber 13 | public var minor: NSNumber 14 | public var proximity: CLProximity 15 | public var rssi: Int 16 | 17 | public init(rawValue: CLBeacon) { 18 | self.rawValue = rawValue 19 | 20 | self.accuracy = rawValue.accuracy 21 | self.major = rawValue.major 22 | self.minor = rawValue.minor 23 | self.proximity = rawValue.proximity 24 | self.rssi = rawValue.rssi 25 | } 26 | 27 | init( 28 | accuracy: CLLocationAccuracy, 29 | major: NSNumber, 30 | minor: NSNumber, 31 | proximity: CLProximity, 32 | rssi: Int, 33 | timestamp: Date, 34 | uuid: UUID 35 | ) { 36 | self.rawValue = nil 37 | self.accuracy = accuracy 38 | self.major = major 39 | self.minor = minor 40 | self.proximity = proximity 41 | self.rssi = rssi 42 | } 43 | 44 | public static func == (lhs: Self, rhs: Self) -> Bool { 45 | return lhs.accuracy == rhs.accuracy 46 | && lhs.major == rhs.major 47 | && lhs.minor == rhs.minor 48 | && lhs.proximity == rhs.proximity 49 | && lhs.rssi == rhs.rssi 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Effects/Debouncing.swift: -------------------------------------------------------------------------------- 1 | import Dispatch 2 | import RxSwift 3 | 4 | extension Effect { 5 | /// Turns an effect into one that can be debounced. 6 | /// 7 | /// To turn an effect into a debounce-able one you must provide an identifier, which is used to 8 | /// determine which in-flight effect should be canceled in order to start a new effect. Any 9 | /// hashable value can be used for the identifier, such as a string, but you can add a bit of 10 | /// protection against typos by defining a new type that conforms to `Hashable`, such as an empty 11 | /// struct: 12 | /// 13 | /// case let .textChanged(text): 14 | /// struct SearchId: Hashable {} 15 | /// 16 | /// return environment.search(text) 17 | /// .map(Action.searchResponse) 18 | /// .debounce(id: SearchId(), for: 0.5, scheduler: environment.mainQueue) 19 | /// 20 | /// - Parameters: 21 | /// - id: The effect's identifier. 22 | /// - dueTime: The duration you want to debounce for. 23 | /// - scheduler: The scheduler you want to deliver the debounced output to. 24 | /// - options: Scheduler options that customize the effect's delivery of elements. 25 | /// - Returns: An effect that publishes events only after a specified time elapses. 26 | public func debounce( 27 | id: AnyHashable, 28 | for dueTime: RxTimeInterval, 29 | scheduler: SchedulerType 30 | ) -> Effect { 31 | Observable.just(()) 32 | .delay(dueTime, scheduler: scheduler) 33 | .flatMap { self } 34 | .eraseToEffect() 35 | .cancellable(id: id, cancelInFlight: true) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Models/Visit.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | /// A value type wrapper for `CLVisit`. This type is necessary so that we can do equality checks 4 | /// and write tests against its values. 5 | @available(iOS 8, macCatalyst 13, *) 6 | @available(macOS, unavailable) 7 | @available(tvOS, unavailable) 8 | @available(watchOS, unavailable) 9 | public struct Visit: Equatable { 10 | public let rawValue: CLVisit? 11 | 12 | public var arrivalDate: Date 13 | public var coordinate: CLLocationCoordinate2D 14 | public var departureDate: Date 15 | public var horizontalAccuracy: CLLocationAccuracy 16 | 17 | init(visit: CLVisit) { 18 | self.rawValue = nil 19 | 20 | self.arrivalDate = visit.arrivalDate 21 | self.coordinate = visit.coordinate 22 | self.departureDate = visit.departureDate 23 | self.horizontalAccuracy = visit.horizontalAccuracy 24 | } 25 | 26 | public init( 27 | arrivalDate: Date, 28 | coordinate: CLLocationCoordinate2D, 29 | departureDate: Date, 30 | horizontalAccuracy: CLLocationAccuracy 31 | ) { 32 | self.rawValue = nil 33 | 34 | self.arrivalDate = arrivalDate 35 | self.coordinate = coordinate 36 | self.departureDate = departureDate 37 | self.horizontalAccuracy = horizontalAccuracy 38 | } 39 | 40 | public static func == (lhs: Self, rhs: Self) -> Bool { 41 | lhs.arrivalDate == rhs.arrivalDate 42 | && lhs.coordinate.latitude == rhs.coordinate.latitude 43 | && lhs.coordinate.longitude == rhs.coordinate.longitude 44 | && lhs.departureDate == rhs.departureDate 45 | && lhs.horizontalAccuracy == rhs.horizontalAccuracy 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Effects/Timer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | extension Effect where Output: RxAbstractInteger { 5 | /// Returns an effect that repeatedly emits the current time of the given 6 | /// scheduler on the given interval. 7 | /// 8 | /// This effect serves as a testable alternative to `Timer.publish`, which 9 | /// performs its work on a run loop, _not_ a scheduler. 10 | /// 11 | /// struct TimerId: Hashable {} 12 | /// 13 | /// switch action { 14 | /// case .startTimer: 15 | /// return Effect.timer(id: TimerId(), every: 1, on: environment.scheduler) 16 | /// .map { .timerUpdated($0) } 17 | /// case let .timerUpdated(date): 18 | /// state.date = date 19 | /// return .none 20 | /// case .stopTimer: 21 | /// return .cancel(id: TimerId()) 22 | /// 23 | /// - Parameters: 24 | /// - interval: The time interval on which to publish events. For example, a value of `0.5` 25 | /// publishes an event approximately every half-second. 26 | /// - scheduler: The scheduler on which the timer runs. 27 | /// - tolerance: The allowed timing variance when emitting events. Defaults to `nil`, which 28 | /// allows any variance. 29 | /// - options: Scheduler options passed to the timer. Defaults to `nil`. 30 | public static func timer( 31 | id: AnyHashable, 32 | every interval: RxTimeInterval, 33 | on scheduler: SchedulerType 34 | ) -> Effect { 35 | 36 | return 37 | Observable 38 | .interval(interval, scheduler: scheduler) 39 | .eraseToEffect() 40 | .cancellable(id: id) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/UIKit/Alert+UIKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Danny Hertz on 7/15/20. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import Foundation 10 | import UIKit 11 | 12 | extension AlertState.Button { 13 | func toUIKit(send: @escaping (Action) -> Void) -> UIAlertAction { 14 | let action = { if let action = self.action { send(action) } } 15 | switch self.type { 16 | case let .cancel(.some(label)): 17 | return .init(title: label, style: .cancel, handler: { _ in action() }) 18 | case .cancel(.none): 19 | return .init(title: nil, style: .cancel, handler: { _ in action() }) 20 | case let .default(label): 21 | return .init(title: label, style: .default, handler: { _ in action() }) 22 | case let .destructive(label): 23 | return .init(title: label, style: .destructive, handler: { _ in action() }) 24 | } 25 | } 26 | } 27 | 28 | extension AlertState { 29 | func toUIKit(send: @escaping (Action) -> Void) -> UIAlertController { 30 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 31 | 32 | if let primaryButton = primaryButton { 33 | alert.addAction(primaryButton.toUIKit(send: send)) 34 | } 35 | if let secondaryButton = secondaryButton { 36 | alert.addAction(secondaryButton.toUIKit(send: send)) 37 | } 38 | 39 | return alert 40 | } 41 | } 42 | 43 | extension UIAlertController { 44 | public static func alert(_ alert: AlertState, send: @escaping (Action) -> Void) 45 | -> UIAlertController 46 | { 47 | return alert.toUIKit(send: send) 48 | } 49 | } 50 | #endif 51 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/Internal/IfLetStoreController.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import RxCocoa 3 | import ComposableArchitecture 4 | import UIKit 5 | 6 | final class IfLetStoreController: UIViewController { 7 | let store: Store 8 | let ifDestination: (Store) -> UIViewController 9 | let elseDestination: () -> UIViewController 10 | 11 | private var disposeBag = DisposeBag() 12 | private var viewController = UIViewController() { 13 | willSet { 14 | self.viewController.willMove(toParent: nil) 15 | self.viewController.view.removeFromSuperview() 16 | self.viewController.removeFromParent() 17 | self.addChild(newValue) 18 | self.view.addSubview(newValue.view) 19 | newValue.didMove(toParent: self) 20 | } 21 | } 22 | 23 | init( 24 | store: Store, 25 | then ifDestination: @escaping (Store) -> UIViewController, 26 | else elseDestination: @autoclosure @escaping () -> UIViewController 27 | ) { 28 | self.store = store 29 | self.ifDestination = ifDestination 30 | self.elseDestination = elseDestination 31 | super.init(nibName: nil, bundle: nil) 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | 41 | self.store.ifLet( 42 | then: { [weak self] store in 43 | guard let self = self else { return } 44 | self.viewController = self.ifDestination(store) 45 | }, 46 | else: { [weak self] in 47 | guard let self = self else { return } 48 | self.viewController = self.elseDestination() 49 | } 50 | ) 51 | .disposed(by: disposeBag) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/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 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Models/Heading.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | /// A value type wrapper for `CLHeading`. This type is necessary so that we can do equality checks 4 | /// and write tests against its values. 5 | @available(iOS 3, macCatalyst 13, watchOS 2, *) 6 | @available(macOS, unavailable) 7 | @available(tvOS, unavailable) 8 | public struct Heading: Equatable { 9 | public let rawValue: CLHeading? 10 | 11 | public var headingAccuracy: CLLocationDirection 12 | public var magneticHeading: CLLocationDirection 13 | public var timestamp: Date 14 | public var trueHeading: CLLocationDirection 15 | public var x: CLHeadingComponentValue 16 | public var y: CLHeadingComponentValue 17 | public var z: CLHeadingComponentValue 18 | 19 | init(rawValue: CLHeading) { 20 | self.rawValue = rawValue 21 | 22 | self.headingAccuracy = rawValue.headingAccuracy 23 | self.magneticHeading = rawValue.magneticHeading 24 | self.timestamp = rawValue.timestamp 25 | self.trueHeading = rawValue.trueHeading 26 | self.x = rawValue.x 27 | self.y = rawValue.y 28 | self.z = rawValue.z 29 | } 30 | 31 | init( 32 | headingAccuracy: CLLocationDirection, 33 | magneticHeading: CLLocationDirection, 34 | timestamp: Date, 35 | trueHeading: CLLocationDirection, 36 | x: CLHeadingComponentValue, 37 | y: CLHeadingComponentValue, 38 | z: CLHeadingComponentValue 39 | ) { 40 | self.rawValue = nil 41 | 42 | self.headingAccuracy = headingAccuracy 43 | self.magneticHeading = magneticHeading 44 | self.timestamp = timestamp 45 | self.trueHeading = trueHeading 46 | self.x = x 47 | self.y = y 48 | self.z = z 49 | } 50 | 51 | public static func == (lhs: Self, rhs: Self) -> Bool { 52 | lhs.headingAccuracy == rhs.headingAccuracy 53 | && lhs.magneticHeading == rhs.magneticHeading 54 | && lhs.timestamp == rhs.timestamp 55 | && lhs.trueHeading == rhs.trueHeading 56 | && lhs.x == rhs.x 57 | && lhs.y == rhs.y 58 | && lhs.z == rhs.z 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/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 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/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 | "filename" : "AppIcon.png", 40 | "idiom" : "iphone", 41 | "scale" : "3x", 42 | "size" : "60x60" 43 | }, 44 | { 45 | "idiom" : "ipad", 46 | "scale" : "1x", 47 | "size" : "20x20" 48 | }, 49 | { 50 | "idiom" : "ipad", 51 | "scale" : "2x", 52 | "size" : "20x20" 53 | }, 54 | { 55 | "idiom" : "ipad", 56 | "scale" : "1x", 57 | "size" : "29x29" 58 | }, 59 | { 60 | "idiom" : "ipad", 61 | "scale" : "2x", 62 | "size" : "29x29" 63 | }, 64 | { 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "40x40" 68 | }, 69 | { 70 | "idiom" : "ipad", 71 | "scale" : "2x", 72 | "size" : "40x40" 73 | }, 74 | { 75 | "idiom" : "ipad", 76 | "scale" : "1x", 77 | "size" : "76x76" 78 | }, 79 | { 80 | "idiom" : "ipad", 81 | "scale" : "2x", 82 | "size" : "76x76" 83 | }, 84 | { 85 | "idiom" : "ipad", 86 | "scale" : "2x", 87 | "size" : "83.5x83.5" 88 | }, 89 | { 90 | "idiom" : "ios-marketing", 91 | "scale" : "1x", 92 | "size" : "1024x1024" 93 | } 94 | ], 95 | "info" : { 96 | "author" : "xcode", 97 | "version" : 1 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import ComposableArchitecture 3 | import UIKit 4 | import RxCocoa 5 | 6 | struct CounterListState: Equatable { 7 | var counters: [CounterState] = [] 8 | } 9 | 10 | enum CounterListAction: Equatable { 11 | case counter(index: Int, action: CounterAction) 12 | } 13 | 14 | struct CounterListEnvironment {} 15 | 16 | let counterListReducer: Reducer = 17 | counterReducer.forEach( 18 | state: \CounterListState.counters, 19 | action: /CounterListAction.counter(index:action:), 20 | environment: { _ in CounterEnvironment() } 21 | ) 22 | 23 | let cellIdentifier = "Cell" 24 | 25 | final class CountersTableViewController: UITableViewController { 26 | let store: Store 27 | let viewStore: ViewStore 28 | var disposeBag = DisposeBag() 29 | 30 | var dataSource: [CounterState] = [] { 31 | didSet { self.tableView.reloadData() } 32 | } 33 | 34 | init(store: Store) { 35 | self.store = store 36 | self.viewStore = ViewStore(store) 37 | super.init(nibName: nil, bundle: nil) 38 | } 39 | 40 | required init?(coder: NSCoder) { 41 | fatalError("init(coder:) has not been implemented") 42 | } 43 | 44 | override func viewDidLoad() { 45 | super.viewDidLoad() 46 | 47 | self.title = "Lists" 48 | 49 | self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier) 50 | 51 | self.viewStore.publisher.counters 52 | .subscribe(onNext: { [weak self] in self?.dataSource = $0 }) 53 | .disposed(by: disposeBag) 54 | } 55 | 56 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 57 | self.dataSource.count 58 | } 59 | 60 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 61 | -> UITableViewCell 62 | { 63 | let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) 64 | cell.accessoryType = .disclosureIndicator 65 | cell.textLabel?.text = "\(self.dataSource[indexPath.row].count)" 66 | return cell 67 | } 68 | 69 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 70 | self.navigationController?.pushViewController( 71 | CounterViewController( 72 | store: self.store.scope( 73 | state: { $0.counters[indexPath.row] }, 74 | action: { .counter(index: indexPath.row, action: $0) } 75 | ) 76 | ), 77 | animated: true 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/EffectDebounceTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import RxSwift 3 | import RxTest 4 | import XCTest 5 | 6 | final class EffectDebounceTests: XCTestCase { 7 | var disposeBag = DisposeBag() 8 | 9 | func testDebounce() { 10 | let scheduler = TestScheduler.default() 11 | var values: [Int] = [] 12 | 13 | func runDebouncedEffect(value: Int) { 14 | struct CancelToken: Hashable {} 15 | Observable.just(value) 16 | .eraseToEffect() 17 | .debounce(id: CancelToken(), for: .seconds(1), scheduler: scheduler) 18 | .subscribe(onNext: { values.append($0) }) 19 | .disposed(by: disposeBag) 20 | } 21 | 22 | runDebouncedEffect(value: 1) 23 | 24 | // Nothing emits right away. 25 | XCTAssertEqual(values, []) 26 | 27 | // Waiting half the time also emits nothing 28 | scheduler.advance(by: 0.5) 29 | XCTAssertEqual(values, []) 30 | 31 | // Run another debounced effect. 32 | runDebouncedEffect(value: 2) 33 | 34 | // Waiting half the time emits nothing because the first debounced effect has been canceled. 35 | scheduler.advance(by: 0.5) 36 | XCTAssertEqual(values, []) 37 | 38 | // Run another debounced effect. 39 | runDebouncedEffect(value: 3) 40 | 41 | // Waiting half the time emits nothing because the second debounced effect has been canceled. 42 | scheduler.advance(by: 0.5) 43 | XCTAssertEqual(values, []) 44 | 45 | // Waiting the rest of the time emits the final effect value. 46 | scheduler.advance(by: 0.5) 47 | XCTAssertEqual(values, [3]) 48 | 49 | // Running out the scheduler 50 | scheduler.run() 51 | XCTAssertEqual(values, [3]) 52 | } 53 | 54 | func testDebounceIsLazy() { 55 | let scheduler = TestScheduler.default() 56 | var values: [Int] = [] 57 | var effectRuns = 0 58 | 59 | func runDebouncedEffect(value: Int) { 60 | struct CancelToken: Hashable {} 61 | 62 | Observable.deferred { () -> Observable in 63 | effectRuns += 1 64 | return Observable.just(value) 65 | } 66 | .eraseToEffect() 67 | .debounce(id: CancelToken(), for: .seconds(1), scheduler: scheduler) 68 | .subscribe(onNext: { values.append($0) }) 69 | .disposed(by: disposeBag) 70 | } 71 | 72 | runDebouncedEffect(value: 1) 73 | 74 | XCTAssertEqual(values, []) 75 | XCTAssertEqual(effectRuns, 0) 76 | 77 | scheduler.advance(by: 0.5) 78 | 79 | XCTAssertEqual(values, []) 80 | XCTAssertEqual(effectRuns, 0) 81 | 82 | scheduler.advance(by: 0.5) 83 | 84 | XCTAssertEqual(values, [1]) 85 | XCTAssertEqual(effectRuns, 1) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposableCoreLocation.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import ComposableArchitecture 3 | import UIKit 4 | import RxCocoa 5 | 6 | struct CounterState: Equatable { 7 | var count = 0 8 | } 9 | 10 | enum CounterAction: Equatable { 11 | case decrementButtonTapped 12 | case incrementButtonTapped 13 | } 14 | 15 | struct CounterEnvironment {} 16 | 17 | let counterReducer = Reducer { state, action, _ in 18 | switch action { 19 | case .decrementButtonTapped: 20 | state.count -= 1 21 | return .none 22 | case .incrementButtonTapped: 23 | state.count += 1 24 | return .none 25 | } 26 | } 27 | 28 | final class CounterViewController: UIViewController { 29 | let viewStore: ViewStore 30 | var disposeBag = DisposeBag() 31 | 32 | init(store: Store) { 33 | self.viewStore = ViewStore(store) 34 | super.init(nibName: nil, bundle: nil) 35 | } 36 | 37 | required init?(coder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | override func viewDidLoad() { 42 | super.viewDidLoad() 43 | 44 | self.view.backgroundColor = .white 45 | 46 | let decrementButton = UIButton(type: .system) 47 | decrementButton.addTarget(self, action: #selector(decrementButtonTapped), for: .touchUpInside) 48 | decrementButton.setTitle("−", for: .normal) 49 | 50 | let countLabel = UILabel() 51 | countLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular) 52 | 53 | let incrementButton = UIButton(type: .system) 54 | incrementButton.addTarget(self, action: #selector(incrementButtonTapped), for: .touchUpInside) 55 | incrementButton.setTitle("+", for: .normal) 56 | 57 | let rootStackView = UIStackView(arrangedSubviews: [ 58 | decrementButton, 59 | countLabel, 60 | incrementButton, 61 | ]) 62 | rootStackView.translatesAutoresizingMaskIntoConstraints = false 63 | self.view.addSubview(rootStackView) 64 | 65 | NSLayoutConstraint.activate([ 66 | rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), 67 | rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), 68 | ]) 69 | 70 | self.viewStore.publisher 71 | .map { "\($0.count)" } 72 | .bind(to: countLabel.rx.text) 73 | .disposed(by: disposeBag) 74 | 75 | // .assign(to: \.text, on: countLabel) 76 | // .store(in: &self.cancellables) 77 | } 78 | 79 | @objc func decrementButtonTapped() { 80 | self.viewStore.send(.decrementButtonTapped) 81 | } 82 | 83 | @objc func incrementButtonTapped() { 84 | self.viewStore.send(.incrementButtonTapped) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import UIKit 3 | import RxSwift 4 | 5 | struct CaseStudy { 6 | let title: String 7 | let viewController: () -> UIViewController 8 | 9 | init(title: String, viewController: @autoclosure @escaping () -> UIViewController) { 10 | self.title = title 11 | self.viewController = viewController 12 | } 13 | } 14 | 15 | let dataSource: [CaseStudy] = [ 16 | CaseStudy( 17 | title: "Basics", 18 | viewController: CounterViewController( 19 | store: Store( 20 | initialState: CounterState(), 21 | reducer: counterReducer, 22 | environment: CounterEnvironment() 23 | ) 24 | ) 25 | ), 26 | CaseStudy( 27 | title: "Lists", 28 | viewController: CountersTableViewController( 29 | store: Store( 30 | initialState: CounterListState( 31 | counters: [ 32 | CounterState(), 33 | CounterState(), 34 | CounterState(), 35 | ] 36 | ), 37 | reducer: counterListReducer, 38 | environment: CounterListEnvironment() 39 | ) 40 | ) 41 | ), 42 | CaseStudy( 43 | title: "Navigate and load", 44 | viewController: EagerNavigationViewController( 45 | store: Store( 46 | initialState: EagerNavigationState(), 47 | reducer: eagerNavigationReducer, 48 | environment: EagerNavigationEnvironment( 49 | mainQueue: MainScheduler.instance 50 | ) 51 | ) 52 | ) 53 | ), 54 | CaseStudy( 55 | title: "Load then navigate", 56 | viewController: LazyNavigationViewController( 57 | store: Store( 58 | initialState: LazyNavigationState(), 59 | reducer: lazyNavigationReducer, 60 | environment: LazyNavigationEnvironment( 61 | mainQueue: MainScheduler.instance 62 | ) 63 | ) 64 | ) 65 | ), 66 | ] 67 | 68 | final class RootViewController: UITableViewController { 69 | override func viewDidLoad() { 70 | super.viewDidLoad() 71 | 72 | self.title = "Case Studies" 73 | self.navigationController?.navigationBar.prefersLargeTitles = true 74 | } 75 | 76 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 77 | dataSource.count 78 | } 79 | 80 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 81 | -> UITableViewCell 82 | { 83 | let caseStudy = dataSource[indexPath.row] 84 | let cell = UITableViewCell() 85 | cell.accessoryType = .disclosureIndicator 86 | cell.textLabel?.text = caseStudy.title 87 | return cell 88 | } 89 | 90 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 91 | let caseStudy = dataSource[indexPath.row] 92 | self.navigationController?.pushViewController(caseStudy.viewController(), animated: true) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Internal/Throttling.swift: -------------------------------------------------------------------------------- 1 | import Dispatch 2 | import Foundation 3 | import RxSwift 4 | 5 | extension Effect { 6 | /// Turns an effect into one that can be throttled. 7 | /// 8 | /// - Parameters: 9 | /// - id: The effect's identifier. 10 | /// - interval: The interval at which to find and emit the most recent element, expressed in 11 | /// the time system of the scheduler. 12 | /// - scheduler: The scheduler you want to deliver the throttled output to. 13 | /// - latest: A boolean value that indicates whether to publish the most recent element. If 14 | /// `false`, the publisher emits the first element received during the interval. 15 | /// - Returns: An effect that emits either the most-recent or first element received during the 16 | /// specified interval. 17 | func throttle( 18 | id: AnyHashable, 19 | for interval: RxTimeInterval, 20 | scheduler: SchedulerType, 21 | latest: Bool 22 | ) -> Effect { 23 | self.flatMap { value -> Observable in 24 | guard let throttleTime = throttleTimes[id] as! RxTime? else { 25 | throttleTimes[id] = scheduler.now 26 | throttleValues[id] = nil 27 | return Observable.just(value) 28 | } 29 | 30 | guard 31 | scheduler.now.timeIntervalSince1970 - throttleTime.timeIntervalSince1970 32 | < interval.timeInterval 33 | else { 34 | throttleTimes[id] = scheduler.now 35 | throttleValues[id] = nil 36 | return Observable.just(value) 37 | } 38 | 39 | let value = latest ? value : (throttleValues[id] as! Output? ?? value) 40 | throttleValues[id] = value 41 | 42 | return Observable.just(value) 43 | .delay( 44 | .seconds( 45 | throttleTime.addingTimeInterval(interval.timeInterval).timeIntervalSince1970 46 | - scheduler.now.timeIntervalSince1970), scheduler: scheduler) 47 | } 48 | .eraseToEffect() 49 | .cancellable(id: id, cancelInFlight: true) 50 | } 51 | } 52 | 53 | var throttleTimes: [AnyHashable: Any] = [:] 54 | var throttleValues: [AnyHashable: Any] = [:] 55 | 56 | extension DispatchTimeInterval { 57 | var timeInterval: TimeInterval { 58 | switch self { 59 | case let .seconds(s): 60 | return TimeInterval(s) 61 | case let .milliseconds(ms): 62 | return TimeInterval(TimeInterval(ms) / 1000.0) 63 | case let .microseconds(us): 64 | return TimeInterval(Int64(us) * Int64(NSEC_PER_USEC)) / TimeInterval(NSEC_PER_SEC) 65 | case let .nanoseconds(ns): 66 | return TimeInterval(ns) / TimeInterval(NSEC_PER_SEC) 67 | case .never: 68 | return .infinity 69 | @unknown default: 70 | fatalError() 71 | } 72 | } 73 | 74 | static func seconds(_ interval: TimeInterval) -> DispatchTimeInterval { 75 | let delay = Double(NSEC_PER_SEC) * interval 76 | return DispatchTimeInterval.nanoseconds(Int(delay)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Internal/Diff.swift: -------------------------------------------------------------------------------- 1 | func diff(_ first: String, _ second: String) -> String? { 2 | struct Difference { 3 | enum Which { 4 | case both 5 | case first 6 | case second 7 | 8 | var prefix: StaticString { 9 | switch self { 10 | case .both: return "\u{2007}" 11 | case .first: return "−" 12 | case .second: return "+" 13 | } 14 | } 15 | } 16 | 17 | let elements: ArraySlice 18 | let which: Which 19 | } 20 | 21 | func diffHelp(_ first: ArraySlice, _ second: ArraySlice) -> [Difference] { 22 | var indicesForLine: [Substring: [Int]] = [:] 23 | for (firstIndex, firstLine) in zip(first.indices, first) { 24 | indicesForLine[firstLine, default: []].append(firstIndex) 25 | } 26 | 27 | var overlap: [Int: Int] = [:] 28 | var firstIndex = first.startIndex 29 | var secondIndex = second.startIndex 30 | var count = 0 31 | 32 | for (index, secondLine) in zip(second.indices, second) { 33 | var innerOverlap: [Int: Int] = [:] 34 | var innerFirstIndex = firstIndex 35 | var innerSecondIndex = secondIndex 36 | var innerCount = count 37 | 38 | indicesForLine[secondLine]?.forEach { firstIndex in 39 | let newCount = (overlap[firstIndex - 1] ?? 0) + 1 40 | innerOverlap[firstIndex] = newCount 41 | if newCount > count { 42 | innerFirstIndex = firstIndex - newCount + 1 43 | innerSecondIndex = index - newCount + 1 44 | innerCount = newCount 45 | } 46 | } 47 | 48 | overlap = innerOverlap 49 | firstIndex = innerFirstIndex 50 | secondIndex = innerSecondIndex 51 | count = innerCount 52 | } 53 | 54 | if count == 0 { 55 | var differences: [Difference] = [] 56 | if !first.isEmpty { differences.append(Difference(elements: first, which: .first)) } 57 | if !second.isEmpty { differences.append(Difference(elements: second, which: .second)) } 58 | return differences 59 | } else { 60 | var differences = diffHelp(first.prefix(upTo: firstIndex), second.prefix(upTo: secondIndex)) 61 | differences.append( 62 | Difference(elements: first.suffix(from: firstIndex).prefix(count), which: .both)) 63 | differences.append( 64 | contentsOf: diffHelp( 65 | first.suffix(from: firstIndex + count), second.suffix(from: secondIndex + count))) 66 | return differences 67 | } 68 | } 69 | 70 | let differences = diffHelp( 71 | first.split(separator: "\n", omittingEmptySubsequences: false)[...], 72 | second.split(separator: "\n", omittingEmptySubsequences: false)[...] 73 | ) 74 | if differences.count == 1, case .both = differences[0].which { return nil } 75 | var string = differences.reduce(into: "") { string, diff in 76 | diff.elements.forEach { line in 77 | string += "\(diff.which.prefix) \(line)\n" 78 | } 79 | } 80 | string.removeLast() 81 | return string 82 | } 83 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | 3 | extension Store { 4 | /// Subscribes to updates when a store containing optional state goes from `nil` to non-`nil` or 5 | /// non-`nil` to `nil`. 6 | /// 7 | /// This is useful for handling navigation in UIKit. The state for a screen that you want to 8 | /// navigate to can be held as an optional value in the parent, and when that value switches 9 | /// from `nil` to non-`nil` you want to trigger a navigation and hand the detail view a `Store` 10 | /// whose domain has been scoped to just that feature: 11 | /// 12 | /// class MasterViewController: UIViewController { 13 | /// let store: Store 14 | /// var cancellables: Set = [] 15 | /// ... 16 | /// func viewDidLoad() { 17 | /// ... 18 | /// self.store 19 | /// .scope(state: \.optionalDetail, action: MasterAction.detail) 20 | /// .ifLet( 21 | /// then: { [weak self] detailStore in 22 | /// self?.navigationController?.pushViewController( 23 | /// DetailViewController(store: detailStore), 24 | /// animated: true 25 | /// ) 26 | /// }, 27 | /// else: { [weak self] in 28 | /// guard let self = self else { return } 29 | /// self.navigationController?.popToViewController(self, animated: true) 30 | /// } 31 | /// ) 32 | /// .store(in: &self.cancellables) 33 | /// } 34 | /// } 35 | /// 36 | /// - Parameters: 37 | /// - unwrap: A function that is called with a store of non-optional state whenever the store's 38 | /// optional state goes from `nil` to non-`nil`. 39 | /// - else: A function that is called whenever the store's optional state goes from non-`nil` to 40 | /// `nil`. 41 | /// - Returns: A cancellable associated with the underlying subscription. 42 | public func ifLet( 43 | then unwrap: @escaping (Store) -> Void, 44 | else: @escaping () -> Void 45 | ) -> Disposable where State == Wrapped? { 46 | 47 | let elseDisposable = 48 | self 49 | .scope( 50 | state: { state in 51 | state.distinctUntilChanged({ ($0 != nil) == ($1 != nil) }) 52 | } 53 | ) 54 | .subscribe(onNext: { store in 55 | if store.state == nil { `else`() } 56 | }) 57 | 58 | let unwrapDisposable = 59 | self 60 | .scope( 61 | state: { state in 62 | state.distinctUntilChanged({ ($0 != nil) == ($1 != nil) }) 63 | .compactMap { $0 } 64 | } 65 | ) 66 | .subscribe(onNext: unwrap) 67 | 68 | return CompositeDisposable(elseDisposable, unwrapDisposable) 69 | } 70 | 71 | /// An overload of `ifLet(then:else:)` for the times that you do not want to handle the `else` 72 | /// case. 73 | /// 74 | /// - Parameter unwrap: A function that is called with a store of non-optional state whenever the 75 | /// store's optional state goes from `nil` to non-`nil`. 76 | /// - Returns: A cancellable associated with the underlying subscription. 77 | public func ifLet( 78 | then unwrap: @escaping (Store) -> Void 79 | ) -> Disposable where State == Wrapped? { 80 | self.ifLet(then: unwrap, else: {}) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/TimerTests.swift: -------------------------------------------------------------------------------- 1 | //import RxSwift 2 | import ComposableArchitecture 3 | import RxSwift 4 | import RxTest 5 | import XCTest 6 | 7 | final class TimerTests: XCTestCase { 8 | var disposeBag = DisposeBag() 9 | 10 | func testTimer() { 11 | let scheduler = TestScheduler.default() 12 | 13 | var count = 0 14 | 15 | Effect.timer(id: 1, every: .seconds(1), on: scheduler) 16 | .subscribe(onNext: { _ in count += 1 }) 17 | .disposed(by: disposeBag) 18 | 19 | scheduler.advance(by: 1) 20 | XCTAssertEqual(count, 1) 21 | 22 | scheduler.advance(by: 1) 23 | XCTAssertEqual(count, 2) 24 | 25 | scheduler.advance(by: 1) 26 | XCTAssertEqual(count, 3) 27 | 28 | scheduler.advance(by: 3) 29 | XCTAssertEqual(count, 6) 30 | } 31 | 32 | func testInterleavingTimer() { 33 | let scheduler = TestScheduler.default() 34 | 35 | var count2 = 0 36 | var count3 = 0 37 | 38 | Effect.merge( 39 | Effect.timer(id: 1, every: .seconds(2), on: scheduler) 40 | .do(onNext: { _ in count2 += 1 }) 41 | .eraseToEffect(), 42 | Effect.timer(id: 2, every: .seconds(3), on: scheduler) 43 | .do(onNext: { _ in count3 += 1 }) 44 | .eraseToEffect() 45 | ) 46 | .subscribe(onNext: { _ in }) 47 | .disposed(by: disposeBag) 48 | 49 | scheduler.advance(by: 1) 50 | XCTAssertEqual(count2, 0) 51 | XCTAssertEqual(count3, 0) 52 | scheduler.advance(by: 1) 53 | XCTAssertEqual(count2, 1) 54 | XCTAssertEqual(count3, 0) 55 | scheduler.advance(by: 1) 56 | XCTAssertEqual(count2, 1) 57 | XCTAssertEqual(count3, 1) 58 | scheduler.advance(by: 1) 59 | XCTAssertEqual(count2, 2) 60 | XCTAssertEqual(count3, 1) 61 | } 62 | 63 | func testTimerCancellation() { 64 | let scheduler = TestScheduler.default() 65 | 66 | var count2 = 0 67 | var count3 = 0 68 | 69 | struct CancelToken: Hashable {} 70 | 71 | Effect.merge( 72 | Effect.timer(id: CancelToken(), every: .seconds(2), on: scheduler) 73 | .do(onNext: { _ in count2 += 1 }) 74 | .eraseToEffect(), 75 | Effect.timer(id: CancelToken(), every: .seconds(3), on: scheduler) 76 | .do(onNext: { _ in count3 += 1 }) 77 | .eraseToEffect(), 78 | Observable.just(()) 79 | .delay(.seconds(31), scheduler: scheduler) 80 | .flatMap { Effect.cancel(id: CancelToken()) } 81 | .eraseToEffect() 82 | ) 83 | .subscribe(onNext: { _ in }) 84 | .disposed(by: disposeBag) 85 | 86 | scheduler.advance(by: 1) 87 | 88 | XCTAssertEqual(count2, 0) 89 | XCTAssertEqual(count3, 0) 90 | 91 | scheduler.advance(by: 1) 92 | 93 | XCTAssertEqual(count2, 1) 94 | XCTAssertEqual(count3, 0) 95 | 96 | scheduler.advance(by: 1) 97 | 98 | XCTAssertEqual(count2, 1) 99 | XCTAssertEqual(count3, 1) 100 | 101 | scheduler.advance(by: 1) 102 | 103 | XCTAssertEqual(count2, 2) 104 | XCTAssertEqual(count3, 1) 105 | 106 | scheduler.run() 107 | 108 | XCTAssertEqual(count2, 15) 109 | XCTAssertEqual(count3, 10) 110 | } 111 | 112 | func testTimerCompletion() { 113 | let scheduler = TestScheduler.default() 114 | 115 | var count = 0 116 | 117 | Effect.timer(id: 1, every: .seconds(1), on: scheduler) 118 | .take(3) 119 | .subscribe(onNext: { _ in count += 1 }) 120 | .disposed(by: disposeBag) 121 | 122 | scheduler.advance(by: 1) 123 | XCTAssertEqual(count, 1) 124 | 125 | scheduler.advance(by: 1) 126 | XCTAssertEqual(count, 2) 127 | 128 | scheduler.advance(by: 1) 129 | XCTAssertEqual(count, 3) 130 | 131 | scheduler.run() 132 | XCTAssertEqual(count, 3) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import ComposableArchitecture 3 | import UIKit 4 | import RxCocoa 5 | 6 | struct EagerNavigationState: Equatable { 7 | var isNavigationActive = false 8 | var optionalCounter: CounterState? 9 | } 10 | 11 | enum EagerNavigationAction: Equatable { 12 | case optionalCounter(CounterAction) 13 | case setNavigation(isActive: Bool) 14 | case setNavigationIsActiveDelayCompleted 15 | } 16 | 17 | struct EagerNavigationEnvironment { 18 | var mainQueue: SchedulerType 19 | } 20 | 21 | let eagerNavigationReducer = counterReducer 22 | .optional() 23 | .pullback( 24 | state: \.optionalCounter, 25 | action: /EagerNavigationAction.optionalCounter, 26 | environment: { _ in CounterEnvironment() } 27 | ) 28 | .combined( 29 | with: Reducer< 30 | EagerNavigationState, EagerNavigationAction, EagerNavigationEnvironment 31 | > { state, action, environment in 32 | switch action { 33 | case .setNavigation(isActive: true): 34 | state.isNavigationActive = true 35 | return Effect(value: .setNavigationIsActiveDelayCompleted) 36 | .delay(.seconds(1), scheduler: environment.mainQueue) 37 | .eraseToEffect() 38 | case .setNavigation(isActive: false): 39 | state.isNavigationActive = false 40 | state.optionalCounter = nil 41 | return .none 42 | case .setNavigationIsActiveDelayCompleted: 43 | state.optionalCounter = CounterState() 44 | return .none 45 | case .optionalCounter: 46 | return .none 47 | } 48 | } 49 | ) 50 | 51 | class EagerNavigationViewController: UIViewController { 52 | var disposeBag = DisposeBag() 53 | let store: Store 54 | let viewStore: ViewStore 55 | 56 | init(store: Store) { 57 | self.store = store 58 | self.viewStore = ViewStore(store) 59 | super.init(nibName: nil, bundle: nil) 60 | } 61 | 62 | required init?(coder: NSCoder) { 63 | fatalError("init(coder:) has not been implemented") 64 | } 65 | 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | 69 | self.title = "Navigate and load" 70 | 71 | self.view.backgroundColor = .white 72 | 73 | let button = UIButton(type: .system) 74 | button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside) 75 | button.setTitle("Load optional counter", for: .normal) 76 | button.translatesAutoresizingMaskIntoConstraints = false 77 | self.view.addSubview(button) 78 | 79 | NSLayoutConstraint.activate([ 80 | button.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), 81 | button.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), 82 | ]) 83 | 84 | self.viewStore.publisher.isNavigationActive.subscribe(onNext: { [weak self] isNavigationActive in 85 | guard let self = self else { return } 86 | if isNavigationActive { 87 | self.navigationController?.pushViewController( 88 | IfLetStoreController( 89 | store: self.store 90 | .scope(state: { $0.optionalCounter }, action: EagerNavigationAction.optionalCounter), 91 | then: CounterViewController.init(store:), 92 | else: ActivityIndicatorViewController() 93 | ), 94 | animated: true 95 | ) 96 | } else { 97 | self.navigationController?.popToViewController(self, animated: true) 98 | } 99 | }) 100 | .disposed(by: disposeBag) 101 | } 102 | 103 | override func viewDidAppear(_ animated: Bool) { 104 | super.viewDidAppear(animated) 105 | 106 | if !self.isMovingToParent { 107 | self.viewStore.send(.setNavigation(isActive: false)) 108 | } 109 | } 110 | 111 | @objc private func loadOptionalCounterTapped() { 112 | self.viewStore.send(.setNavigation(isActive: true)) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/ComposableCoreLocationTests/ComposableCoreLocationTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableCoreLocation 2 | import XCTest 3 | 4 | class ComposableCoreLocationTests: XCTestCase { 5 | func testMockHasDefaultsForAllEndpoints() { 6 | _ = LocationManager.mock() 7 | } 8 | 9 | func testLocationEncodeDecode() { 10 | let value: Location 11 | 12 | #if compiler(>=5.2) 13 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 14 | value = Location( 15 | altitude: 50, 16 | coordinate: CLLocationCoordinate2D(latitude: 10, longitude: 20), 17 | course: 9, 18 | courseAccuracy: 1, 19 | horizontalAccuracy: 3, 20 | speed: 5, 21 | speedAccuracy: 2, 22 | timestamp: Date.init(timeIntervalSince1970: 0), 23 | verticalAccuracy: 6 24 | ) 25 | } else { 26 | value = Location( 27 | altitude: 50, 28 | coordinate: CLLocationCoordinate2D(latitude: 10, longitude: 20), 29 | course: 9, 30 | horizontalAccuracy: 3, 31 | speed: 5, 32 | timestamp: Date.init(timeIntervalSince1970: 0), 33 | verticalAccuracy: 6 34 | ) 35 | } 36 | #else 37 | value = Location( 38 | altitude: 50, 39 | coordinate: CLLocationCoordinate2D(latitude: 10, longitude: 20), 40 | course: 9, 41 | horizontalAccuracy: 3, 42 | speed: 5, 43 | timestamp: Date.init(timeIntervalSince1970: 0), 44 | verticalAccuracy: 6 45 | ) 46 | #endif 47 | 48 | let data = try? JSONEncoder().encode(value) 49 | let decoded = try? JSONDecoder().decode(Location.self, from: data ?? Data()) 50 | 51 | XCTAssertEqual(value, decoded) 52 | } 53 | 54 | func testLocationEquatable() { 55 | let a = Location( 56 | altitude: 1, 57 | coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 1), 58 | course: 1, 59 | horizontalAccuracy: 1, 60 | speed: 1, 61 | timestamp: Date.init(timeIntervalSince1970: 0), 62 | verticalAccuracy: 1 63 | ) 64 | 65 | let b = Location( 66 | altitude: 2, 67 | coordinate: CLLocationCoordinate2D(latitude: 2, longitude: 2), 68 | course: 2, 69 | horizontalAccuracy: 2, 70 | speed: 2, 71 | timestamp: Date.init(timeIntervalSince1970: 1), 72 | verticalAccuracy: 2 73 | ) 74 | 75 | XCTAssertTrue(a == a) 76 | XCTAssertFalse(a == b) 77 | } 78 | 79 | #if compiler(>=5.2) 80 | func testLocationEquatable_5_2() { 81 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 82 | let a = Location( 83 | altitude: 1, 84 | coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 1), 85 | course: 1, 86 | courseAccuracy: 1, 87 | horizontalAccuracy: 1, 88 | speed: 1, 89 | speedAccuracy: 1, 90 | timestamp: Date.init(timeIntervalSince1970: 0), 91 | verticalAccuracy: 1 92 | ) 93 | 94 | let b = Location( 95 | altitude: 1, 96 | coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 1), 97 | course: 1, 98 | courseAccuracy: 1, 99 | horizontalAccuracy: 1, 100 | speed: 1, 101 | speedAccuracy: 2, 102 | timestamp: Date.init(timeIntervalSince1970: 0), 103 | verticalAccuracy: 1 104 | ) 105 | 106 | let c = Location( 107 | altitude: 1, 108 | coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 1), 109 | course: 1, 110 | courseAccuracy: 2, 111 | horizontalAccuracy: 1, 112 | speed: 1, 113 | speedAccuracy: 1, 114 | timestamp: Date.init(timeIntervalSince1970: 0), 115 | verticalAccuracy: 1 116 | ) 117 | 118 | XCTAssertTrue(a == a) 119 | XCTAssertFalse(a == b) 120 | XCTAssertFalse(a == c) 121 | } 122 | } 123 | #endif 124 | } 125 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/Internal/EffectThrottleTests.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import RxTest 3 | import XCTest 4 | 5 | @testable import ComposableArchitecture 6 | 7 | final class EffectThrottleTests: XCTestCase { 8 | var disposeBag = DisposeBag() 9 | let scheduler = TestScheduler.default() 10 | 11 | func testThrottleLatest() { 12 | var values: [Int] = [] 13 | var effectRuns = 0 14 | 15 | func runThrottledEffect(value: Int) { 16 | struct CancelToken: Hashable {} 17 | 18 | Observable.deferred { () -> Observable in 19 | effectRuns += 1 20 | return Observable.just(value) 21 | } 22 | .eraseToEffect() 23 | .throttle(id: CancelToken(), for: .seconds(1), scheduler: scheduler, latest: true) 24 | .subscribe(onNext: { values.append($0) }) 25 | .disposed(by: disposeBag) 26 | } 27 | 28 | runThrottledEffect(value: 1) 29 | 30 | // A value emits right away. 31 | XCTAssertEqual(values, [1]) 32 | 33 | runThrottledEffect(value: 2) 34 | 35 | // A second value is throttled. 36 | XCTAssertEqual(values, [1]) 37 | 38 | scheduler.advance(by: 0.25) 39 | 40 | runThrottledEffect(value: 3) 41 | 42 | scheduler.advance(by: 0.25) 43 | 44 | runThrottledEffect(value: 4) 45 | 46 | scheduler.advance(by: 0.25) 47 | 48 | runThrottledEffect(value: 5) 49 | 50 | // A third value is throttled. 51 | XCTAssertEqual(values, [1]) 52 | 53 | scheduler.advance(by: 0.25) 54 | 55 | // The latest value emits. 56 | XCTAssertEqual(values, [1, 5]) 57 | } 58 | // 59 | // func testThrottleFirst() { 60 | // var values: [Int] = [] 61 | // var effectRuns = 0 62 | // 63 | // func runThrottledEffect(value: Int) { 64 | // struct CancelToken: Hashable {} 65 | // 66 | // Deferred { () -> Just in 67 | // effectRuns += 1 68 | // return Just(value) 69 | // } 70 | // .eraseToEffect() 71 | // .throttle( 72 | // id: CancelToken(), for: 1, scheduler: scheduler.eraseToAnyScheduler(), latest: false 73 | // ) 74 | // .sink { values.append($0) } 75 | // .store(in: &self.cancellables) 76 | // } 77 | // 78 | // runThrottledEffect(value: 1) 79 | // 80 | // // A value emits right away. 81 | // XCTAssertEqual(values, [1]) 82 | // 83 | // runThrottledEffect(value: 2) 84 | // 85 | // // A second value is throttled. 86 | // XCTAssertEqual(values, [1]) 87 | // 88 | // scheduler.advance(by: 0.25) 89 | // 90 | // runThrottledEffect(value: 3) 91 | // 92 | // scheduler.advance(by: 0.25) 93 | // 94 | // runThrottledEffect(value: 4) 95 | // 96 | // scheduler.advance(by: 0.25) 97 | // 98 | // runThrottledEffect(value: 5) 99 | // 100 | // // A third value is throttled. 101 | // XCTAssertEqual(values, [1]) 102 | // 103 | // scheduler.advance(by: 0.25) 104 | // 105 | // // The first throttled value emits. 106 | // XCTAssertEqual(values, [1, 2]) 107 | // } 108 | // 109 | // func testThrottleAfterInterval() { 110 | // var values: [Int] = [] 111 | // var effectRuns = 0 112 | // 113 | // func runThrottledEffect(value: Int) { 114 | // struct CancelToken: Hashable {} 115 | // 116 | // Deferred { () -> Just in 117 | // effectRuns += 1 118 | // return Just(value) 119 | // } 120 | // .eraseToEffect() 121 | // .throttle(id: CancelToken(), for: 1, scheduler: scheduler.eraseToAnyScheduler(), latest: true) 122 | // .sink { values.append($0) } 123 | // .store(in: &self.cancellables) 124 | // } 125 | // 126 | // runThrottledEffect(value: 1) 127 | // 128 | // // A value emits right away. 129 | // XCTAssertEqual(values, [1]) 130 | // 131 | // scheduler.advance(by: 2) 132 | // 133 | // runThrottledEffect(value: 2) 134 | // 135 | // // A second value is emitted right away. 136 | // XCTAssertEqual(values, [1, 2]) 137 | // } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/ViewStore.swift: -------------------------------------------------------------------------------- 1 | import RxRelay 2 | import RxSwift 3 | 4 | /// A `ViewStore` is an object that can observe state changes and send actions. They are most 5 | /// commonly used in views, such as SwiftUI views, UIView or UIViewController, but they can be 6 | /// used anywhere it makes sense to observe state and send actions. 7 | /// 8 | /// In SwiftUI applications, a `ViewStore` is accessed most commonly using the `WithViewStore` view. 9 | /// It can be initialized with a store and a closure that is handed a view store and must return a 10 | /// view to be rendered: 11 | /// 12 | /// var body: some View { 13 | /// WithViewStore(self.store) { viewStore in 14 | /// VStack { 15 | /// Text("Current count: \(viewStore.count)") 16 | /// Button("Increment") { viewStore.send(.incrementButtonTapped) } 17 | /// } 18 | /// } 19 | /// } 20 | /// 21 | /// In UIKit applications a `ViewStore` can be created from a `Store` and then subscribed to for 22 | /// state updates: 23 | /// 24 | /// let store: Store 25 | /// let viewStore: ViewStore 26 | /// 27 | /// init(store: Store) { 28 | /// self.store = store 29 | /// self.viewStore = ViewStore(store) 30 | /// } 31 | /// 32 | /// func viewDidLoad() { 33 | /// super.viewDidLoad() 34 | /// 35 | /// self.viewStore.publisher.count 36 | /// .sink { [weak self] in self?.countLabel.text = $0 } 37 | /// .store(in: &self.cancellables) 38 | /// } 39 | /// 40 | /// @objc func incrementButtonTapped() { 41 | /// self.viewStore.send(.incrementButtonTapped) 42 | /// } 43 | /// 44 | @dynamicMemberLookup 45 | public final class ViewStore { 46 | 47 | /// A publisher of state. 48 | public let publisher: StorePublisher 49 | private var viewDisposable: Disposable? 50 | 51 | deinit { 52 | viewDisposable?.dispose() 53 | } 54 | 55 | /// Initializes a view store from a store. 56 | /// 57 | /// - Parameters: 58 | /// - store: A store. 59 | /// - isDuplicate: A function to determine when two `State` values are equal. When values are 60 | /// equal, repeat view computations are removed. 61 | public init( 62 | _ store: Store, 63 | removeDuplicates isDuplicate: @escaping (State, State) -> Bool 64 | ) { 65 | let publisher = store.observable.distinctUntilChanged(isDuplicate) 66 | self.publisher = StorePublisher(publisher) 67 | self.stateRelay = BehaviorRelay(value: store.state) 68 | self._send = store.send 69 | self.viewDisposable = publisher.subscribe(onNext: { [weak self] in self?.state = $0 }) 70 | } 71 | 72 | /// The current state. 73 | private var stateRelay: BehaviorRelay 74 | public private(set) var state: State { 75 | get { return stateRelay.value } 76 | set { stateRelay.accept(newValue) } 77 | } 78 | var observable: Observable { 79 | return stateRelay.asObservable() 80 | } 81 | 82 | let _send: (Action) -> Void 83 | 84 | /// Returns the resulting value of a given key path. 85 | public subscript(dynamicMember keyPath: KeyPath) -> LocalState { 86 | self.state[keyPath: keyPath] 87 | } 88 | 89 | /// Sends an action to the store. 90 | /// 91 | /// `ViewStore` is not thread safe and you should only send actions to it from the main thread. 92 | /// If you are wanting to send actions on background threads due to the fact that the reducer 93 | /// is performing computationally expensive work, then a better way to handle this is to wrap 94 | /// that work in an `Effect` that is performed on a background thread so that the result can 95 | /// be fed back into the store. 96 | /// 97 | /// - Parameter action: An action. 98 | public func send(_ action: Action) { 99 | self._send(action) 100 | } 101 | 102 | } 103 | 104 | extension ViewStore where State: Equatable { 105 | public convenience init(_ store: Store) { 106 | self.init(store, removeDuplicates: ==) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Effects/Cancellation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class AnyDisposable: Disposable, Hashable { 5 | let _dispose: () -> Void 6 | 7 | init(_ disposable: Disposable) { 8 | _dispose = disposable.dispose 9 | } 10 | 11 | func dispose() { 12 | _dispose() 13 | } 14 | 15 | static func == (lhs: AnyDisposable, rhs: AnyDisposable) -> Bool { 16 | return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 17 | } 18 | 19 | func hash(into hasher: inout Hasher) { 20 | hasher.combine(ObjectIdentifier(self)) 21 | } 22 | } 23 | 24 | extension Effect { 25 | /// Turns an effect into one that is capable of being canceled. 26 | /// 27 | /// To turn an effect into a cancellable one you must provide an identifier, which is used in 28 | /// `Effect.cancel(id:)` to identify which in-flight effect should be canceled. Any hashable 29 | /// value can be used for the identifier, such as a string, but you can add a bit of protection 30 | /// against typos by defining a new type that conforms to `Hashable`, such as an empty struct: 31 | /// 32 | /// struct LoadUserId: Hashable {} 33 | /// 34 | /// case .reloadButtonTapped: 35 | /// // Start a new effect to load the user 36 | /// return environment.loadUser 37 | /// .map(Action.userResponse) 38 | /// .cancellable(id: LoadUserId(), cancelInFlight: true) 39 | /// 40 | /// case .cancelButtonTapped: 41 | /// // Cancel any in-flight requests to load the user 42 | /// return .cancel(id: LoadUserId()) 43 | /// 44 | /// - Parameters: 45 | /// - id: The effect's identifier. 46 | /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be 47 | /// canceled before starting this new one. 48 | /// - Returns: A new effect that is capable of being canceled by an identifier. 49 | public func cancellable(id: AnyHashable, cancelInFlight: Bool = false) -> Effect { 50 | let effect = Observable.deferred { 51 | cancellablesLock.lock() 52 | defer { cancellablesLock.unlock() } 53 | 54 | let subject = PublishSubject() 55 | var values: [Output] = [] 56 | var isCaching = true 57 | let disposable = 58 | self 59 | .do(onNext: { val in 60 | guard isCaching else { return } 61 | values.append(val) 62 | }) 63 | .subscribe(subject) 64 | 65 | var cancellationDisposable: AnyDisposable! 66 | cancellationDisposable = AnyDisposable( 67 | Disposables.create { 68 | cancellablesLock.sync { 69 | subject.onCompleted() 70 | disposable.dispose() 71 | cancellationCancellables[id]?.remove(cancellationDisposable) 72 | if cancellationCancellables[id]?.isEmpty == .some(true) { 73 | cancellationCancellables[id] = nil 74 | } 75 | } 76 | }) 77 | 78 | cancellationCancellables[id, default: []].insert( 79 | cancellationDisposable 80 | ) 81 | 82 | return Observable.from(values) 83 | .concat(subject) 84 | .do( 85 | onError: { _ in cancellationDisposable.dispose() }, 86 | onCompleted: cancellationDisposable.dispose, 87 | onSubscribed: { isCaching = false }, 88 | onDispose: cancellationDisposable.dispose 89 | ) 90 | } 91 | .eraseToEffect() 92 | 93 | return cancelInFlight ? .concatenate(.cancel(id: id), effect) : effect 94 | } 95 | 96 | /// An effect that will cancel any currently in-flight effect with the given identifier. 97 | /// 98 | /// - Parameter id: An effect identifier. 99 | /// - Returns: A new effect that will cancel any currently in-flight effect with the given 100 | /// identifier. 101 | public static func cancel(id: AnyHashable) -> Effect { 102 | return .fireAndForget { 103 | cancellablesLock.sync { 104 | cancellationCancellables[id]?.forEach { $0.dispose() } 105 | } 106 | } 107 | } 108 | } 109 | 110 | var cancellationCancellables: [AnyHashable: Set] = [:] 111 | let cancellablesLock = NSRecursiveLock() 112 | -------------------------------------------------------------------------------- /Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift: -------------------------------------------------------------------------------- 1 | import RxCocoa 2 | import RxSwift 3 | import ComposableArchitecture 4 | import UIKit 5 | 6 | struct LazyNavigationState: Equatable { 7 | var optionalCounter: CounterState? 8 | var isActivityIndicatorHidden = true 9 | } 10 | 11 | enum LazyNavigationAction: Equatable { 12 | case optionalCounter(CounterAction) 13 | case setNavigation(isActive: Bool) 14 | case setNavigationIsActiveDelayCompleted 15 | } 16 | 17 | struct LazyNavigationEnvironment { 18 | var mainQueue: SchedulerType 19 | } 20 | 21 | let lazyNavigationReducer = counterReducer 22 | .optional() 23 | .pullback( 24 | state: \.optionalCounter, 25 | action: /LazyNavigationAction.optionalCounter, 26 | environment: { _ in CounterEnvironment() } 27 | ) 28 | .combined( 29 | with: Reducer< 30 | LazyNavigationState, LazyNavigationAction, LazyNavigationEnvironment 31 | > { state, action, environment in 32 | switch action { 33 | case .setNavigation(isActive: true): 34 | state.isActivityIndicatorHidden = false 35 | return Effect(value: .setNavigationIsActiveDelayCompleted) 36 | .delay(.seconds(1), scheduler: environment.mainQueue) 37 | .eraseToEffect() 38 | case .setNavigation(isActive: false): 39 | state.optionalCounter = nil 40 | return .none 41 | case .setNavigationIsActiveDelayCompleted: 42 | state.isActivityIndicatorHidden = true 43 | state.optionalCounter = CounterState() 44 | return .none 45 | case .optionalCounter: 46 | return .none 47 | } 48 | } 49 | ) 50 | 51 | class LazyNavigationViewController: UIViewController { 52 | var disposeBag = DisposeBag() 53 | let store: Store 54 | let viewStore: ViewStore 55 | 56 | init(store: Store) { 57 | self.store = store 58 | self.viewStore = ViewStore(store) 59 | super.init(nibName: nil, bundle: nil) 60 | } 61 | 62 | required init?(coder: NSCoder) { 63 | fatalError("init(coder:) has not been implemented") 64 | } 65 | 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | 69 | self.title = "Load then navigate" 70 | 71 | self.view.backgroundColor = .white 72 | 73 | let button = UIButton(type: .system) 74 | button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside) 75 | button.setTitle("Load optional counter", for: .normal) 76 | 77 | let activityIndicator = UIActivityIndicatorView() 78 | activityIndicator.startAnimating() 79 | 80 | let rootStackView = UIStackView(arrangedSubviews: [ 81 | button, 82 | activityIndicator, 83 | ]) 84 | rootStackView.translatesAutoresizingMaskIntoConstraints = false 85 | self.view.addSubview(rootStackView) 86 | 87 | NSLayoutConstraint.activate([ 88 | rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), 89 | rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), 90 | ]) 91 | 92 | self.viewStore.publisher.isActivityIndicatorHidden 93 | .bind(to: activityIndicator.rx.isHidden) 94 | .disposed(by: disposeBag) 95 | 96 | self.store 97 | .scope(state: { $0.optionalCounter }, action: LazyNavigationAction.optionalCounter) 98 | .ifLet( 99 | then: { [weak self] store in 100 | self?.navigationController?.pushViewController( 101 | CounterViewController(store: store), animated: true) 102 | }, 103 | else: { [weak self] in 104 | guard let self = self else { return } 105 | self.navigationController?.popToViewController(self, animated: true) 106 | } 107 | ) 108 | .disposed(by: disposeBag) 109 | 110 | } 111 | 112 | override func viewDidAppear(_ animated: Bool) { 113 | super.viewDidAppear(animated) 114 | 115 | if !self.isMovingToParent { 116 | self.viewStore.send(.setNavigation(isActive: false)) 117 | } 118 | } 119 | 120 | @objc private func loadOptionalCounterTapped() { 121 | self.viewStore.send(.setNavigation(isActive: true)) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import os.signpost 3 | 4 | extension Reducer { 5 | /// Instruments the reducer with 6 | /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). 7 | /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its 8 | /// effects will be measured with interval and event signposts. 9 | /// 10 | /// To use, build your app for Instruments (⌘I), create a blank instrument, and then use the "+" 11 | /// icon at top right to add the signpost instrument. Start recording your app (red button at top 12 | /// left) and then you should see timing information for every action sent to the store and every 13 | /// effect executed. 14 | /// 15 | /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living 16 | /// effects. For example, if you start an effect (e.g. a location manager) in `onAppear` and 17 | /// forget to tear down the effect in `onDisappear`, it will clearly show in Instruments that the 18 | /// effect never completed. 19 | /// 20 | /// - Parameters: 21 | /// - prefix: A string to print at the beginning of the formatted message for the signpost. 22 | /// - log: An `OSLog` to use for signposts. 23 | /// - Returns: A reducer that has been enhanced with instrumentation. 24 | public func signpost( 25 | _ prefix: String = "", 26 | log: OSLog = OSLog( 27 | subsystem: "co.pointfree.composable-architecture", 28 | category: "Reducer Instrumentation" 29 | ) 30 | ) -> Self { 31 | guard log.signpostsEnabled else { return self } 32 | 33 | // NB: Prevent rendering as "N/A" in Instruments 34 | let zeroWidthSpace = "\u{200B}" 35 | 36 | let prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " 37 | 38 | return Self { state, action, environment in 39 | var actionOutput: String! 40 | if log.signpostsEnabled { 41 | actionOutput = debugCaseOutput(action) 42 | os_signpost(.begin, log: log, name: "Action", "%s%s", prefix, actionOutput) 43 | } 44 | let effects = self.run(&state, action, environment) 45 | if log.signpostsEnabled { 46 | os_signpost(.end, log: log, name: "Action") 47 | return 48 | effects 49 | .effectSignpost(prefix, log: log, actionOutput: actionOutput) 50 | .eraseToEffect() 51 | } 52 | return effects 53 | } 54 | } 55 | } 56 | 57 | extension ObservableType { 58 | func effectSignpost( 59 | _ prefix: String, 60 | log: OSLog, 61 | actionOutput: String 62 | ) -> Observable { 63 | let sid = OSSignpostID(log: log) 64 | 65 | return 66 | self 67 | .do( 68 | onNext: { _ in 69 | os_signpost( 70 | .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) 71 | }, 72 | onCompleted: { 73 | os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) 74 | }, 75 | onSubscribe: { 76 | os_signpost( 77 | .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, 78 | actionOutput) 79 | }, 80 | onDispose: { 81 | os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) 82 | } 83 | ) 84 | } 85 | } 86 | 87 | func debugCaseOutput(_ value: Any) -> String { 88 | func debugCaseOutputHelp(_ value: Any) -> String { 89 | let mirror = Mirror(reflecting: value) 90 | switch mirror.displayStyle { 91 | case .enum: 92 | guard let child = mirror.children.first else { 93 | let childOutput = "\(value)" 94 | return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" 95 | } 96 | let childOutput = debugCaseOutputHelp(child.value) 97 | return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" 98 | case .tuple: 99 | return mirror.children.map { label, value in 100 | let childOutput = debugCaseOutputHelp(value) 101 | return 102 | "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" 103 | } 104 | .joined(separator: ", ") 105 | default: 106 | return "" 107 | } 108 | } 109 | 110 | return "\(type(of: value))\(debugCaseOutputHelp(value))" 111 | } 112 | 113 | private func isUnlabeledArgument(_ label: String) -> Bool { 114 | label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil 115 | } 116 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import RxSwift 3 | import RxTest 4 | import XCTest 5 | 6 | final class ComposableArchitectureTests: XCTestCase { 7 | var disposeBag = DisposeBag() 8 | 9 | func testScheduling() { 10 | enum CounterAction: Equatable { 11 | case incrAndSquareLater 12 | case incrNow 13 | case squareNow 14 | } 15 | 16 | let counterReducer = Reducer { 17 | state, action, scheduler in 18 | switch action { 19 | case .incrAndSquareLater: 20 | return .merge( 21 | Effect(value: .incrNow) 22 | .delay(.seconds(2), scheduler: scheduler) 23 | .eraseToEffect(), 24 | Effect(value: .squareNow) 25 | .delay(.seconds(1), scheduler: scheduler) 26 | .eraseToEffect(), 27 | Effect(value: .squareNow) 28 | .delay(.seconds(2), scheduler: scheduler) 29 | .eraseToEffect() 30 | ) 31 | case .incrNow: 32 | state += 1 33 | return .none 34 | case .squareNow: 35 | state *= state 36 | return .none 37 | } 38 | } 39 | 40 | let scheduler = TestScheduler.default() 41 | 42 | let store = TestStore( 43 | initialState: 2, 44 | reducer: counterReducer, 45 | environment: scheduler 46 | ) 47 | 48 | store.assert( 49 | .send(.incrAndSquareLater), 50 | .do { scheduler.advance(by: 1) }, 51 | .receive(.squareNow) { $0 = 4 }, 52 | .do { scheduler.advance(by: 1) }, 53 | .receive(.incrNow) { $0 = 5 }, 54 | .receive(.squareNow) { $0 = 25 } 55 | ) 56 | 57 | store.assert( 58 | .send(.incrAndSquareLater), 59 | .do { scheduler.advance(by: 2) }, 60 | .receive(.squareNow) { $0 = 625 }, 61 | .receive(.incrNow) { $0 = 626 }, 62 | .receive(.squareNow) { $0 = 391876 } 63 | ) 64 | } 65 | 66 | func testLongLivingEffects() { 67 | typealias Environment = ( 68 | startEffect: Effect, 69 | stopEffect: Effect 70 | ) 71 | 72 | enum Action { case end, incr, start } 73 | 74 | let reducer = Reducer { state, action, environment in 75 | switch action { 76 | case .end: 77 | return environment.stopEffect.fireAndForget() 78 | case .incr: 79 | state += 1 80 | return .none 81 | case .start: 82 | return environment.startEffect.map { Action.incr } 83 | } 84 | } 85 | 86 | let subject = PublishSubject() 87 | 88 | let store = TestStore( 89 | initialState: 0, 90 | reducer: reducer, 91 | environment: ( 92 | startEffect: subject.eraseToEffect(), 93 | stopEffect: .fireAndForget { subject.onCompleted() } 94 | ) 95 | ) 96 | 97 | store.assert( 98 | .send(.start), 99 | .send(.incr) { $0 = 1 }, 100 | .do { subject.onNext(()) }, 101 | .receive(.incr) { $0 = 2 }, 102 | .send(.end) 103 | ) 104 | } 105 | 106 | func testCancellation() { 107 | enum Action: Equatable { 108 | case cancel 109 | case incr 110 | case response(Int) 111 | } 112 | 113 | struct Environment { 114 | let fetch: (Int) -> Effect 115 | let mainQueue: SchedulerType 116 | } 117 | 118 | let reducer = Reducer { state, action, environment in 119 | struct CancelId: Hashable {} 120 | 121 | switch action { 122 | case .cancel: 123 | return .cancel(id: CancelId()) 124 | 125 | case .incr: 126 | state += 1 127 | return environment.fetch(state) 128 | .observeOn(environment.mainQueue) 129 | .map(Action.response) 130 | .eraseToEffect() 131 | .cancellable(id: CancelId()) 132 | 133 | case let .response(value): 134 | state = value 135 | return .none 136 | } 137 | } 138 | 139 | let scheduler = TestScheduler.default() 140 | 141 | let store = TestStore( 142 | initialState: 0, 143 | reducer: reducer, 144 | environment: Environment( 145 | fetch: { value in Effect(value: value * value) }, 146 | mainQueue: scheduler 147 | ) 148 | ) 149 | 150 | store.assert( 151 | .send(.incr) { $0 = 1 }, 152 | .do { scheduler.advance() }, 153 | .receive(.response(1)) { $0 = 1 } 154 | ) 155 | 156 | store.assert( 157 | .send(.incr) { $0 = 2 }, 158 | .send(.cancel), 159 | .do { scheduler.run() } 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/EffectTests.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import RxTest 3 | import XCTest 4 | 5 | @testable import ComposableArchitecture 6 | 7 | final class EffectTests: XCTestCase { 8 | var disposeBag = DisposeBag() 9 | let scheduler = TestScheduler.default() 10 | 11 | func testConcatenate() { 12 | var values: [Int] = [] 13 | 14 | let effect = Effect.concatenate( 15 | Effect(value: 1).delay(.seconds(1), scheduler: scheduler).eraseToEffect(), 16 | Effect(value: 2).delay(.seconds(2), scheduler: scheduler).eraseToEffect(), 17 | Effect(value: 3).delay(.seconds(3), scheduler: scheduler).eraseToEffect() 18 | ) 19 | 20 | effect 21 | .subscribe(onNext: { values.append($0) }) 22 | .disposed(by: disposeBag) 23 | 24 | XCTAssertEqual(values, []) 25 | 26 | self.scheduler.advance(by: 1) 27 | XCTAssertEqual(values, [1]) 28 | 29 | self.scheduler.advance(by: 2) 30 | XCTAssertEqual(values, [1, 2]) 31 | 32 | self.scheduler.advance(by: 3) 33 | XCTAssertEqual(values, [1, 2, 3]) 34 | 35 | self.scheduler.run() 36 | XCTAssertEqual(values, [1, 2, 3]) 37 | } 38 | 39 | func testConcatenateOneEffect() { 40 | var values: [Int] = [] 41 | 42 | let effect = Effect.concatenate( 43 | Effect(value: 1).delay(.seconds(1), scheduler: scheduler).eraseToEffect() 44 | ) 45 | 46 | effect 47 | .subscribe(onNext: { values.append($0) }) 48 | .disposed(by: disposeBag) 49 | 50 | XCTAssertEqual(values, []) 51 | 52 | self.scheduler.advance(by: 1) 53 | XCTAssertEqual(values, [1]) 54 | 55 | self.scheduler.run() 56 | XCTAssertEqual(values, [1]) 57 | } 58 | 59 | func testMerge() { 60 | let effect = Effect.merge( 61 | Effect(value: 1).delay(.seconds(1), scheduler: scheduler).eraseToEffect(), 62 | Effect(value: 2).delay(.seconds(2), scheduler: scheduler).eraseToEffect(), 63 | Effect(value: 3).delay(.seconds(3), scheduler: scheduler).eraseToEffect() 64 | ) 65 | 66 | var values: [Int] = [] 67 | effect 68 | .subscribe(onNext: { values.append($0) }) 69 | .disposed(by: disposeBag) 70 | 71 | XCTAssertEqual(values, []) 72 | 73 | self.scheduler.advance(by: 1) 74 | XCTAssertEqual(values, [1]) 75 | 76 | self.scheduler.advance(by: 1) 77 | XCTAssertEqual(values, [1, 2]) 78 | 79 | self.scheduler.advance(by: 1) 80 | XCTAssertEqual(values, [1, 2, 3]) 81 | } 82 | 83 | func testEffectSubscriberInitializer() { 84 | let effect = Effect.run { subscriber in 85 | subscriber.onNext(1) 86 | subscriber.onNext(2) 87 | 88 | self.scheduler.scheduleRelative((), dueTime: .seconds(1)) { 89 | subscriber.onNext(3) 90 | return Disposables.create() 91 | } 92 | .disposed(by: self.disposeBag) 93 | 94 | self.scheduler.scheduleRelative((), dueTime: .seconds(2)) { 95 | subscriber.onNext(4) 96 | subscriber.onCompleted() 97 | return Disposables.create() 98 | } 99 | .disposed(by: self.disposeBag) 100 | 101 | return Disposables.create() 102 | } 103 | 104 | var values: [Int] = [] 105 | var isComplete = false 106 | effect 107 | .subscribe(onNext: { values.append($0) }, onCompleted: { isComplete = true }) 108 | .disposed(by: disposeBag) 109 | 110 | XCTAssertEqual(values, [1, 2]) 111 | XCTAssertEqual(isComplete, false) 112 | 113 | self.scheduler.advance(by: 1) 114 | 115 | XCTAssertEqual(values, [1, 2, 3]) 116 | XCTAssertEqual(isComplete, false) 117 | 118 | self.scheduler.advance(by: 1) 119 | 120 | XCTAssertEqual(values, [1, 2, 3, 4]) 121 | XCTAssertEqual(isComplete, true) 122 | } 123 | 124 | func testEffectSubscriberInitializer_WithCancellation() { 125 | struct CancelId: Hashable {} 126 | 127 | let effect = Effect.run { observer in 128 | observer.onNext(1) 129 | 130 | self.scheduler.scheduleRelative((), dueTime: .seconds(1)) { 131 | observer.onNext(2) 132 | return Disposables.create() 133 | } 134 | .disposed(by: self.disposeBag) 135 | 136 | return Disposables.create() 137 | } 138 | .cancellable(id: CancelId()) 139 | 140 | var values: [Int] = [] 141 | var isComplete = false 142 | effect 143 | .subscribe(onNext: { values.append($0) }, onCompleted: { isComplete = true }) 144 | .disposed(by: disposeBag) 145 | 146 | XCTAssertEqual(values, [1]) 147 | XCTAssertEqual(isComplete, false) 148 | 149 | Effect.cancel(id: CancelId()) 150 | .subscribe(onNext: {}) 151 | .disposed(by: disposeBag) 152 | 153 | self.scheduler.advance(by: 1) 154 | 155 | XCTAssertEqual(values, [1]) 156 | XCTAssertEqual(isComplete, true) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@pointfree.co. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/rxswift-composable-architecture-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 72 | 73 | 75 | 81 | 82 | 83 | 85 | 91 | 92 | 93 | 94 | 95 | 105 | 106 | 112 | 113 | 119 | 120 | 121 | 122 | 124 | 125 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Helpers/Alert.swift: -------------------------------------------------------------------------------- 1 | /// A data type that describes the state of an alert that can be shown to the user. The `Action` 2 | /// generic is the type of actions that can be sent from tapping on a button in the alert. 3 | /// 4 | /// This type can be used in your application's state in order to control the presentation or 5 | /// dismissal of alerts. It is preferrable to use this API instead of the default SwiftUI API 6 | /// for alerts because SwiftUI uses 2-way bindings in order to control the showing and dismissal 7 | /// of alerts, and that does not play nicely with the Composable Architecture. The library requires 8 | /// that all state mutations happen by sending an action so that a reducer can handle that logic, 9 | /// which greatly simplifies how data flows through your application, and gives you instant 10 | /// testability on all parts of your application. 11 | /// 12 | /// To use this API, you model all the alert actions in your domain's action enum: 13 | /// 14 | /// enum AppAction: Equatable { 15 | /// case cancelTapped 16 | /// case confirmTapped 17 | /// case deleteTapped 18 | /// 19 | /// // Your other actions 20 | /// } 21 | /// 22 | /// And you model the state for showing the alert in your domain's state, and it can start off 23 | /// `nil`: 24 | /// 25 | /// struct AppState: Equatable { 26 | /// var alert = AlertState? 27 | /// 28 | /// // Your other state 29 | /// } 30 | /// 31 | /// Then, in the reducer you can construct an `AlertState` value to represent the alert you want 32 | /// to show to the user: 33 | /// 34 | /// let appReducer = Reducer { state, action, env in 35 | /// switch action 36 | /// case .cancelTapped: 37 | /// state.alert = nil 38 | /// return .none 39 | /// 40 | /// case .confirmTapped: 41 | /// state.alert = nil 42 | /// // Do deletion logic... 43 | /// 44 | /// case .deleteTapped: 45 | /// state.alert = .init( 46 | /// title: "Delete", 47 | /// message: "Are you sure you want to delete this? It cannot be undone.", 48 | /// primaryButton: .default("Confirm", send: .confirmTapped), 49 | /// secondaryButton: .cancel() 50 | /// ) 51 | /// return .none 52 | /// } 53 | /// } 54 | /// 55 | /// And then, in your view you can use the `.alert(_:send:dismiss:)` method on `View` in order 56 | /// to present the alert in a way that works best with the Composable Architecture: 57 | /// 58 | /// Button("Delete") { viewStore.send(.deleteTapped) } 59 | /// .alert( 60 | /// self.store.scope(state: \.alert), 61 | /// dismiss: .cancelTapped 62 | /// ) 63 | /// 64 | /// This makes your reducer in complete control of when the alert is shown or dismissed, and makes 65 | /// it so that any choice made in the alert is automatically fed back into the reducer so that you 66 | /// can handle its logic. 67 | /// 68 | /// Even better, you can instantly write tests that your alert behavior works as expected: 69 | /// 70 | /// let store = TestStore( 71 | /// initialState: AppState(), 72 | /// reducer: appReducer, 73 | /// environment: .mock 74 | /// ) 75 | /// 76 | /// store.assert( 77 | /// .send(.deleteTapped) { 78 | /// $0.alert = .init( 79 | /// title: "Delete", 80 | /// message: "Are you sure you want to delete this? It cannot be undone.", 81 | /// primaryButton: .default("Confirm", send: .confirmTapped), 82 | /// secondaryButton: .cancel(send: .cancelTapped) 83 | /// ) 84 | /// }, 85 | /// .send(.deleteTapped) { 86 | /// $0.alert = nil 87 | /// // Also verify that delete logic executed correctly 88 | /// } 89 | /// ) 90 | /// 91 | public struct AlertState { 92 | public var message: String? 93 | public var primaryButton: Button? 94 | public var secondaryButton: Button? 95 | public var title: String 96 | 97 | public init( 98 | title: String, 99 | message: String? = nil, 100 | dismissButton: Button? = nil 101 | ) { 102 | self.title = title 103 | self.message = message 104 | self.primaryButton = dismissButton 105 | } 106 | 107 | public init( 108 | title: String, 109 | message: String? = nil, 110 | primaryButton: Button, 111 | secondaryButton: Button 112 | ) { 113 | self.title = title 114 | self.message = message 115 | self.primaryButton = primaryButton 116 | self.secondaryButton = secondaryButton 117 | } 118 | 119 | public struct Button { 120 | public var action: Action? 121 | public var type: `Type` 122 | 123 | public static func cancel( 124 | _ label: String, 125 | send action: Action? = nil 126 | ) -> Self { 127 | Self(action: action, type: .cancel(label: label)) 128 | } 129 | 130 | public static func cancel( 131 | send action: Action? = nil 132 | ) -> Self { 133 | Self(action: action, type: .cancel(label: nil)) 134 | } 135 | 136 | public static func `default`( 137 | _ label: String, 138 | send action: Action? = nil 139 | ) -> Self { 140 | Self(action: action, type: .default(label: label)) 141 | } 142 | 143 | public static func destructive( 144 | _ label: String, 145 | send action: Action? = nil 146 | ) -> Self { 147 | Self(action: action, type: .destructive(label: label)) 148 | } 149 | 150 | public enum `Type`: Hashable { 151 | case cancel(label: String?) 152 | case `default`(label: String) 153 | case destructive(label: String) 154 | } 155 | } 156 | } 157 | 158 | extension AlertState: Equatable where Action: Equatable {} 159 | extension AlertState: Hashable where Action: Hashable {} 160 | extension AlertState.Button: Equatable where Action: Equatable {} 161 | extension AlertState.Button: Hashable where Action: Hashable {} 162 | extension AlertState: Identifiable where Action: Hashable { 163 | public var id: Self { self } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Debugging/ReducerDebugging.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import Dispatch 3 | 4 | /// Determines how the string description of an action should be printed when using the `.debug()` 5 | /// higher-order reducer. 6 | public enum ActionFormat { 7 | /// Prints the action in a single line by only specifying the labels of the associated values: 8 | /// 9 | /// Action.screenA(.row(index:, action: .textChanged(query:))) 10 | case labelsOnly 11 | /// Prints the action in a multiline, pretty-printed format, including all the labels of 12 | /// any associated values, as well as the data held in the associated values: 13 | /// 14 | /// Action.screenA( 15 | /// ScreenA.row( 16 | /// index: 1, 17 | /// action: RowAction.textChanged( 18 | /// query: "Hi" 19 | /// ) 20 | /// ) 21 | /// ) 22 | case prettyPrint 23 | } 24 | 25 | extension Reducer { 26 | /// Prints debug messages describing all received actions and state mutations. 27 | /// 28 | /// Printing is only done in debug (`#if DEBUG`) builds. 29 | /// 30 | /// - Parameters: 31 | /// - prefix: A string with which to prefix all debug messages. 32 | /// - toDebugEnvironment: A function that transforms an environment into a debug environment by 33 | /// describing a print function and a queue to print from. Defaults to a function that ignores 34 | /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` 35 | /// function and a background queue. 36 | /// - Returns: A reducer that prints debug messages for all received actions. 37 | public func debug( 38 | _ prefix: String = "", 39 | actionFormat: ActionFormat = .prettyPrint, 40 | environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in 41 | DebugEnvironment() 42 | } 43 | ) -> Reducer { 44 | self.debug( 45 | prefix, 46 | state: { $0 }, 47 | action: .self, 48 | actionFormat: actionFormat, 49 | environment: toDebugEnvironment 50 | ) 51 | } 52 | 53 | /// Prints debug messages describing all received actions. 54 | /// 55 | /// Printing is only done in debug (`#if DEBUG`) builds. 56 | /// 57 | /// - Parameters: 58 | /// - prefix: A string with which to prefix all debug messages. 59 | /// - toDebugEnvironment: A function that transforms an environment into a debug environment by 60 | /// describing a print function and a queue to print from. Defaults to a function that ignores 61 | /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` 62 | /// function and a background queue. 63 | /// - Returns: A reducer that prints debug messages for all received actions. 64 | public func debugActions( 65 | _ prefix: String = "", 66 | actionFormat: ActionFormat = .prettyPrint, 67 | environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in 68 | DebugEnvironment() 69 | } 70 | ) -> Reducer { 71 | self.debug( 72 | prefix, 73 | state: { _ in () }, 74 | action: .self, 75 | actionFormat: actionFormat, 76 | environment: toDebugEnvironment 77 | ) 78 | } 79 | 80 | /// Prints debug messages describing all received local actions and local state mutations. 81 | /// 82 | /// Printing is only done in debug (`#if DEBUG`) builds. 83 | /// 84 | /// - Parameters: 85 | /// - prefix: A string with which to prefix all debug messages. 86 | /// - toLocalState: A function that filters state to be printed. 87 | /// - toLocalAction: A case path that filters actions that are printed. 88 | /// - toDebugEnvironment: A function that transforms an environment into a debug environment by 89 | /// describing a print function and a queue to print from. Defaults to a function that ignores 90 | /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` 91 | /// function and a background queue. 92 | /// - Returns: A reducer that prints debug messages for all received actions. 93 | public func debug( 94 | _ prefix: String = "", 95 | state toLocalState: @escaping (State) -> LocalState, 96 | action toLocalAction: CasePath, 97 | actionFormat: ActionFormat = .prettyPrint, 98 | environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in 99 | DebugEnvironment() 100 | } 101 | ) -> Reducer { 102 | #if DEBUG 103 | return .init { state, action, environment in 104 | let previousState = toLocalState(state) 105 | let effects = self.run(&state, action, environment) 106 | guard let localAction = toLocalAction.extract(from: action) else { return effects } 107 | let nextState = toLocalState(state) 108 | let debugEnvironment = toDebugEnvironment(environment) 109 | return .concatenate( 110 | .fireAndForget { 111 | debugEnvironment.queue.async { 112 | let actionOutput = 113 | actionFormat == .prettyPrint 114 | ? debugOutput(localAction).indent(by: 2) 115 | : debugCaseOutput(localAction).indent(by: 2) 116 | let stateOutput = 117 | LocalState.self == Void.self 118 | ? "" 119 | : debugDiff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n" 120 | debugEnvironment.printer( 121 | """ 122 | \(prefix.isEmpty ? "" : "\(prefix): ")received action: 123 | \(actionOutput) 124 | \(stateOutput) 125 | """ 126 | ) 127 | } 128 | }, 129 | effects 130 | ) 131 | } 132 | #else 133 | return self 134 | #endif 135 | } 136 | } 137 | 138 | /// An environment for debug-printing reducers. 139 | public struct DebugEnvironment { 140 | public var printer: (String) -> Void 141 | public var queue: DispatchQueue 142 | 143 | public init( 144 | printer: @escaping (String) -> Void = { print($0) }, 145 | queue: DispatchQueue 146 | ) { 147 | self.printer = printer 148 | self.queue = queue 149 | } 150 | 151 | public init( 152 | printer: @escaping (String) -> Void = { print($0) } 153 | ) { 154 | self.init(printer: printer, queue: _queue) 155 | } 156 | } 157 | 158 | private let _queue = DispatchQueue( 159 | label: "co.pointfree.ComposableArchitecture.DebugEnvironment", 160 | qos: .background 161 | ) 162 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/ReducerTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import RxSwift 3 | import RxTest 4 | import XCTest 5 | import os.signpost 6 | 7 | final class ReducerTests: XCTestCase { 8 | var disposeBag = DisposeBag() 9 | 10 | func testCallableAsFunction() { 11 | let reducer = Reducer { state, _, _ in 12 | state += 1 13 | return .none 14 | } 15 | 16 | var state = 0 17 | _ = reducer.run(&state, (), ()) 18 | XCTAssertEqual(state, 1) 19 | } 20 | 21 | func testCombine_EffectsAreMerged() { 22 | enum Action: Equatable { 23 | case increment 24 | } 25 | 26 | var fastValue: Int? 27 | let fastReducer = Reducer { state, _, scheduler in 28 | state += 1 29 | return Effect.fireAndForget { fastValue = 42 } 30 | .delay(.seconds(1), scheduler: scheduler) 31 | .eraseToEffect() 32 | } 33 | 34 | var slowValue: Int? 35 | let slowReducer = Reducer { state, _, scheduler in 36 | state += 1 37 | return Effect.fireAndForget { slowValue = 1729 } 38 | .delay(.seconds(2), scheduler: scheduler) 39 | .eraseToEffect() 40 | } 41 | 42 | let scheduler = TestScheduler.default() 43 | let store = TestStore( 44 | initialState: 0, 45 | reducer: .combine(fastReducer, slowReducer), 46 | environment: scheduler 47 | ) 48 | 49 | store.assert( 50 | .send(.increment) { 51 | $0 = 2 52 | }, 53 | // Waiting a second causes the fast effect to fire. 54 | .do { scheduler.advance(by: 1) }, 55 | .do { XCTAssertEqual(fastValue, 42) }, 56 | // Waiting one more second causes the slow effect to fire. This proves that the effects 57 | // are merged together, as opposed to concatenated. 58 | .do { scheduler.advance(by: 1) }, 59 | .do { XCTAssertEqual(slowValue, 1729) } 60 | ) 61 | } 62 | 63 | func testCombine() { 64 | enum Action: Equatable { 65 | case increment 66 | } 67 | 68 | var childEffectExecuted = false 69 | let childReducer = Reducer { state, _, _ in 70 | state += 1 71 | return Effect.fireAndForget { childEffectExecuted = true } 72 | .eraseToEffect() 73 | } 74 | 75 | var mainEffectExecuted = false 76 | let mainReducer = Reducer { state, _, _ in 77 | state += 1 78 | return Effect.fireAndForget { mainEffectExecuted = true } 79 | .eraseToEffect() 80 | } 81 | .combined(with: childReducer) 82 | 83 | let store = TestStore( 84 | initialState: 0, 85 | reducer: mainReducer, 86 | environment: () 87 | ) 88 | 89 | store.assert( 90 | .send(.increment) { 91 | $0 = 2 92 | } 93 | ) 94 | 95 | XCTAssertTrue(childEffectExecuted) 96 | XCTAssertTrue(mainEffectExecuted) 97 | } 98 | 99 | func testDebug() { 100 | enum Action: Equatable { case incr, noop } 101 | struct State: Equatable { var count = 0 } 102 | 103 | var logs: [String] = [] 104 | let logsExpectation = self.expectation(description: "logs") 105 | logsExpectation.expectedFulfillmentCount = 2 106 | 107 | let reducer = Reducer { state, action, _ in 108 | switch action { 109 | case .incr: 110 | state.count += 1 111 | return .none 112 | case .noop: 113 | return .none 114 | } 115 | } 116 | .debug("[prefix]") { _ in 117 | DebugEnvironment( 118 | printer: { 119 | logs.append($0) 120 | logsExpectation.fulfill() 121 | } 122 | ) 123 | } 124 | 125 | let store = TestStore( 126 | initialState: State(), 127 | reducer: reducer, 128 | environment: () 129 | ) 130 | store.assert( 131 | .send(.incr) { $0.count = 1 }, 132 | .send(.noop) 133 | ) 134 | 135 | self.wait(for: [logsExpectation], timeout: 2) 136 | 137 | XCTAssertEqual( 138 | logs, 139 | [ 140 | #""" 141 | [prefix]: received action: 142 | Action.incr 143 |   State( 144 | − count: 0 145 | + count: 1 146 |   ) 147 | 148 | """#, 149 | #""" 150 | [prefix]: received action: 151 | Action.noop 152 | (No state changes) 153 | 154 | """#, 155 | ] 156 | ) 157 | } 158 | 159 | func testDebug_ActionFormat_OnlyLabels() { 160 | enum Action: Equatable { case incr(Bool) } 161 | struct State: Equatable { var count = 0 } 162 | 163 | var logs: [String] = [] 164 | let logsExpectation = self.expectation(description: "logs") 165 | 166 | let reducer = Reducer { state, action, _ in 167 | switch action { 168 | case let .incr(bool): 169 | state.count += bool ? 1 : 0 170 | return .none 171 | } 172 | } 173 | .debug("[prefix]", actionFormat: .labelsOnly) { _ in 174 | DebugEnvironment( 175 | printer: { 176 | logs.append($0) 177 | logsExpectation.fulfill() 178 | } 179 | ) 180 | } 181 | 182 | let viewStore = ViewStore( 183 | Store( 184 | initialState: State(), 185 | reducer: reducer, 186 | environment: () 187 | ) 188 | ) 189 | viewStore.send(.incr(true)) 190 | 191 | self.wait(for: [logsExpectation], timeout: 2) 192 | 193 | XCTAssertEqual( 194 | logs, 195 | [ 196 | #""" 197 | [prefix]: received action: 198 | Action.incr 199 |   State( 200 | − count: 0 201 | + count: 1 202 |   ) 203 | 204 | """# 205 | ] 206 | ) 207 | } 208 | 209 | func testDefaultSignpost() { 210 | let reducer = Reducer.empty.signpost(log: .default) 211 | var n = 0 212 | let effect = reducer.run(&n, (), ()) 213 | let expectation = self.expectation(description: "effect") 214 | effect 215 | .subscribe(onCompleted: { expectation.fulfill() }) 216 | .disposed(by: disposeBag) 217 | self.wait(for: [expectation], timeout: 0.1) 218 | } 219 | 220 | func testDisabledSignpost() { 221 | let reducer = Reducer.empty.signpost(log: .disabled) 222 | var n = 0 223 | let effect = reducer.run(&n, (), ()) 224 | let expectation = self.expectation(description: "effect") 225 | effect 226 | .subscribe(onCompleted: { expectation.fulfill() }) 227 | .disposed(by: disposeBag) 228 | self.wait(for: [expectation], timeout: 0.1) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Models/Location.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | /// A value type wrapper for `CLLocation`. This type is necessary so that we can do equality checks 4 | /// and write tests against its values. 5 | 6 | @dynamicMemberLookup 7 | public struct Location { 8 | public let rawValue: CLLocation 9 | 10 | public init( 11 | altitude: CLLocationDistance = 0, 12 | coordinate: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude: 0, longitude: 0), 13 | course: CLLocationDirection = 0, 14 | horizontalAccuracy: CLLocationAccuracy = 0, 15 | speed: CLLocationSpeed = 0, 16 | timestamp: Date = Date(), 17 | verticalAccuracy: CLLocationAccuracy = 0 18 | ) { 19 | self.rawValue = CLLocation( 20 | coordinate: coordinate, 21 | altitude: altitude, 22 | horizontalAccuracy: horizontalAccuracy, 23 | verticalAccuracy: verticalAccuracy, 24 | course: course, 25 | speed: speed, 26 | timestamp: timestamp 27 | ) 28 | } 29 | 30 | public init(rawValue: CLLocation) { 31 | self.rawValue = rawValue 32 | } 33 | 34 | public subscript(dynamicMember keyPath: KeyPath) -> T { 35 | self.rawValue[keyPath: keyPath] 36 | } 37 | } 38 | 39 | extension Location: Equatable { 40 | public static func == (lhs: Self, rhs: Self) -> Bool { 41 | let speedAccuracyIsEqual: Bool 42 | let courseAccuracyIsEqual: Bool 43 | 44 | #if compiler(>=5.2) 45 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 46 | courseAccuracyIsEqual = lhs.courseAccuracy == rhs.courseAccuracy 47 | } else { 48 | courseAccuracyIsEqual = true 49 | } 50 | speedAccuracyIsEqual = lhs.speedAccuracy == rhs.speedAccuracy 51 | #else 52 | speedAccuracyIsEqual = true 53 | courseAccuracyIsEqual = true 54 | #endif 55 | 56 | return lhs.altitude == rhs.altitude 57 | && lhs.coordinate.latitude == rhs.coordinate.latitude 58 | && lhs.coordinate.longitude == rhs.coordinate.longitude 59 | && lhs.course == rhs.course 60 | && lhs.floor == rhs.floor 61 | && lhs.horizontalAccuracy == rhs.horizontalAccuracy 62 | && lhs.speed == rhs.speed 63 | && lhs.timestamp == rhs.timestamp 64 | && lhs.verticalAccuracy == rhs.verticalAccuracy 65 | && speedAccuracyIsEqual 66 | && courseAccuracyIsEqual 67 | } 68 | 69 | } 70 | 71 | #if compiler(>=5.2) 72 | extension Location { 73 | public init( 74 | altitude: CLLocationDistance = 0, 75 | coordinate: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude: 0, longitude: 0), 76 | course: CLLocationDirection = 0, 77 | courseAccuracy: Double = 0, 78 | horizontalAccuracy: CLLocationAccuracy = 0, 79 | speed: CLLocationSpeed = 0, 80 | speedAccuracy: Double = 0, 81 | timestamp: Date = Date(), 82 | verticalAccuracy: CLLocationAccuracy = 0 83 | ) { 84 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 85 | self.rawValue = CLLocation( 86 | coordinate: coordinate, 87 | altitude: altitude, 88 | horizontalAccuracy: horizontalAccuracy, 89 | verticalAccuracy: verticalAccuracy, 90 | course: course, 91 | courseAccuracy: courseAccuracy, 92 | speed: speed, 93 | speedAccuracy: speedAccuracy, 94 | timestamp: timestamp 95 | ) 96 | } else { 97 | self.rawValue = CLLocation( 98 | coordinate: coordinate, 99 | altitude: altitude, 100 | horizontalAccuracy: horizontalAccuracy, 101 | verticalAccuracy: verticalAccuracy, 102 | course: course, 103 | speed: speed, 104 | timestamp: timestamp 105 | ) 106 | } 107 | } 108 | } 109 | #endif 110 | 111 | extension Location: Codable { 112 | public init(from decoder: Decoder) throws { 113 | let values = try decoder.container(keyedBy: CodingKeys.self) 114 | let altitude = try values.decode(CLLocationDistance.self, forKey: .altitude) 115 | let latitude = try values.decode(CLLocationDegrees.self, forKey: .latitude) 116 | let longitude = try values.decode(CLLocationDegrees.self, forKey: .longitude) 117 | let course = try values.decode(CLLocationDirection.self, forKey: .course) 118 | let horizontalAccuracy = try values.decode(Double.self, forKey: .horizontalAccuracy) 119 | let speed = try values.decode(CLLocationSpeed.self, forKey: .speed) 120 | let timestamp = try values.decode(Date.self, forKey: .timestamp) 121 | let verticalAccuracy = try values.decode(CLLocationAccuracy.self, forKey: .verticalAccuracy) 122 | 123 | #if compiler(>=5.2) 124 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 125 | let courseAccuracy = try values.decode(Double.self, forKey: .courseAccuracy) 126 | let speedAccuracy = try values.decode(Double.self, forKey: .speedAccuracy) 127 | 128 | self.init( 129 | altitude: altitude, 130 | coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), 131 | course: course, 132 | courseAccuracy: courseAccuracy, 133 | horizontalAccuracy: horizontalAccuracy, 134 | speed: speed, 135 | speedAccuracy: speedAccuracy, 136 | timestamp: timestamp, 137 | verticalAccuracy: verticalAccuracy 138 | ) 139 | } else { 140 | self.init( 141 | altitude: altitude, 142 | coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), 143 | course: course, 144 | horizontalAccuracy: horizontalAccuracy, 145 | speed: speed, 146 | timestamp: timestamp, 147 | verticalAccuracy: verticalAccuracy 148 | ) 149 | } 150 | #else 151 | self.init( 152 | altitude: altitude, 153 | coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), 154 | course: course, 155 | horizontalAccuracy: horizontalAccuracy, 156 | speed: speed, 157 | timestamp: timestamp, 158 | verticalAccuracy: verticalAccuracy 159 | ) 160 | #endif 161 | } 162 | 163 | public func encode(to encoder: Encoder) throws { 164 | var container = encoder.container(keyedBy: CodingKeys.self) 165 | try container.encode(rawValue.altitude, forKey: .altitude) 166 | try container.encode(rawValue.coordinate.latitude, forKey: .latitude) 167 | try container.encode(rawValue.coordinate.longitude, forKey: .longitude) 168 | try container.encode(rawValue.course, forKey: .course) 169 | try container.encode(rawValue.horizontalAccuracy, forKey: .horizontalAccuracy) 170 | try container.encode(rawValue.speed, forKey: .speed) 171 | try container.encode(rawValue.timestamp, forKey: .timestamp) 172 | try container.encode(rawValue.verticalAccuracy, forKey: .verticalAccuracy) 173 | 174 | #if compiler(>=5.2) 175 | if #available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, tvOS 13.4, watchOS 6.2, *) { 176 | try container.encode(rawValue.courseAccuracy, forKey: .courseAccuracy) 177 | } 178 | try container.encode(rawValue.speedAccuracy, forKey: .speedAccuracy) 179 | #endif 180 | } 181 | 182 | private enum CodingKeys: String, CodingKey { 183 | case latitude 184 | case longitude 185 | case altitude 186 | case course 187 | case courseAccuracy 188 | case horizontalAccuracy 189 | case speed 190 | case speedAccuracy 191 | case timestamp 192 | case verticalAccuracy 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/EffectCancellationTests.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import RxTest 3 | import XCTest 4 | 5 | @testable import ComposableArchitecture 6 | 7 | final class EffectCancellationTests: XCTestCase { 8 | struct CancelToken: Hashable {} 9 | var disposeBag = DisposeBag() 10 | 11 | override func tearDown() { 12 | super.tearDown() 13 | disposeBag = DisposeBag() 14 | } 15 | 16 | func testCancellation() { 17 | var values: [Int] = [] 18 | 19 | let subject = PublishSubject() 20 | let effect = Effect(subject) 21 | .cancellable(id: CancelToken()) 22 | 23 | effect 24 | .subscribe(onNext: { values.append($0) }) 25 | .disposed(by: disposeBag) 26 | 27 | XCTAssertEqual(values, []) 28 | subject.onNext(1) 29 | XCTAssertEqual(values, [1]) 30 | subject.onNext(2) 31 | XCTAssertEqual(values, [1, 2]) 32 | 33 | _ = Effect.cancel(id: CancelToken()) 34 | .subscribe(onNext: { _ in }) 35 | .disposed(by: disposeBag) 36 | 37 | subject.onNext(3) 38 | XCTAssertEqual(values, [1, 2]) 39 | } 40 | 41 | func testCancelInFlight() { 42 | var values: [Int] = [] 43 | 44 | let subject = PublishSubject() 45 | Effect(subject) 46 | .cancellable(id: CancelToken(), cancelInFlight: true) 47 | .subscribe(onNext: { values.append($0) }) 48 | .disposed(by: disposeBag) 49 | 50 | XCTAssertEqual(values, []) 51 | subject.onNext(1) 52 | XCTAssertEqual(values, [1]) 53 | subject.onNext(2) 54 | XCTAssertEqual(values, [1, 2]) 55 | 56 | Effect(subject) 57 | .cancellable(id: CancelToken(), cancelInFlight: true) 58 | .subscribe(onNext: { values.append($0) }) 59 | .disposed(by: disposeBag) 60 | 61 | subject.onNext(3) 62 | XCTAssertEqual(values, [1, 2, 3]) 63 | subject.onNext(4) 64 | XCTAssertEqual(values, [1, 2, 3, 4]) 65 | } 66 | 67 | func testCancellationAfterDelay() { 68 | var value: Int? 69 | 70 | Observable.just(1) 71 | .delay(.milliseconds(150), scheduler: MainScheduler.instance) 72 | .eraseToEffect() 73 | .cancellable(id: CancelToken()) 74 | .subscribe(onNext: { value = $0 }) 75 | .disposed(by: disposeBag) 76 | 77 | XCTAssertEqual(value, nil) 78 | 79 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { 80 | _ = Effect.cancel(id: CancelToken()) 81 | .subscribe(onNext: { _ in }) 82 | .disposed(by: self.disposeBag) 83 | } 84 | 85 | _ = XCTWaiter.wait(for: [self.expectation(description: "")], timeout: 0.3) 86 | 87 | XCTAssertEqual(value, nil) 88 | } 89 | 90 | func testCancellationAfterDelay_WithTestScheduler() { 91 | let scheduler = TestScheduler.default() 92 | var value: Int? 93 | 94 | Observable.just(1) 95 | .delay(.seconds(2), scheduler: scheduler) 96 | .eraseToEffect() 97 | .cancellable(id: CancelToken()) 98 | .subscribe(onNext: { value = $0 }) 99 | .disposed(by: disposeBag) 100 | 101 | XCTAssertEqual(value, nil) 102 | 103 | scheduler.advance(by: 1) 104 | Effect.cancel(id: CancelToken()) 105 | .subscribe(onNext: { _ in }) 106 | .disposed(by: self.disposeBag) 107 | 108 | scheduler.run() 109 | 110 | XCTAssertEqual(value, nil) 111 | } 112 | 113 | func testCancellablesCleanUp_OnComplete() { 114 | var isDisposed = false 115 | 116 | Observable.just(1) 117 | .eraseToEffect() 118 | .cancellable(id: 1) 119 | .subscribe(onNext: { _ in }, onDisposed: { isDisposed = true }) 120 | .disposed(by: self.disposeBag) 121 | 122 | XCTAssertTrue(isDisposed) 123 | } 124 | 125 | func testCancellablesCleanUp_OnCancel() { 126 | let scheduler = TestScheduler.default() 127 | Observable.just(1) 128 | .delay(.seconds(1), scheduler: scheduler) 129 | .eraseToEffect() 130 | .cancellable(id: 1) 131 | .subscribe(onNext: { _ in }) 132 | .disposed(by: self.disposeBag) 133 | 134 | Effect 135 | .cancel(id: 1) 136 | .subscribe(onNext: { _ in }) 137 | .disposed(by: self.disposeBag) 138 | 139 | XCTAssertEqual(0, cancellationCancellables.count) 140 | } 141 | 142 | func testDoubleCancellation() { 143 | var values: [Int] = [] 144 | 145 | let subject = PublishSubject() 146 | let effect = Effect(subject) 147 | .cancellable(id: CancelToken()) 148 | .cancellable(id: CancelToken()) 149 | 150 | effect 151 | .subscribe(onNext: { values.append($0) }) 152 | .disposed(by: disposeBag) 153 | 154 | XCTAssertEqual(values, []) 155 | subject.onNext(1) 156 | XCTAssertEqual(values, [1]) 157 | 158 | _ = Effect.cancel(id: CancelToken()) 159 | .subscribe(onNext: { _ in }) 160 | .disposed(by: disposeBag) 161 | 162 | subject.onNext(2) 163 | XCTAssertEqual(values, [1]) 164 | } 165 | 166 | func testCompleteBeforeCancellation() { 167 | var values: [Int] = [] 168 | 169 | let subject = PublishSubject() 170 | let effect = Effect(subject) 171 | .cancellable(id: CancelToken()) 172 | 173 | effect 174 | .subscribe(onNext: { values.append($0) }) 175 | .disposed(by: disposeBag) 176 | 177 | subject.onNext(1) 178 | XCTAssertEqual(values, [1]) 179 | 180 | subject.onCompleted() 181 | XCTAssertEqual(values, [1]) 182 | 183 | _ = Effect.cancel(id: CancelToken()) 184 | .subscribe(onNext: { _ in }) 185 | .disposed(by: disposeBag) 186 | 187 | XCTAssertEqual(values, [1]) 188 | } 189 | 190 | func testConcurrentCancels() { 191 | let queues = [ 192 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.main), 193 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .background)), 194 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .default)), 195 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .unspecified)), 196 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .userInitiated)), 197 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .userInteractive)), 198 | ConcurrentDispatchQueueScheduler(queue: DispatchQueue.global(qos: .utility)), 199 | ] 200 | 201 | let effect = Effect.merge( 202 | (1...1_000).map { idx -> Effect in 203 | let id = idx % 10 204 | 205 | return Effect.merge( 206 | Observable.just(idx) 207 | .delay( 208 | .milliseconds(Int.random(in: 1...100)), scheduler: queues.randomElement()! 209 | ) 210 | .eraseToEffect() 211 | .cancellable(id: id), 212 | 213 | Observable.just(()) 214 | .delay( 215 | .milliseconds(Int.random(in: 1...100)), scheduler: queues.randomElement()! 216 | ) 217 | .flatMap { Effect.cancel(id: id) } 218 | .eraseToEffect() 219 | ) 220 | } 221 | ) 222 | 223 | let expectation = self.expectation(description: "wait") 224 | effect 225 | .subscribe(onCompleted: { expectation.fulfill() }) 226 | .disposed(by: disposeBag) 227 | 228 | self.wait(for: [expectation], timeout: 999) 229 | 230 | XCTAssertEqual(0, cancellationCancellables.count) 231 | } 232 | 233 | func testNestedCancels() { 234 | var effect = Observable.never() 235 | .eraseToEffect() 236 | .cancellable(id: 1) 237 | 238 | for _ in 1 ... .random(in: 1...1_000) { 239 | effect = effect.cancellable(id: 1) 240 | } 241 | 242 | effect 243 | .subscribe(onNext: { _ in }) 244 | .disposed(by: disposeBag) 245 | 246 | disposeBag = DisposeBag() 247 | 248 | XCTAssertEqual(0, cancellationCancellables.count) 249 | } 250 | 251 | func testSharedId() { 252 | let scheduler = TestScheduler.default() 253 | 254 | let effect1 = Observable.just(1) 255 | .delay(.seconds(1), scheduler: scheduler) 256 | .eraseToEffect() 257 | .cancellable(id: "id") 258 | 259 | let effect2 = Observable.just(2) 260 | .delay(.seconds(2), scheduler: scheduler) 261 | .eraseToEffect() 262 | .cancellable(id: "id") 263 | 264 | var expectedOutput: [Int] = [] 265 | effect1 266 | .subscribe(onNext: { expectedOutput.append($0) }) 267 | .disposed(by: disposeBag) 268 | effect2 269 | .subscribe(onNext: { expectedOutput.append($0) }) 270 | .disposed(by: disposeBag) 271 | 272 | XCTAssertEqual(expectedOutput, []) 273 | scheduler.advance(by: 1) 274 | XCTAssertEqual(expectedOutput, [1]) 275 | scheduler.advance(by: 1) 276 | XCTAssertEqual(expectedOutput, [1, 2]) 277 | } 278 | 279 | func testImmediateCancellation() { 280 | let scheduler = TestScheduler.default() 281 | 282 | var expectedOutput: [Int] = [] 283 | // Don't hold onto cancellable so that it is deallocated immediately. 284 | let d = Observable.deferred { .just(1) } 285 | .delay(.seconds(1), scheduler: scheduler) 286 | .eraseToEffect() 287 | .cancellable(id: "id") 288 | .subscribe(onNext: { expectedOutput.append($0) }) 289 | d.dispose() 290 | 291 | XCTAssertEqual(expectedOutput, []) 292 | scheduler.advance(by: 1) 293 | XCTAssertEqual(expectedOutput, []) 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Store.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxRelay 3 | import RxSwift 4 | 5 | /// A store represents the runtime that powers the application. It is the object that you will pass 6 | /// around to views that need to interact with the application. 7 | /// 8 | /// You will typically construct a single one of these at the root of your application, and then use 9 | /// the `scope` method to derive more focused stores that can be passed to subviews. 10 | public final class Store { 11 | 12 | private var synchronousActionsToSend: [Action] = [] 13 | private var isSending = false 14 | private var parentDisposable: Disposable? 15 | var effectDisposables = CompositeDisposable() 16 | private let reducer: (inout State, Action) -> Effect 17 | 18 | private var stateRelay: BehaviorRelay 19 | public private(set) var state: State { 20 | get { return stateRelay.value } 21 | set { stateRelay.accept(newValue) } 22 | } 23 | var observable: Observable { 24 | return stateRelay.asObservable() 25 | } 26 | 27 | deinit { 28 | parentDisposable?.dispose() 29 | effectDisposables.dispose() 30 | } 31 | 32 | /// Initializes a store from an initial state, a reducer, and an environment. 33 | /// 34 | /// - Parameters: 35 | /// - initialState: The state to start the application in. 36 | /// - reducer: The reducer that powers the business logic of the application. 37 | /// - environment: The environment of dependencies for the application. 38 | public convenience init( 39 | initialState: State, 40 | reducer: Reducer, 41 | environment: Environment 42 | ) { 43 | self.init( 44 | initialState: initialState, 45 | reducer: { reducer.run(&$0, $1, environment) } 46 | ) 47 | } 48 | 49 | /// Scopes the store to one that exposes local state and actions. 50 | /// 51 | /// This can be useful for deriving new stores to hand to child views in an application. For 52 | /// example: 53 | /// 54 | /// // Application state made from local states. 55 | /// struct AppState { var login: LoginState, ... } 56 | /// struct AppAction { case login(LoginAction), ... } 57 | /// 58 | /// // A store that runs the entire application. 59 | /// let store = Store(initialState: AppState(), reducer: appReducer, environment: ()) 60 | /// 61 | /// // Construct a login view by scoping the store to one that works with only login domain. 62 | /// let loginView = LoginView( 63 | /// store: store.scope( 64 | /// state: { $0.login }, 65 | /// action: { AppAction.login($0) } 66 | /// ) 67 | /// ) 68 | /// 69 | /// - Parameters: 70 | /// - toLocalState: A function that transforms `State` into `LocalState`. 71 | /// - fromLocalAction: A function that transforms `LocalAction` into `Action`. 72 | /// - Returns: A new store with its domain (state and action) transformed. 73 | public func scope( 74 | state toLocalState: @escaping (State) -> LocalState, 75 | action fromLocalAction: @escaping (LocalAction) -> Action 76 | ) -> Store { 77 | let localStore = Store( 78 | initialState: toLocalState(self.state), 79 | reducer: { localState, localAction in 80 | self.send(fromLocalAction(localAction)) 81 | localState = toLocalState(self.state) 82 | return .none 83 | } 84 | ) 85 | localStore.parentDisposable = self.observable 86 | .subscribe(onNext: { [weak localStore] newValue in localStore?.state = toLocalState(newValue) 87 | }) 88 | return localStore 89 | } 90 | 91 | /// Scopes the store to one that exposes local state. 92 | /// 93 | /// - Parameter toLocalState: A function that transforms `State` into `LocalState`. 94 | /// - Returns: A new store with its domain (state and action) transformed. 95 | public func scope( 96 | state toLocalState: @escaping (State) -> LocalState 97 | ) -> Store { 98 | self.scope(state: toLocalState, action: { $0 }) 99 | } 100 | 101 | /// Scopes the store to a publisher of stores of more local state and local actions. 102 | /// 103 | /// - Parameters: 104 | /// - toLocalState: A function that transforms a publisher of `State` into a publisher of 105 | /// `LocalState`. 106 | /// - fromLocalAction: A function that transforms `LocalAction` into `Action`. 107 | /// - Returns: A publisher of stores with its domain (state and action) transformed. 108 | public func scope( 109 | state toLocalState: @escaping (Observable) -> Observable, 110 | action fromLocalAction: @escaping (LocalAction) -> Action 111 | ) -> Observable> { 112 | 113 | func extractLocalState(_ state: State) -> LocalState? { 114 | var localState: LocalState? 115 | _ = toLocalState(Observable.just(state)).subscribe(onNext: { localState = $0 }) 116 | return localState 117 | } 118 | 119 | return toLocalState(self.observable) 120 | .map { localState in 121 | let localStore = Store( 122 | initialState: localState, 123 | reducer: { localState, localAction in 124 | self.send(fromLocalAction(localAction)) 125 | localState = extractLocalState(self.state) ?? localState 126 | return .none 127 | }) 128 | 129 | localStore.parentDisposable = self.observable 130 | .subscribe(onNext: { [weak localStore] state in 131 | guard let localStore = localStore else { return } 132 | localStore.state = extractLocalState(state) ?? localStore.state 133 | }) 134 | 135 | return localStore 136 | } 137 | } 138 | 139 | /// Scopes the store to a publisher of stores of more local state and local actions. 140 | /// 141 | /// - Parameter toLocalState: A function that transforms a publisher of `State` into a publisher 142 | /// of `LocalState`. 143 | /// - Returns: A publisher of stores with its domain (state and action) 144 | /// transformed. 145 | public func scope( 146 | state toLocalState: @escaping (Observable) -> Observable 147 | ) -> Observable> { 148 | self.scope(state: toLocalState, action: { $0 }) 149 | } 150 | 151 | func send(_ action: Action) { 152 | self.synchronousActionsToSend.append(action) 153 | 154 | while !self.synchronousActionsToSend.isEmpty { 155 | let action = self.synchronousActionsToSend.removeFirst() 156 | 157 | if self.isSending { 158 | assertionFailure( 159 | """ 160 | The store was sent the action \(debugCaseOutput(action)) while it was already 161 | processing another action. 162 | 163 | This can happen for a few reasons: 164 | 165 | * The store was sent an action recursively. This can occur when you run an effect \ 166 | directly in the reducer, rather than returning it from the reducer. Check the stack (⌘7) \ 167 | to find frames corresponding to one of your reducers. That code should be refactored to \ 168 | not invoke the effect directly. 169 | 170 | * The store has been sent actions from multiple threads. The `send` method is not \ 171 | thread-safe, and should only ever be used from a single thread (typically the main \ 172 | thread). Instead of calling `send` from multiple threads you should use effects to \ 173 | process expensive computations on background threads so that it can be fed back into the \ 174 | store. 175 | """ 176 | ) 177 | } 178 | self.isSending = true 179 | let effect = self.reducer(&self.state, action) 180 | self.isSending = false 181 | 182 | var didComplete = false 183 | var isProcessingEffects = true 184 | var disposeKey: CompositeDisposable.DisposeKey? 185 | 186 | let effectDisposable = effect.subscribe( 187 | onNext: { [weak self] action in 188 | if isProcessingEffects { 189 | self?.synchronousActionsToSend.append(action) 190 | } else { 191 | self?.send(action) 192 | } 193 | }, 194 | onError: { err in 195 | assertionFailure("Error during effect handling: \(err.localizedDescription)") 196 | }, 197 | onCompleted: { [weak self] in 198 | didComplete = true 199 | if let disposeKey = disposeKey { 200 | self?.effectDisposables.remove(for: disposeKey) 201 | } 202 | } 203 | ) 204 | 205 | isProcessingEffects = false 206 | 207 | if !didComplete { 208 | disposeKey = effectDisposables.insert(effectDisposable) 209 | } 210 | } 211 | } 212 | 213 | /// Returns a "stateless" store by erasing state to `Void`. 214 | public var stateless: Store { 215 | self.scope(state: { _ in () }) 216 | } 217 | 218 | /// Returns an "actionless" store by erasing action to `Never`. 219 | public var actionless: Store { 220 | func absurd(_ never: Never) -> A {} 221 | return self.scope(state: { $0 }, action: absurd) 222 | } 223 | 224 | private init( 225 | initialState: State, 226 | reducer: @escaping (inout State, Action) -> Effect 227 | ) { 228 | self.stateRelay = BehaviorRelay(value: initialState) 229 | self.reducer = reducer 230 | self.state = initialState 231 | } 232 | } 233 | 234 | /// A publisher of store state. 235 | @dynamicMemberLookup 236 | public struct StorePublisher: ObservableType { 237 | public typealias Element = State 238 | public let upstream: Observable 239 | 240 | public func subscribe(_ observer: Observer) -> Disposable 241 | where Observer: ObserverType, Element == Observer.Element { 242 | upstream.subscribe(observer) 243 | } 244 | 245 | init(_ upstream: Observable) { 246 | self.upstream = upstream 247 | } 248 | 249 | /// Returns the resulting publisher of a given key path. 250 | public subscript( 251 | dynamicMember keyPath: KeyPath 252 | ) -> StorePublisher 253 | where LocalState: Equatable { 254 | .init(self.upstream.map { $0[keyPath: keyPath] }.distinctUntilChanged()) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Live.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CoreLocation 3 | import RxSwift 4 | 5 | extension LocationManager { 6 | 7 | /// The live implementation of the `LocationManager` interface. This implementation is capable of 8 | /// creating real `CLLocationManager` instances, listening to its delegate methods, and invoking 9 | /// its methods. You will typically use this when building for the simulator or device: 10 | /// 11 | /// let store = Store( 12 | /// initialState: AppState(), 13 | /// reducer: appReducer, 14 | /// environment: AppEnvironment( 15 | /// locationManager: LocationManager.live 16 | /// ) 17 | /// ) 18 | /// 19 | public static let live: LocationManager = { () -> LocationManager in 20 | var manager = LocationManager() 21 | 22 | manager.authorizationStatus = CLLocationManager.authorizationStatus 23 | 24 | manager.create = { id in 25 | Effect.run { subscriber in 26 | let manager = CLLocationManager() 27 | var delegate = LocationManagerDelegate(subscriber) 28 | manager.delegate = delegate 29 | 30 | dependencies[id] = Dependencies( 31 | delegate: delegate, 32 | manager: manager, 33 | subscriber: subscriber 34 | ) 35 | 36 | return Disposables.create { 37 | dependencies[id] = nil 38 | } 39 | } 40 | } 41 | 42 | manager.destroy = { id in 43 | .fireAndForget { 44 | dependencies[id]?.subscriber.onCompleted() 45 | dependencies[id] = nil 46 | } 47 | } 48 | 49 | manager.locationServicesEnabled = CLLocationManager.locationServicesEnabled 50 | 51 | manager.location = { id in dependencies[id]?.manager.location.map(Location.init(rawValue:)) } 52 | 53 | manager.requestLocation = { id in 54 | .fireAndForget { dependencies[id]?.manager.requestLocation() } 55 | } 56 | 57 | #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) 58 | manager.requestAlwaysAuthorization = { id in 59 | .fireAndForget { dependencies[id]?.manager.requestAlwaysAuthorization() } 60 | } 61 | #endif 62 | 63 | #if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst) 64 | manager.requestWhenInUseAuthorization = { id in 65 | .fireAndForget { dependencies[id]?.manager.requestWhenInUseAuthorization() } 66 | } 67 | #endif 68 | 69 | manager.set = { id, properties in 70 | .fireAndForget { 71 | guard let manager = dependencies[id]?.manager else { return } 72 | 73 | #if os(iOS) || os(watchOS) || targetEnvironment(macCatalyst) 74 | if let activityType = properties.activityType { 75 | manager.activityType = activityType 76 | } 77 | if let allowsBackgroundLocationUpdates = properties.allowsBackgroundLocationUpdates { 78 | manager.allowsBackgroundLocationUpdates = allowsBackgroundLocationUpdates 79 | } 80 | #endif 81 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst) 82 | if let desiredAccuracy = properties.desiredAccuracy { 83 | manager.desiredAccuracy = desiredAccuracy 84 | } 85 | if let distanceFilter = properties.distanceFilter { 86 | manager.distanceFilter = distanceFilter 87 | } 88 | #endif 89 | #if os(iOS) || os(watchOS) || targetEnvironment(macCatalyst) 90 | if let headingFilter = properties.headingFilter { 91 | manager.headingFilter = headingFilter 92 | } 93 | if let headingOrientation = properties.headingOrientation { 94 | manager.headingOrientation = headingOrientation 95 | } 96 | #endif 97 | #if os(iOS) || targetEnvironment(macCatalyst) 98 | if let pausesLocationUpdatesAutomatically = properties.pausesLocationUpdatesAutomatically 99 | { 100 | manager.pausesLocationUpdatesAutomatically = pausesLocationUpdatesAutomatically 101 | } 102 | if let showsBackgroundLocationIndicator = properties.showsBackgroundLocationIndicator { 103 | manager.showsBackgroundLocationIndicator = showsBackgroundLocationIndicator 104 | } 105 | #endif 106 | } 107 | } 108 | 109 | #if os(iOS) || targetEnvironment(macCatalyst) 110 | manager.startMonitoringVisits = { id in 111 | .fireAndForget { dependencies[id]?.manager.startMonitoringVisits() } 112 | } 113 | #endif 114 | 115 | #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) 116 | manager.startUpdatingLocation = { id in 117 | .fireAndForget { dependencies[id]?.manager.startUpdatingLocation() } 118 | } 119 | #endif 120 | 121 | #if os(iOS) || targetEnvironment(macCatalyst) 122 | manager.stopMonitoringVisits = { id in 123 | .fireAndForget { dependencies[id]?.manager.stopMonitoringVisits() } 124 | } 125 | #endif 126 | 127 | manager.stopUpdatingLocation = { id in 128 | .fireAndForget { dependencies[id]?.manager.stopUpdatingLocation() } 129 | } 130 | 131 | return manager 132 | }() 133 | } 134 | 135 | private struct Dependencies { 136 | let delegate: LocationManagerDelegate 137 | let manager: CLLocationManager 138 | let subscriber: AnyObserver 139 | } 140 | 141 | private var dependencies: [AnyHashable: Dependencies] = [:] 142 | 143 | private class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { 144 | let subscriber: AnyObserver 145 | 146 | init(_ subscriber: AnyObserver) { 147 | self.subscriber = subscriber 148 | } 149 | 150 | func locationManager( 151 | _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus 152 | ) { 153 | subscriber.onNext(.didChangeAuthorization(status)) 154 | } 155 | 156 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 157 | subscriber.onNext(.didFailWithError(LocationManager.Error(error))) 158 | } 159 | 160 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 161 | subscriber.onNext(.didUpdateLocations(locations.map(Location.init(rawValue:)))) 162 | } 163 | 164 | #if os(macOS) 165 | func locationManager( 166 | _ manager: CLLocationManager, didUpdateTo newLocation: CLLocation, 167 | from oldLocation: CLLocation 168 | ) { 169 | subscriber.send( 170 | .didUpdateTo( 171 | newLocation: Location(rawValue: newLocation), 172 | oldLocation: Location(rawValue: oldLocation) 173 | ) 174 | ) 175 | } 176 | #endif 177 | 178 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 179 | func locationManager( 180 | _ manager: CLLocationManager, didFinishDeferredUpdatesWithError error: Error? 181 | ) { 182 | subscriber.onNext(.didFinishDeferredUpdatesWithError(error.map(LocationManager.Error.init))) 183 | } 184 | #endif 185 | 186 | #if os(iOS) || targetEnvironment(macCatalyst) 187 | func locationManagerDidPauseLocationUpdates(_ manager: CLLocationManager) { 188 | subscriber.onNext(.didPauseLocationUpdates) 189 | } 190 | #endif 191 | 192 | #if os(iOS) || targetEnvironment(macCatalyst) 193 | func locationManagerDidResumeLocationUpdates(_ manager: CLLocationManager) { 194 | subscriber.onNext(.didResumeLocationUpdates) 195 | } 196 | #endif 197 | 198 | #if os(iOS) || os(watchOS) || targetEnvironment(macCatalyst) 199 | func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { 200 | subscriber.onNext(.didUpdateHeading(newHeading: Heading(rawValue: newHeading))) 201 | } 202 | #endif 203 | 204 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 205 | func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { 206 | subscriber.onNext(.didEnterRegion(Region(rawValue: region))) 207 | } 208 | #endif 209 | 210 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 211 | func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { 212 | subscriber.onNext(.didExitRegion(Region(rawValue: region))) 213 | } 214 | #endif 215 | 216 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 217 | func locationManager( 218 | _ manager: CLLocationManager, didDetermineState state: CLRegionState, for region: CLRegion 219 | ) { 220 | subscriber.onNext(.didDetermineState(state, region: Region(rawValue: region))) 221 | } 222 | #endif 223 | 224 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 225 | func locationManager( 226 | _ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error 227 | ) { 228 | subscriber.onNext( 229 | .monitoringDidFail( 230 | region: region.map(Region.init(rawValue:)), error: LocationManager.Error(error))) 231 | } 232 | #endif 233 | 234 | #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) 235 | func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) { 236 | subscriber.onNext(.didStartMonitoring(region: Region(rawValue: region))) 237 | } 238 | #endif 239 | 240 | #if os(iOS) || targetEnvironment(macCatalyst) 241 | @available(iOS 13.0, *) 242 | func locationManager( 243 | _ manager: CLLocationManager, didRange beacons: [CLBeacon], 244 | satisfying beaconConstraint: CLBeaconIdentityConstraint 245 | ) { 246 | subscriber.onNext( 247 | .didRangeBeacons( 248 | beacons.map(Beacon.init(rawValue:)), satisfyingConstraint: beaconConstraint)) 249 | } 250 | #endif 251 | 252 | #if os(iOS) || targetEnvironment(macCatalyst) 253 | @available(iOS 13.0, *) 254 | func locationManager( 255 | _ manager: CLLocationManager, didFailRangingFor beaconConstraint: CLBeaconIdentityConstraint, 256 | error: Error 257 | ) { 258 | subscriber.onNext( 259 | .didFailRanging(beaconConstraint: beaconConstraint, error: LocationManager.Error(error))) 260 | } 261 | #endif 262 | 263 | #if os(iOS) || targetEnvironment(macCatalyst) 264 | func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) { 265 | subscriber.onNext(.didVisit(Visit(visit: visit))) 266 | } 267 | #endif 268 | } 269 | -------------------------------------------------------------------------------- /Tests/ComposableArchitectureTests/StoreTests.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | import RxTest 3 | import XCTest 4 | 5 | @testable import ComposableArchitecture 6 | 7 | final class StoreTests: XCTestCase { 8 | var disposeBag = DisposeBag() 9 | 10 | func testCancellableIsRemovedOnImmediatelyCompletingEffect() { 11 | let reducer = Reducer { _, _, _ in .none } 12 | let store = Store(initialState: (), reducer: reducer, environment: ()) 13 | 14 | XCTAssertEqual(store.effectDisposables.count, 0) 15 | 16 | store.send(()) 17 | 18 | XCTAssertEqual(store.effectDisposables.count, 0) 19 | } 20 | 21 | func testCancellableIsRemovedWhenEffectCompletes() { 22 | let scheduler = TestScheduler.default() 23 | let effect = Effect(value: ()) 24 | .delay(.seconds(1), scheduler: scheduler) 25 | .eraseToEffect() 26 | 27 | enum Action { case start, end } 28 | 29 | let reducer = Reducer { _, action, _ in 30 | switch action { 31 | case .start: 32 | return effect.map { .end } 33 | case .end: 34 | return .none 35 | } 36 | } 37 | let store = Store(initialState: (), reducer: reducer, environment: ()) 38 | 39 | XCTAssertEqual(store.effectDisposables.count, 0) 40 | 41 | store.send(.start) 42 | 43 | XCTAssertEqual(store.effectDisposables.count, 1) 44 | 45 | scheduler.advance(by: 2) 46 | 47 | XCTAssertEqual(store.effectDisposables.count, 0) 48 | } 49 | 50 | func testScopedStoreReceivesUpdatesFromParent() { 51 | let counterReducer = Reducer { state, _, _ in 52 | state += 1 53 | return .none 54 | } 55 | 56 | let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) 57 | let parentViewStore = ViewStore(parentStore) 58 | let childStore = parentStore.scope(state: String.init) 59 | 60 | var values: [String] = [] 61 | childStore.observable 62 | .subscribe(onNext: { values.append($0) }) 63 | .disposed(by: disposeBag) 64 | 65 | XCTAssertEqual(values, ["0"]) 66 | 67 | parentViewStore.send(()) 68 | 69 | XCTAssertEqual(values, ["0", "1"]) 70 | } 71 | 72 | func testParentStoreReceivesUpdatesFromChild() { 73 | let counterReducer = Reducer { state, _, _ in 74 | state += 1 75 | return .none 76 | } 77 | 78 | let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) 79 | let childStore = parentStore.scope(state: String.init) 80 | let childViewStore = ViewStore(childStore) 81 | 82 | var values: [Int] = [] 83 | parentStore.observable 84 | .subscribe(onNext: { values.append($0) }) 85 | .disposed(by: disposeBag) 86 | 87 | XCTAssertEqual(values, [0]) 88 | 89 | childViewStore.send(()) 90 | 91 | XCTAssertEqual(values, [0, 1]) 92 | } 93 | 94 | func testScopeWithPublisherTransform() { 95 | let counterReducer = Reducer { state, action, _ in 96 | state = action 97 | return .none 98 | } 99 | let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) 100 | 101 | var outputs: [String] = [] 102 | 103 | parentStore 104 | .scope(state: { $0.map { "\($0)" }.distinctUntilChanged() }) 105 | .subscribe(onNext: { childStore in 106 | childStore.observable 107 | .subscribe(onNext: { outputs.append($0) }) 108 | .disposed(by: self.disposeBag) 109 | }) 110 | .disposed(by: disposeBag) 111 | 112 | parentStore.send(0) 113 | XCTAssertEqual(outputs, ["0"]) 114 | parentStore.send(0) 115 | XCTAssertEqual(outputs, ["0"]) 116 | parentStore.send(1) 117 | XCTAssertEqual(outputs, ["0", "1"]) 118 | parentStore.send(1) 119 | XCTAssertEqual(outputs, ["0", "1"]) 120 | parentStore.send(2) 121 | XCTAssertEqual(outputs, ["0", "1", "2"]) 122 | } 123 | 124 | func testScopeCallCount() { 125 | let counterReducer = Reducer { state, _, _ in state += 1 126 | return .none 127 | } 128 | 129 | var numCalls1 = 0 130 | _ = Store(initialState: 0, reducer: counterReducer, environment: ()) 131 | .scope(state: { (count: Int) -> Int in 132 | numCalls1 += 1 133 | return count 134 | }) 135 | 136 | XCTAssertEqual(numCalls1, 2) 137 | } 138 | 139 | func testScopeCallCount2() { 140 | let counterReducer = Reducer { state, _, _ in 141 | state += 1 142 | return .none 143 | } 144 | 145 | var numCalls1 = 0 146 | var numCalls2 = 0 147 | var numCalls3 = 0 148 | 149 | let store = Store(initialState: 0, reducer: counterReducer, environment: ()) 150 | .scope(state: { (count: Int) -> Int in 151 | numCalls1 += 1 152 | return count 153 | }) 154 | .scope(state: { (count: Int) -> Int in 155 | numCalls2 += 1 156 | return count 157 | }) 158 | .scope(state: { (count: Int) -> Int in 159 | numCalls3 += 1 160 | return count 161 | }) 162 | 163 | XCTAssertEqual(numCalls1, 2) 164 | XCTAssertEqual(numCalls2, 2) 165 | XCTAssertEqual(numCalls3, 2) 166 | 167 | store.send(()) 168 | 169 | XCTAssertEqual(numCalls1, 4) 170 | XCTAssertEqual(numCalls2, 5) 171 | XCTAssertEqual(numCalls3, 6) 172 | 173 | store.send(()) 174 | 175 | XCTAssertEqual(numCalls1, 6) 176 | XCTAssertEqual(numCalls2, 8) 177 | XCTAssertEqual(numCalls3, 10) 178 | 179 | store.send(()) 180 | 181 | XCTAssertEqual(numCalls1, 8) 182 | XCTAssertEqual(numCalls2, 11) 183 | XCTAssertEqual(numCalls3, 14) 184 | } 185 | 186 | func testSynchronousEffectsSentAfterSinking() { 187 | enum Action { 188 | case tap 189 | case next1 190 | case next2 191 | case end 192 | } 193 | var values: [Int] = [] 194 | let counterReducer = Reducer { state, action, _ in 195 | switch action { 196 | case .tap: 197 | return .merge( 198 | Effect(value: .next1), 199 | Effect(value: .next2), 200 | .fireAndForget { values.append(1) } 201 | ) 202 | case .next1: 203 | return .merge( 204 | Effect(value: .end), 205 | .fireAndForget { values.append(2) } 206 | ) 207 | case .next2: 208 | return .fireAndForget { values.append(3) } 209 | case .end: 210 | return .fireAndForget { values.append(4) } 211 | } 212 | } 213 | 214 | let store = Store(initialState: (), reducer: counterReducer, environment: ()) 215 | 216 | store.send(.tap) 217 | 218 | XCTAssertEqual(values, [1, 2, 3, 4]) 219 | } 220 | 221 | func testLotsOfSynchronousActions() { 222 | enum Action { case incr, noop } 223 | let reducer = Reducer { state, action, _ in 224 | switch action { 225 | case .incr: 226 | state += 1 227 | return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) 228 | case .noop: 229 | return .none 230 | } 231 | } 232 | 233 | let store = Store(initialState: 0, reducer: reducer, environment: ()) 234 | store.send(.incr) 235 | XCTAssertEqual(ViewStore(store).state, 100_000) 236 | } 237 | 238 | func testPublisherScope() { 239 | let appReducer = Reducer { state, action, _ in 240 | state += action ? 1 : 0 241 | return .none 242 | } 243 | 244 | let parentStore = Store(initialState: 0, reducer: appReducer, environment: ()) 245 | 246 | var outputs: [Int] = [] 247 | 248 | parentStore 249 | .scope { $0.distinctUntilChanged() } 250 | .subscribe(onNext: { 251 | outputs.append($0.state) 252 | }) 253 | .disposed(by: disposeBag) 254 | 255 | XCTAssertEqual(outputs, [0]) 256 | 257 | parentStore.send(true) 258 | XCTAssertEqual(outputs, [0, 1]) 259 | 260 | parentStore.send(false) 261 | XCTAssertEqual(outputs, [0, 1]) 262 | parentStore.send(false) 263 | XCTAssertEqual(outputs, [0, 1]) 264 | parentStore.send(false) 265 | XCTAssertEqual(outputs, [0, 1]) 266 | parentStore.send(false) 267 | XCTAssertEqual(outputs, [0, 1]) 268 | } 269 | 270 | func testIfLetAfterScope() { 271 | struct AppState { 272 | var count: Int? 273 | } 274 | 275 | let appReducer = Reducer { state, action, _ in 276 | state.count = action 277 | return .none 278 | } 279 | 280 | let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) 281 | 282 | // NB: This test needs to hold a strong reference to the emitted stores 283 | var outputs: [Int?] = [] 284 | var stores: [Any] = [] 285 | 286 | parentStore 287 | .scope(state: \.count) 288 | .ifLet( 289 | then: { store in 290 | stores.append(store) 291 | outputs.append(store.state) 292 | }, 293 | else: { 294 | outputs.append(nil) 295 | } 296 | ) 297 | .disposed(by: disposeBag) 298 | 299 | XCTAssertEqual(outputs, [nil]) 300 | 301 | parentStore.send(1) 302 | XCTAssertEqual(outputs, [nil, 1]) 303 | 304 | parentStore.send(nil) 305 | XCTAssertEqual(outputs, [nil, 1, nil]) 306 | 307 | parentStore.send(1) 308 | XCTAssertEqual(outputs, [nil, 1, nil, 1]) 309 | 310 | parentStore.send(nil) 311 | XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) 312 | 313 | parentStore.send(1) 314 | XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) 315 | 316 | parentStore.send(nil) 317 | XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) 318 | } 319 | 320 | func testIfLetTwo() { 321 | let parentStore = Store( 322 | initialState: 0, 323 | reducer: Reducer { state, action, _ in 324 | if action { 325 | state? += 1 326 | return .none 327 | } else { 328 | return Effect(value: true) 329 | .observeOn(MainScheduler.instance) 330 | .eraseToEffect() 331 | } 332 | }, 333 | environment: () 334 | ) 335 | 336 | parentStore.ifLet { childStore in 337 | let vs = ViewStore(childStore) 338 | 339 | vs 340 | .publisher 341 | .subscribe(onNext: { _ in }) 342 | .disposed(by: self.disposeBag) 343 | 344 | vs.send(false) 345 | _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) 346 | vs.send(false) 347 | _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) 348 | vs.send(false) 349 | _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) 350 | XCTAssertEqual(vs.state, 3) 351 | } 352 | .disposed(by: disposeBag) 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Internal/Debug.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func debugOutput(_ value: Any, indent: Int = 0) -> String { 4 | var visitedItems: Set = [] 5 | 6 | func debugOutputHelp(_ value: Any, indent: Int = 0) -> String { 7 | let mirror = Mirror(reflecting: value) 8 | switch (value, mirror.displayStyle) { 9 | case let (value as CustomDebugOutputConvertible, _): 10 | return value.debugOutput.indent(by: indent) 11 | case (_, .collection?): 12 | return """ 13 | [ 14 | \(mirror.children.map { "\(debugOutput($0.value, indent: 2)),\n" }.joined())] 15 | """ 16 | .indent(by: indent) 17 | 18 | case (_, .dictionary?): 19 | let pairs = mirror.children.map { label, value -> String in 20 | let pair = value as! (key: AnyHashable, value: Any) 21 | return 22 | "\("\(debugOutputHelp(pair.key.base)): \(debugOutputHelp(pair.value)),".indent(by: 2))\n" 23 | } 24 | return """ 25 | [ 26 | \(pairs.sorted().joined())] 27 | """ 28 | .indent(by: indent) 29 | 30 | case (_, .set?): 31 | return """ 32 | Set([ 33 | \(mirror.children.map { "\(debugOutputHelp($0.value, indent: 2)),\n" }.sorted().joined())]) 34 | """ 35 | .indent(by: indent) 36 | 37 | case (_, .optional?): 38 | return mirror.children.isEmpty 39 | ? "nil".indent(by: indent) 40 | : debugOutputHelp(mirror.children.first!.value, indent: indent) 41 | 42 | case (_, .enum?) where !mirror.children.isEmpty: 43 | let child = mirror.children.first! 44 | let childMirror = Mirror(reflecting: child.value) 45 | let elements = 46 | childMirror.displayStyle != .tuple 47 | ? debugOutputHelp(child.value, indent: 2) 48 | : childMirror.children.map { child -> String in 49 | let label = child.label! 50 | return "\(label.hasPrefix(".") ? "" : "\(label): ")\(debugOutputHelp(child.value))" 51 | } 52 | .joined(separator: ",\n") 53 | .indent(by: 2) 54 | return """ 55 | \(mirror.subjectType).\(child.label!)( 56 | \(elements) 57 | ) 58 | """ 59 | .indent(by: indent) 60 | 61 | case (_, .enum?): 62 | return """ 63 | \(mirror.subjectType).\(value) 64 | """ 65 | .indent(by: indent) 66 | 67 | case (_, .struct?) where !mirror.children.isEmpty: 68 | let elements = mirror.children 69 | .map { "\($0.label.map { "\($0): " } ?? "")\(debugOutputHelp($0.value))".indent(by: 2) } 70 | .joined(separator: ",\n") 71 | return """ 72 | \(mirror.subjectType)( 73 | \(elements) 74 | ) 75 | """ 76 | .indent(by: indent) 77 | 78 | case let (value as AnyObject, .class?) 79 | where !mirror.children.isEmpty && !visitedItems.contains(ObjectIdentifier(value)): 80 | visitedItems.insert(ObjectIdentifier(value)) 81 | let elements = mirror.children 82 | .map { "\($0.label.map { "\($0): " } ?? "")\(debugOutputHelp($0.value))".indent(by: 2) } 83 | .joined(separator: ",\n") 84 | return """ 85 | \(mirror.subjectType)( 86 | \(elements) 87 | ) 88 | """ 89 | .indent(by: indent) 90 | 91 | case let (value as AnyObject, .class?) 92 | where !mirror.children.isEmpty && visitedItems.contains(ObjectIdentifier(value)): 93 | return "\(mirror.subjectType)(↩︎)" 94 | 95 | case let (value as CustomStringConvertible, .class?): 96 | return value.description 97 | .replacingOccurrences( 98 | of: #"^<([^:]+): 0x[^>]+>$"#, with: "$1()", options: .regularExpression 99 | ) 100 | .indent(by: indent) 101 | 102 | case let (value as CustomDebugStringConvertible, _): 103 | return value.debugDescription 104 | .replacingOccurrences( 105 | of: #"^<([^:]+): 0x[^>]+>$"#, with: "$1()", options: .regularExpression 106 | ) 107 | .indent(by: indent) 108 | 109 | case let (value as CustomStringConvertible, _): 110 | return value.description 111 | .indent(by: indent) 112 | 113 | case (_, .struct?), (_, .class?): 114 | return "\(mirror.subjectType)()" 115 | .indent(by: indent) 116 | 117 | case (_, .tuple?) where mirror.children.isEmpty: 118 | return "()" 119 | .indent(by: indent) 120 | 121 | case (_, .tuple?): 122 | let elements = mirror.children.map { child -> String in 123 | let label = child.label! 124 | return "\(label.hasPrefix(".") ? "" : "\(label): ")\(debugOutputHelp(child.value))" 125 | .indent(by: 2) 126 | } 127 | return """ 128 | ( 129 | \(elements.joined(separator: ",\n")) 130 | ) 131 | """ 132 | .indent(by: indent) 133 | 134 | case (_, nil): 135 | return "\(value)" 136 | .indent(by: indent) 137 | 138 | @unknown default: 139 | return "\(value)" 140 | .indent(by: indent) 141 | } 142 | } 143 | 144 | return debugOutputHelp(value, indent: indent) 145 | } 146 | 147 | func debugDiff(_ before: T, _ after: T, printer: (T) -> String = { debugOutput($0) }) -> String? 148 | { 149 | diff(printer(before), printer(after)) 150 | } 151 | 152 | extension String { 153 | func indent(by indent: Int) -> String { 154 | let indentation = String(repeating: " ", count: indent) 155 | return indentation + self.replacingOccurrences(of: "\n", with: "\n\(indentation)") 156 | } 157 | } 158 | 159 | public protocol CustomDebugOutputConvertible { 160 | var debugOutput: String { get } 161 | } 162 | 163 | extension Date: CustomDebugOutputConvertible { 164 | public var debugOutput: String { 165 | dateFormatter.string(from: self) 166 | } 167 | } 168 | 169 | private let dateFormatter: ISO8601DateFormatter = { 170 | let formatter = ISO8601DateFormatter() 171 | formatter.timeZone = TimeZone(identifier: "UTC")! 172 | return formatter 173 | }() 174 | 175 | extension DispatchQueue: CustomDebugOutputConvertible { 176 | public var debugOutput: String { 177 | switch (self, self.label) { 178 | case (.main, _): return "DispatchQueue.main" 179 | case (_, "com.apple.root.default-qos"): return "DispatchQueue.global()" 180 | case (_, _) where self.label == "com.apple.root.\(self.qos.qosClass)-qos": 181 | return "DispatchQueue.global(qos: .\(self.qos.qosClass))" 182 | default: 183 | return "DispatchQueue(label: \(self.label.debugDescription), qos: .\(self.qos.qosClass))" 184 | } 185 | } 186 | } 187 | 188 | extension Effect: CustomDebugOutputConvertible { 189 | public var debugOutput: String { 190 | var empty: Any? 191 | var just: Any? 192 | var mergeMany: [Any] = [] 193 | var path: [String] = [] 194 | var transform: Any? 195 | 196 | func updatePath(_ value: Any) { 197 | let mirror = Mirror(reflecting: value) 198 | let subjectType = "\(mirror.subjectType)" 199 | 200 | // if subjectType.hasPrefix("Deferred<"), let value = value as? Invokable { 201 | // updatePath(value()) 202 | // } 203 | if subjectType.hasPrefix("Concatenate<") { 204 | let prefix = mirror.children.first(where: { label, _ in label == "prefix" })!.value 205 | let suffix = mirror.children.first(where: { label, _ in label == "suffix" })!.value 206 | mergeMany.append(contentsOf: [prefix, suffix]) 207 | return 208 | } 209 | if subjectType.hasPrefix("Delay<") { 210 | let interval = mirror.children.first(where: { label, _ in label == "interval" })!.value 211 | let scheduler = mirror.children.first(where: { label, _ in label == "scheduler" })!.value 212 | let ns = Int("\(Mirror(reflecting: interval).children.first!.value)")! 213 | path.append( 214 | "\n.delay(for: \(Double(ns) / Double(NSEC_PER_SEC)), scheduler: \(ComposableArchitecture.debugOutput(scheduler)))" 215 | ) 216 | } 217 | if subjectType.hasPrefix("Empty<") { 218 | let completeImmediately = mirror.children.first(where: { label, _ in 219 | label == "completeImmediately" 220 | })!.value 221 | empty = completeImmediately 222 | } 223 | if subjectType.hasPrefix("Just<") { 224 | just = mirror.children.first!.value 225 | } 226 | if subjectType.hasPrefix("Map<") { 227 | transform = mirror.children.first(where: { label, _ in label == "transform" })!.value 228 | } 229 | if subjectType.hasPrefix("MergeMany<") { 230 | let publishers = mirror.children.first(where: { label, _ in label == "publishers" })!.value 231 | mergeMany.append(contentsOf: Mirror(reflecting: publishers).children.map { $0.value }) 232 | return 233 | } 234 | if subjectType.hasPrefix("ReceiveOn<") { 235 | let scheduler = mirror.children.first(where: { label, _ in label == "scheduler" })!.value 236 | path.append("\n.receive(on: \(ComposableArchitecture.debugOutput(scheduler)))") 237 | } 238 | 239 | mirror.children.forEach { _, v in updatePath(v) } 240 | } 241 | 242 | updatePath(self) 243 | 244 | guard mergeMany.isEmpty else { 245 | return 246 | ComposableArchitecture 247 | .debugOutput(mergeMany.filter { !ComposableArchitecture.debugOutput($0).isEmpty }) 248 | } 249 | guard empty == nil else { return "" } 250 | 251 | if let value = just, let transform = transform { 252 | let transform = withUnsafePointer(to: transform) { 253 | $0.withMemoryRebound(to: ((Any) -> Output).self, capacity: 1, { $0.pointee }) 254 | } 255 | just = transform(value) 256 | } 257 | 258 | let operators = path.reversed().joined() 259 | return """ 260 | \(type(of: self))(\ 261 | \(just.map { "\n\("value: \(ComposableArchitecture.debugOutput($0))".indent(by: 2))\n" } ?? "")\ 262 | )\(operators.indent(by: !operators.isEmpty && just == nil ? 2 : 0)) 263 | """ 264 | } 265 | } 266 | 267 | extension OperationQueue: CustomDebugOutputConvertible { 268 | public var debugOutput: String { 269 | switch (self, self.name) { 270 | case (.main, _): return "OperationQueue.main" 271 | default: return "OperationQueue()" 272 | } 273 | } 274 | } 275 | 276 | extension RunLoop: CustomDebugOutputConvertible { 277 | public var debugOutput: String { 278 | switch self { 279 | case .main: return "RunLoop.main" 280 | default: return "RunLoop()" 281 | } 282 | } 283 | } 284 | 285 | extension URL: CustomDebugOutputConvertible { 286 | public var debugOutput: String { 287 | self.absoluteString 288 | } 289 | } 290 | 291 | #if DEBUG 292 | #if canImport(CoreLocation) 293 | import CoreLocation 294 | extension CLAuthorizationStatus: CustomDebugOutputConvertible { 295 | public var debugOutput: String { 296 | switch self { 297 | case .notDetermined: 298 | return "notDetermined" 299 | case .restricted: 300 | return "restricted" 301 | case .denied: 302 | return "denied" 303 | case .authorizedAlways: 304 | return "authorizedAlways" 305 | case .authorizedWhenInUse: 306 | return "authorizedWhenInUse" 307 | @unknown default: 308 | return "unknown" 309 | } 310 | } 311 | } 312 | #endif 313 | 314 | #if canImport(Speech) 315 | import Speech 316 | extension SFSpeechRecognizerAuthorizationStatus: CustomDebugOutputConvertible { 317 | public var debugOutput: String { 318 | switch self { 319 | case .notDetermined: 320 | return "notDetermined" 321 | case .denied: 322 | return "denied" 323 | case .restricted: 324 | return "restricted" 325 | case .authorized: 326 | return "authorized" 327 | @unknown default: 328 | return "unknown" 329 | } 330 | } 331 | } 332 | #endif 333 | #endif 334 | -------------------------------------------------------------------------------- /Sources/ComposableArchitecture/Effect.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | /// The `Effect` type encapsulates a unit of work that can be run in the outside world, and can feed 5 | /// data back to the `Store`. It is the perfect place to do side effects, such as network requests, 6 | /// saving/loading from disk, creating timers, interacting with dependencies, and more. 7 | /// 8 | /// Effects are returned from reducers so that the `Store` can perform the effects after the reducer 9 | /// is done running. It is important to note that `Store` is not thread safe, and so all effects 10 | /// must receive values on the same thread, **and** if the store is being used to drive UI then it 11 | /// must receive values on the main thread. 12 | /// 13 | /// An effect simply wraps a `Publisher` value and provides some convenience initializers for 14 | /// constructing some common types of effects. 15 | public struct Effect: ObservableType { 16 | public typealias Element = Output 17 | 18 | public let upstream: Observable 19 | 20 | /// Initializes an effect that wraps a publisher. Each emission of the wrapped publisher will be 21 | /// emitted by the effect. 22 | /// 23 | /// This initializer is useful for turning any publisher into an effect. For example: 24 | /// 25 | /// Effect( 26 | /// NotificationCenter.default 27 | /// .publisher(for: UIApplication.userDidTakeScreenshotNotification) 28 | /// ) 29 | /// 30 | /// Alternatively, you can use the `.eraseToEffect()` method that is defined on the `Publisher` 31 | /// protocol: 32 | /// 33 | /// NotificationCenter.default 34 | /// .publisher(for: UIApplication.userDidTakeScreenshotNotification) 35 | /// .eraseToEffect() 36 | /// 37 | /// - Parameter publisher: A publisher. 38 | public init(_ observable: Observable) { 39 | self.upstream = observable 40 | } 41 | 42 | public func subscribe( 43 | _ observer: Observer 44 | ) -> Disposable where Observer: ObserverType, Element == Observer.Element { 45 | upstream.subscribe(observer) 46 | } 47 | 48 | /// Initializes an effect that immediately emits the value passed in. 49 | /// 50 | /// - Parameter value: The value that is immediately emitted by the effect. 51 | public init(value: Output) { 52 | self.init(Observable.just(value)) 53 | } 54 | 55 | /// Initializes an effect that immediately fails with the error passed in. 56 | /// 57 | /// - Parameter error: The error that is immediately emitted by the effect. 58 | public init(error: Error) { 59 | self.init(Observable.error(error)) 60 | } 61 | 62 | /// An effect that does nothing and completes immediately. Useful for situations where you must 63 | /// return an effect, but you don't need to do anything. 64 | public static var none: Effect { 65 | Observable.empty().eraseToEffect() 66 | } 67 | 68 | /// Creates an effect that can supply a single value asynchronously in the future. 69 | /// 70 | /// This can be helpful for converting APIs that are callback-based into ones that deal with 71 | /// `Effect`s. 72 | /// 73 | /// For example, to create an effect that delivers an integer after waiting a second: 74 | /// 75 | /// Effect.future { callback in 76 | /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 77 | /// callback(.success(42)) 78 | /// } 79 | /// } 80 | /// 81 | /// Note that you can only deliver a single value to the `callback`. If you send more they will be 82 | /// discarded: 83 | /// 84 | /// Effect.future { callback in 85 | /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 86 | /// callback(.success(42)) 87 | /// callback(.success(1729)) // Will not be emitted by the effect 88 | /// } 89 | /// } 90 | /// 91 | /// If you need to deliver more than one value to the effect, you should use the `Effect` 92 | /// initializer that accepts a `Subscriber` value. 93 | /// 94 | /// - Parameter attemptToFulfill: A closure that takes a `callback` as an argument which can be 95 | /// used to feed it `Result` values. 96 | public static func future( 97 | _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void 98 | ) -> Effect { 99 | Observable.create { observer in 100 | attemptToFulfill { result in 101 | switch result { 102 | case let .success(output): 103 | observer.onNext(output) 104 | observer.onCompleted() 105 | case let .failure(error): 106 | observer.onError(error) 107 | } 108 | } 109 | 110 | return Disposables.create() 111 | } 112 | .eraseToEffect() 113 | } 114 | 115 | /// Initializes an effect that lazily executes some work in the real world and synchronously sends 116 | /// that data back into the store. 117 | /// 118 | /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: 119 | /// 120 | /// Effect.result { 121 | /// let fileUrl = URL( 122 | /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( 123 | /// .documentDirectory, .userDomainMask, true 124 | /// )[0] 125 | /// ) 126 | /// .appendingPathComponent("user.json") 127 | /// 128 | /// let result = Result { 129 | /// let data = try Data(contentsOf: fileUrl) 130 | /// return try JSONDecoder().decode(User.self, from: $0) 131 | /// } 132 | /// 133 | /// return result 134 | /// } 135 | /// 136 | /// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world. 137 | /// - Returns: An effect. 138 | public static func result(_ attemptToFulfill: @escaping () -> Result) -> Self { 139 | Observable.create { observer in 140 | switch attemptToFulfill() { 141 | case let .success(output): 142 | observer.onNext(output) 143 | observer.onCompleted() 144 | case let .failure(error): 145 | observer.onError(error) 146 | } 147 | 148 | return Disposables.create() 149 | } 150 | .eraseToEffect() 151 | } 152 | 153 | /// Initializes an effect from a callback that can send as many values as it wants, and can send 154 | /// a completion. 155 | /// 156 | /// This initializer is useful for bridging callback APIs, delegate APIs, and manager APIs to the 157 | /// `Effect` type. One can wrap those APIs in an Effect so that its events are sent through the 158 | /// effect, which allows the reducer to handle them. 159 | /// 160 | /// For example, one can create an effect to ask for access to `MPMediaLibrary`. It can start by 161 | /// sending the current status immediately, and then if the current status is `notDetermined` it 162 | /// can request authorization, and once a status is received it can send that back to the effect: 163 | /// 164 | /// Effect.run { subscriber in 165 | /// subscriber.send(MPMediaLibrary.authorizationStatus()) 166 | /// 167 | /// guard MPMediaLibrary.authorizationStatus() == .notDetermined else { 168 | /// subscriber.send(completion: .finished) 169 | /// return AnyCancellable {} 170 | /// } 171 | /// 172 | /// MPMediaLibrary.requestAuthorization { status in 173 | /// subscriber.send(status) 174 | /// subscriber.send(completion: .finished) 175 | /// } 176 | /// return AnyCancellable { 177 | /// // Typically clean up resources that were created here, but this effect doesn't 178 | /// // have any. 179 | /// } 180 | /// } 181 | /// 182 | /// - Parameter work: A closure that accepts a `Subscriber` value and returns a cancellable. When 183 | /// the `Effect` is completed, the cancellable will be used to clean up any resources created 184 | /// when the effect was started. 185 | public static func run( 186 | _ work: @escaping (AnyObserver) -> Disposable 187 | ) -> Self { 188 | Observable.create(work).eraseToEffect() 189 | } 190 | 191 | /// Concatenates a variadic list of effects together into a single effect, which runs the effects 192 | /// one after the other. 193 | /// 194 | /// - Parameter effects: A variadic list of effects. 195 | /// - Returns: A new effect 196 | public static func concatenate(_ effects: Effect...) -> Effect { 197 | .concatenate(effects) 198 | } 199 | 200 | /// Concatenates a collection of effects together into a single effect, which runs the effects one 201 | /// after the other. 202 | /// 203 | /// - Parameter effects: A collection of effects. 204 | /// - Returns: A new effect 205 | public static func concatenate( 206 | _ effects: C 207 | ) -> Effect where C.Element == Effect { 208 | guard let first = effects.first else { return .none } 209 | 210 | return 211 | effects 212 | .dropFirst() 213 | .reduce(into: first) { effects, effect in 214 | effects = effects.concat(effect).eraseToEffect() 215 | } 216 | } 217 | 218 | /// Merges a variadic list of effects together into a single effect, which runs the effects at the 219 | /// same time. 220 | /// 221 | /// - Parameter effects: A list of effects. 222 | /// - Returns: A new effect 223 | public static func merge( 224 | _ effects: Effect... 225 | ) -> Effect { 226 | .merge(effects) 227 | } 228 | 229 | /// Merges a sequence of effects together into a single effect, which runs the effects at the same 230 | /// time. 231 | /// 232 | /// - Parameter effects: A sequence of effects. 233 | /// - Returns: A new effect 234 | public static func merge(_ effects: S) -> Effect where S.Element == Effect { 235 | Observable 236 | .merge(effects.map { $0.asObservable() }) 237 | .eraseToEffect() 238 | } 239 | 240 | /// Creates an effect that executes some work in the real world that doesn't need to feed data 241 | /// back into the store. 242 | /// 243 | /// - Parameter work: A closure encapsulating some work to execute in the real world. 244 | /// - Returns: An effect. 245 | public static func fireAndForget(_ work: @escaping () -> Void) -> Effect { 246 | return Effect( 247 | Observable.deferred { 248 | work() 249 | return Observable.empty() 250 | }) 251 | } 252 | 253 | /// Transforms all elements from the upstream effect with a provided closure. 254 | /// 255 | /// - Parameter transform: A closure that transforms the upstream effect's output to a new output. 256 | /// - Returns: A publisher that uses the provided closure to map elements from the upstream effect 257 | /// to new elements that it then publishes. 258 | public func map(_ transform: @escaping (Output) -> T) -> Effect { 259 | .init(self.map(transform)) 260 | } 261 | } 262 | 263 | extension Effect where Output == Never { 264 | /// Upcasts an `Effect` to an `Effect` for any type `T`. This is 265 | /// possible to do because an `Effect` can never produce any values to feed back 266 | /// into the store (hence the name "fire and forget"), and therefore we can act like it's an 267 | /// effect that produces values of any type (since it never produces values). 268 | /// 269 | /// This is useful for times you have an `Effect` but need to massage it into 270 | /// another type in order to return it from a reducer: 271 | /// 272 | /// case .buttonTapped: 273 | /// return analyticsClient.track("Button Tapped") 274 | /// .fireAndForget() 275 | /// 276 | /// - Returns: An effect. 277 | public func fireAndForget() -> Effect { 278 | func absurd(_ never: Never) -> A {} 279 | return self.map(absurd) 280 | } 281 | } 282 | 283 | extension ObservableType { 284 | /// Turns any publisher into an `Effect`. 285 | /// 286 | /// This can be useful for when you perform a chain of publisher transformations in a reducer, and 287 | /// you need to convert that publisher to an effect so that you can return it from the reducer: 288 | /// 289 | /// case .buttonTapped: 290 | /// return fetchUser(id: 1) 291 | /// .filter(\.isAdmin) 292 | /// .eraseToEffect() 293 | /// 294 | /// - Returns: An effect that wraps `self`. 295 | public func eraseToEffect() -> Effect { 296 | Effect(asObservable()) 297 | } 298 | 299 | /// Turns any publisher into an `Effect` that cannot fail by wrapping its output and failure in a 300 | /// result. 301 | /// 302 | /// This can be useful when you are working with a failing API but want to deliver its data to an 303 | /// action that handles both success and failure. 304 | /// 305 | /// case .buttonTapped: 306 | /// return fetchUser(id: 1) 307 | /// .catchToEffect() 308 | /// .map(ProfileAction.userResponse) 309 | /// 310 | /// - Returns: An effect that wraps `self`. 311 | public func catchToEffect() -> Effect> { 312 | self.map(Result.success) 313 | .catchError { Observable>.just(Result.failure($0)) } 314 | .eraseToEffect() 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /Sources/ComposableCoreLocation/Mock.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import CoreLocation 3 | import ComposableArchitecture 4 | 5 | extension LocationManager { 6 | /// The mock implementation of the `LocationManager` interface. By default this implementation 7 | /// stubs all of its endpoints as functions that immediately `fatalError`. So, to construct a 8 | /// mock you will invoke the `.mock` static method, and provide implementations for all of the 9 | /// endpoints that you expect your test to need access to. 10 | /// 11 | /// This allows you to test an even deeper property of your features: that they use only the 12 | /// location manager endpoints that you specify and nothing else. This can be useful as a 13 | /// measurement of just how complex a particular test is. Tests that need to stub many endpoints 14 | /// are in some sense more complicated than tests that only need to stub a few endpoints. It's 15 | /// not necessarily a bad thing to stub many endpoints. Sometimes it's needed. 16 | /// 17 | /// As an example, to create a mock manager that simulates a location manager that has already 18 | /// authorized access to location, and when a location is requested it immediately responds 19 | /// with a mock location we can do something like this: 20 | /// 21 | /// // Send actions to this subject to simulate the location manager's delegate methods 22 | /// // being called. 23 | /// let locationManagerSubject = PassthroughSubject() 24 | /// 25 | /// // The mock location we want the manager to say we are located at 26 | /// let mockLocation = Location( 27 | /// coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958), 28 | /// // A whole bunch of other properties have been omitted. 29 | /// ) 30 | /// 31 | /// let manager = LocationManager.mock( 32 | /// // Override any CLLocationManager endpoints your test invokes: 33 | /// 34 | /// authorizationStatus: { .authorizedAlways }, 35 | /// create: { _ in locationManagerSubject.eraseToEffect() }, 36 | /// locationServicesEnabled: { true }, 37 | /// requestLocation: { _ in 38 | /// .fireAndForget { locationManagerSubject.send(.didUpdateLocations([mockLocation])) } 39 | /// } 40 | /// ) 41 | /// 42 | @available(macOS, unavailable) 43 | @available(tvOS, unavailable) 44 | @available(watchOS, unavailable) 45 | public static func mock( 46 | authorizationStatus: @escaping () -> CLAuthorizationStatus = { 47 | _unimplemented("authorizationStatus") 48 | }, 49 | create: @escaping (_ id: AnyHashable) -> Effect = { _ in 50 | _unimplemented("create") 51 | }, 52 | destroy: @escaping (AnyHashable) -> Effect = { _ in _unimplemented("destroy") }, 53 | dismissHeadingCalibrationDisplay: @escaping (AnyHashable) -> Effect = { _ in 54 | _unimplemented("dismissHeadingCalibrationDisplay") 55 | }, 56 | heading: @escaping (AnyHashable) -> Heading? = { _ in _unimplemented("heading") }, 57 | headingAvailable: @escaping () -> Bool = { _unimplemented("headingAvailable") }, 58 | isRangingAvailable: @escaping () -> Bool = { _unimplemented("isRangingAvailable") }, 59 | location: @escaping (AnyHashable) -> Location? = { _ in _unimplemented("location") }, 60 | locationServicesEnabled: @escaping () -> Bool = { _unimplemented("locationServicesEnabled") }, 61 | maximumRegionMonitoringDistance: @escaping (AnyHashable) -> CLLocationDistance = { _ in 62 | _unimplemented("maximumRegionMonitoringDistance") 63 | }, 64 | monitoredRegions: @escaping (AnyHashable) -> Set = { _ in 65 | _unimplemented("monitoredRegions") 66 | }, 67 | requestAlwaysAuthorization: @escaping (AnyHashable) -> Effect = { _ in 68 | _unimplemented("requestAlwaysAuthorization") 69 | }, 70 | requestLocation: @escaping (AnyHashable) -> Effect = { _ in 71 | _unimplemented("requestLocation") 72 | }, 73 | requestWhenInUseAuthorization: @escaping (AnyHashable) -> Effect = { _ in 74 | _unimplemented("requestWhenInUseAuthorization") 75 | }, 76 | set: @escaping (_ id: AnyHashable, _ properties: Properties) -> Effect = { 77 | _, _ in _unimplemented("set") 78 | }, 79 | significantLocationChangeMonitoringAvailable: @escaping () -> Bool = { 80 | _unimplemented("significantLocationChangeMonitoringAvailable") 81 | }, 82 | startMonitoringSignificantLocationChanges: @escaping (AnyHashable) -> Effect = { 83 | _ in _unimplemented("startMonitoringSignificantLocationChanges") 84 | }, 85 | startMonitoringForRegion: @escaping (AnyHashable, Region) -> Effect = { _, _ in 86 | _unimplemented("startMonitoringForRegion") 87 | }, 88 | startMonitoringVisits: @escaping (AnyHashable) -> Effect = { _ in 89 | _unimplemented("startMonitoringVisits") 90 | }, 91 | startUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 92 | _unimplemented("startUpdatingLocation") 93 | }, 94 | stopMonitoringSignificantLocationChanges: @escaping (AnyHashable) -> Effect = { 95 | _ in _unimplemented("stopMonitoringSignificantLocationChanges") 96 | }, 97 | stopMonitoringForRegion: @escaping (AnyHashable, Region) -> Effect = { _, _ in 98 | _unimplemented("stopMonitoringForRegion") 99 | }, 100 | stopMonitoringVisits: @escaping (AnyHashable) -> Effect = { _ in 101 | _unimplemented("stopMonitoringVisits") 102 | }, 103 | startUpdatingHeading: @escaping (AnyHashable) -> Effect = { _ in 104 | _unimplemented("startUpdatingHeading") 105 | }, 106 | stopUpdatingHeading: @escaping (AnyHashable) -> Effect = { _ in 107 | _unimplemented("stopUpdatingHeading") 108 | }, 109 | stopUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 110 | _unimplemented("stopUpdatingLocation") 111 | } 112 | ) -> Self { 113 | Self( 114 | authorizationStatus: authorizationStatus, 115 | create: create, 116 | destroy: destroy, 117 | dismissHeadingCalibrationDisplay: dismissHeadingCalibrationDisplay, 118 | heading: heading, 119 | headingAvailable: headingAvailable, 120 | isRangingAvailable: isRangingAvailable, 121 | location: location, 122 | locationServicesEnabled: locationServicesEnabled, 123 | maximumRegionMonitoringDistance: maximumRegionMonitoringDistance, 124 | monitoredRegions: monitoredRegions, 125 | requestAlwaysAuthorization: requestAlwaysAuthorization, 126 | requestLocation: requestLocation, 127 | requestWhenInUseAuthorization: requestWhenInUseAuthorization, 128 | set: set, 129 | significantLocationChangeMonitoringAvailable: significantLocationChangeMonitoringAvailable, 130 | startMonitoringForRegion: startMonitoringForRegion, 131 | startMonitoringSignificantLocationChanges: startMonitoringSignificantLocationChanges, 132 | startMonitoringVisits: startMonitoringVisits, 133 | startUpdatingHeading: startUpdatingHeading, 134 | startUpdatingLocation: startUpdatingLocation, 135 | stopMonitoringForRegion: stopMonitoringForRegion, 136 | stopMonitoringSignificantLocationChanges: stopMonitoringSignificantLocationChanges, 137 | stopMonitoringVisits: stopMonitoringVisits, 138 | stopUpdatingHeading: stopUpdatingHeading, 139 | stopUpdatingLocation: stopUpdatingLocation 140 | ) 141 | } 142 | 143 | @available(iOS, unavailable) 144 | @available(macCatalyst, unavailable) 145 | @available(macOS, unavailable) 146 | @available(tvOS, unavailable) 147 | public static func mock( 148 | authorizationStatus: @escaping () -> CLAuthorizationStatus = { 149 | _unimplemented("authorizationStatus") 150 | }, 151 | create: @escaping (_ id: AnyHashable) -> Effect = { _ in 152 | _unimplemented("create") 153 | }, 154 | destroy: @escaping (AnyHashable) -> Effect = { _ in _unimplemented("destroy") }, 155 | dismissHeadingCalibrationDisplay: @escaping (AnyHashable) -> Effect = { _ in 156 | _unimplemented("dismissHeadingCalibrationDisplay") 157 | }, 158 | heading: @escaping (AnyHashable) -> Heading? = { _ in _unimplemented("heading") }, 159 | headingAvailable: @escaping () -> Bool = { _unimplemented("headingAvailable") }, 160 | location: @escaping (AnyHashable) -> Location? = { _ in _unimplemented("location") }, 161 | locationServicesEnabled: @escaping () -> Bool = { _unimplemented("locationServicesEnabled") }, 162 | requestAlwaysAuthorization: @escaping (AnyHashable) -> Effect = { _ in 163 | _unimplemented("requestAlwaysAuthorization") 164 | }, 165 | requestLocation: @escaping (AnyHashable) -> Effect = { _ in 166 | _unimplemented("requestLocation") 167 | }, 168 | requestWhenInUseAuthorization: @escaping (AnyHashable) -> Effect = { _ in 169 | _unimplemented("requestWhenInUseAuthorization") 170 | }, 171 | set: @escaping (_ id: AnyHashable, _ properties: Properties) -> Effect = { 172 | _, _ in _unimplemented("set") 173 | }, 174 | startUpdatingHeading: @escaping (AnyHashable) -> Effect = { _ in 175 | _unimplemented("startUpdatingHeading") 176 | }, 177 | startUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 178 | _unimplemented("startUpdatingLocation") 179 | }, 180 | stopUpdatingHeading: @escaping (AnyHashable) -> Effect = { _ in 181 | _unimplemented("stopUpdatingHeading") 182 | }, 183 | stopUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 184 | _unimplemented("stopUpdatingLocation") 185 | } 186 | ) -> Self { 187 | Self( 188 | authorizationStatus: authorizationStatus, 189 | create: create, 190 | destroy: destroy, 191 | dismissHeadingCalibrationDisplay: dismissHeadingCalibrationDisplay, 192 | heading: heading, 193 | headingAvailable: headingAvailable, 194 | location: location, 195 | locationServicesEnabled: locationServicesEnabled, 196 | requestAlwaysAuthorization: requestAlwaysAuthorization, 197 | requestLocation: requestLocation, 198 | requestWhenInUseAuthorization: requestWhenInUseAuthorization, 199 | set: set, 200 | startUpdatingHeading: startUpdatingHeading, 201 | startUpdatingLocation: startUpdatingLocation, 202 | stopUpdatingHeading: stopUpdatingHeading, 203 | stopUpdatingLocation: stopUpdatingLocation 204 | ) 205 | } 206 | 207 | @available(iOS, unavailable) 208 | @available(macCatalyst, unavailable) 209 | @available(macOS, unavailable) 210 | @available(watchOS, unavailable) 211 | public static func mock( 212 | authorizationStatus: @escaping () -> CLAuthorizationStatus = { 213 | _unimplemented("authorizationStatus") 214 | }, 215 | create: @escaping (_ id: AnyHashable) -> Effect = { _ in 216 | _unimplemented("create") 217 | }, 218 | destroy: @escaping (AnyHashable) -> Effect = { _ in _unimplemented("destroy") }, 219 | location: @escaping (AnyHashable) -> Location? = { _ in _unimplemented("location") }, 220 | locationServicesEnabled: @escaping () -> Bool = { _unimplemented("locationServicesEnabled") }, 221 | requestLocation: @escaping (AnyHashable) -> Effect = { _ in 222 | _unimplemented("requestLocation") 223 | }, 224 | requestWhenInUseAuthorization: @escaping (AnyHashable) -> Effect = { _ in 225 | _unimplemented("requestWhenInUseAuthorization") 226 | }, 227 | set: @escaping (_ id: AnyHashable, _ properties: Properties) -> Effect = { 228 | _, _ in _unimplemented("set") 229 | }, 230 | stopUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 231 | _unimplemented("stopUpdatingLocation") 232 | } 233 | ) -> Self { 234 | Self( 235 | authorizationStatus: authorizationStatus, 236 | create: create, 237 | destroy: destroy, 238 | location: location, 239 | locationServicesEnabled: locationServicesEnabled, 240 | requestLocation: requestLocation, 241 | requestWhenInUseAuthorization: requestWhenInUseAuthorization, 242 | set: set, 243 | stopUpdatingLocation: stopUpdatingLocation 244 | ) 245 | } 246 | 247 | @available(iOS, unavailable) 248 | @available(macCatalyst, unavailable) 249 | @available(tvOS, unavailable) 250 | @available(watchOS, unavailable) 251 | public static func mock( 252 | authorizationStatus: @escaping () -> CLAuthorizationStatus = { 253 | _unimplemented("authorizationStatus") 254 | }, 255 | create: @escaping (_ id: AnyHashable) -> Effect = { _ in 256 | _unimplemented("create") 257 | }, 258 | destroy: @escaping (AnyHashable) -> Effect = { _ in _unimplemented("destroy") }, 259 | headingAvailable: @escaping () -> Bool = { _unimplemented("headingAvailable") }, 260 | location: @escaping (AnyHashable) -> Location? = { _ in _unimplemented("location") }, 261 | locationServicesEnabled: @escaping () -> Bool = { _unimplemented("locationServicesEnabled") }, 262 | maximumRegionMonitoringDistance: @escaping (AnyHashable) -> CLLocationDistance = { _ in 263 | _unimplemented("maximumRegionMonitoringDistance") 264 | }, 265 | monitoredRegions: @escaping (AnyHashable) -> Set = { _ in 266 | _unimplemented("monitoredRegions") 267 | }, 268 | requestAlwaysAuthorization: @escaping (AnyHashable) -> Effect = { _ in 269 | _unimplemented("requestAlwaysAuthorization") 270 | }, 271 | requestLocation: @escaping (AnyHashable) -> Effect = { _ in 272 | _unimplemented("requestLocation") 273 | }, 274 | set: @escaping (_ id: AnyHashable, _ properties: Properties) -> Effect = { 275 | _, _ in _unimplemented("set") 276 | }, 277 | significantLocationChangeMonitoringAvailable: @escaping () -> Bool = { 278 | _unimplemented("significantLocationChangeMonitoringAvailable") 279 | }, 280 | startMonitoringForRegion: @escaping (AnyHashable, Region) -> Effect = { _, _ in 281 | _unimplemented("startMonitoringForRegion") 282 | }, 283 | startMonitoringSignificantLocationChanges: @escaping (AnyHashable) -> Effect = { 284 | _ in _unimplemented("startMonitoringSignificantLocationChanges") 285 | }, 286 | startUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 287 | _unimplemented("startUpdatingLocation") 288 | }, 289 | stopMonitoringForRegion: @escaping (AnyHashable, Region) -> Effect = { _, _ in 290 | _unimplemented("stopMonitoringForRegion") 291 | }, 292 | stopMonitoringSignificantLocationChanges: @escaping (AnyHashable) -> Effect = { 293 | _ in _unimplemented("stopMonitoringSignificantLocationChanges") 294 | }, 295 | stopUpdatingLocation: @escaping (AnyHashable) -> Effect = { _ in 296 | _unimplemented("stopUpdatingLocation") 297 | } 298 | ) -> Self { 299 | Self( 300 | authorizationStatus: authorizationStatus, 301 | create: create, 302 | destroy: destroy, 303 | headingAvailable: headingAvailable, 304 | location: location, 305 | locationServicesEnabled: locationServicesEnabled, 306 | maximumRegionMonitoringDistance: maximumRegionMonitoringDistance, 307 | monitoredRegions: monitoredRegions, 308 | requestAlwaysAuthorization: requestAlwaysAuthorization, 309 | requestLocation: requestLocation, 310 | set: set, 311 | significantLocationChangeMonitoringAvailable: significantLocationChangeMonitoringAvailable, 312 | startMonitoringForRegion: startMonitoringForRegion, 313 | startMonitoringSignificantLocationChanges: startMonitoringSignificantLocationChanges, 314 | startUpdatingLocation: startUpdatingLocation, 315 | stopMonitoringForRegion: stopMonitoringForRegion, 316 | stopMonitoringSignificantLocationChanges: stopMonitoringSignificantLocationChanges, 317 | stopUpdatingLocation: stopUpdatingLocation 318 | ) 319 | } 320 | } 321 | #endif 322 | 323 | public func _unimplemented( 324 | _ function: StaticString, file: StaticString = #file, line: UInt = #line 325 | ) -> Never { 326 | fatalError( 327 | """ 328 | `\(function)` was called but is not implemented. Be sure to provide an implementation for 329 | this endpoint when creating the mock. 330 | """, 331 | file: file, 332 | line: line 333 | ) 334 | } 335 | --------------------------------------------------------------------------------