diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index e2d02c5..cedfcf7 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + E189A4AE2776179D0090EC9F /* GlobalSchedulersClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4AD2776179D0090EC9F /* GlobalSchedulersClient.swift */; }; + E189A4AF2776179D0090EC9F /* GlobalSchedulersClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4AD2776179D0090EC9F /* GlobalSchedulersClient.swift */; }; + E189A4B1277619040090EC9F /* NoOptionsScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4B0277619040090EC9F /* NoOptionsScheduler.swift */; }; + E189A4B2277619040090EC9F /* NoOptionsScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4B0277619040090EC9F /* NoOptionsScheduler.swift */; }; + E189A4B427761B9F0090EC9F /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4B327761B9F0090EC9F /* Helpers.swift */; }; + E189A4B527761B9F0090EC9F /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E189A4B327761B9F0090EC9F /* Helpers.swift */; }; E972F1FD26A2B74A00504D39 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972F1EA26A2B74A00504D39 /* ExampleApp.swift */; }; E972F1FE26A2B74B00504D39 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972F1EA26A2B74A00504D39 /* ExampleApp.swift */; }; E972F20126A2B74B00504D39 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E972F1EC26A2B74A00504D39 /* Assets.xcassets */; }; @@ -24,6 +30,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + E189A4AD2776179D0090EC9F /* GlobalSchedulersClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSchedulersClient.swift; sourceTree = ""; }; + E189A4B0277619040090EC9F /* NoOptionsScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoOptionsScheduler.swift; sourceTree = ""; }; + E189A4B327761B9F0090EC9F /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; E972F1EA26A2B74A00504D39 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; E972F1EC26A2B74A00504D39 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E972F1F126A2B74A00504D39 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -75,6 +84,9 @@ E972F21B26A2B9B900504D39 /* Level1Feature.swift */, E972F21E26A2BA8400504D39 /* Level2Feature.swift */, E972F22126A2C72200504D39 /* SharedDependencies.swift */, + E189A4AD2776179D0090EC9F /* GlobalSchedulersClient.swift */, + E189A4B0277619040090EC9F /* NoOptionsScheduler.swift */, + E189A4B327761B9F0090EC9F /* Helpers.swift */, E972F1EA26A2B74A00504D39 /* ExampleApp.swift */, E972F1EC26A2B74A00504D39 /* Assets.xcassets */, ); @@ -222,6 +234,9 @@ E972F21C26A2B9B900504D39 /* Level1Feature.swift in Sources */, E972F21F26A2BA8400504D39 /* Level2Feature.swift in Sources */, E972F21926A2B9A700504D39 /* Level0Feature.swift in Sources */, + E189A4B427761B9F0090EC9F /* Helpers.swift in Sources */, + E189A4B1277619040090EC9F /* NoOptionsScheduler.swift in Sources */, + E189A4AE2776179D0090EC9F /* GlobalSchedulersClient.swift in Sources */, E972F22226A2C72200504D39 /* SharedDependencies.swift in Sources */, E972F1FD26A2B74A00504D39 /* ExampleApp.swift in Sources */, ); @@ -234,6 +249,9 @@ E972F21D26A2B9B900504D39 /* Level1Feature.swift in Sources */, E972F22026A2BA8400504D39 /* Level2Feature.swift in Sources */, E972F21A26A2B9A700504D39 /* Level0Feature.swift in Sources */, + E189A4B527761B9F0090EC9F /* Helpers.swift in Sources */, + E189A4B2277619040090EC9F /* NoOptionsScheduler.swift in Sources */, + E189A4AF2776179D0090EC9F /* GlobalSchedulersClient.swift in Sources */, E972F22326A2C72200504D39 /* SharedDependencies.swift in Sources */, E972F1FE26A2B74B00504D39 /* ExampleApp.swift in Sources */, ); diff --git a/Example/Shared/ExampleApp.swift b/Example/Shared/ExampleApp.swift index 409fa92..e08fc60 100644 --- a/Example/Shared/ExampleApp.swift +++ b/Example/Shared/ExampleApp.swift @@ -2,21 +2,20 @@ import ComposableArchitecture import ComposableEnvironment import SwiftUI -let store = Store( - initialState: - .init(level1: .init( +let store = Store( + initialState: Level0State(level1: .init( first: .init(randomNumber: nil), second: .init(randomNumber: nil) )), reducer: level0Reducer, - environment: .init() + environment: Level0Environment() ) @main struct ExampleApp: App { var body: some Scene { WindowGroup { - Level0View(store: store) + Level0View(store) } } } diff --git a/Example/Shared/GlobalSchedulersClient.swift b/Example/Shared/GlobalSchedulersClient.swift new file mode 100644 index 0000000..3a2b28f --- /dev/null +++ b/Example/Shared/GlobalSchedulersClient.swift @@ -0,0 +1,24 @@ +import Combine +import Foundation + +public struct GlobalSchedulersClient { + public init( + _ scheduler: @escaping (DispatchQoS.QoSClass) -> NoOptionsSchedulerOf + ) { + self.scheduler = scheduler + } + + public var scheduler: (DispatchQoS.QoSClass) -> NoOptionsSchedulerOf + + public func callAsFunction( + qos: DispatchQoS.QoSClass = .default + ) -> NoOptionsSchedulerOf { + return scheduler(qos) + } +} + +extension GlobalSchedulersClient { + public static let live: GlobalSchedulersClient = .init { qos in + return DispatchQueue.global(qos: qos).ignoreOptions() + } +} diff --git a/Example/Shared/Helpers.swift b/Example/Shared/Helpers.swift new file mode 100644 index 0000000..2ee2837 --- /dev/null +++ b/Example/Shared/Helpers.swift @@ -0,0 +1,6 @@ +// for some reason ReferenceWritableKeyPath is not converible to function via type-inference in pullback +func get( + _ keyPath: ReferenceWritableKeyPath +) -> (Object) -> Value { + return { $0[keyPath: keyPath] } +} diff --git a/Example/Shared/Level0Feature.swift b/Example/Shared/Level0Feature.swift index 20d293c..dd4a92e 100644 --- a/Example/Shared/Level0Feature.swift +++ b/Example/Shared/Level0Feature.swift @@ -17,58 +17,83 @@ enum Level0Action { } class Level0Environment: ComposableEnvironment { - // Dependencies - @Dependency(\.mainQueue) var main - @Dependency(\.backgroundQueue) var background - - // Derived Environments - @DerivedEnvironment var level1 + // This style of using property-wrappers allows to + // find derived environment type a bit easier, then DerivedEnvironment + // and visual composition is a bit better, so we keep wrapper as a simple annotation + // and all of the information that is related to a property is on one line + @DerivedEnvironment + var schedulers: StoreSchedulers + + @DerivedEnvironment + var level1: Level1Environment } -let level0Reducer = Reducer.combine( - level1Reducer.pullback(state: \.level1, - action: /Level0Action.level1, - environment: \.level1), - Reducer { - state, action, environment in +// You may prefer to define type explicitly to avoid using `get` helper +// +// let level0Reducer = Reducer< +// Level0State, +// Level0Action, +// Level0Environment +// >.combine( +// level1Reducer.pullback( +// state: \.level1, +// action: /Level0Action.level1, +// environment: \.level1 +// ), +// ... +// ) + +let level0Reducer = Reducer.combine( + level1Reducer.pullback( + state: \Level0State.level1, + action: /Level0Action.level1, + environment: get(\Level0Environment.level1) + ), + Reducer { state, action, environment in switch action { case .isReady: state.isReady = true return .none + case .level1: return .none + case .onAppear: return Effect(value: .isReady) - .delay(for: 1, scheduler: environment.background) // Simulate something lengthy… - .receive(on: environment.main) + .delay(for: 1, scheduler: environment.schedulers.background()) // Simulate something lengthy… + .receive(on: environment.schedulers.main) .eraseToEffect() - - // Alternatively, we can directly tap into the environment's dependepencies using - // their global property name, meaning that we can even bypass declarations like - // `@Dependency(\.mainQueue) var main` in the environment to write: - // - // return Effect(value: .isReady) - // .delay(for: 1, scheduler: environment.backgroundQueue) - // .receive(on: environment.mainQueue) - // .eraseToEffect() } } ) +// Alternatively, we can directly tap into the environment's dependepencies using +// their global property name, meaning that we can even bypass declarations like +// `@Dependency(\.mainQueue) var main` in the environment to write: +// +// return Effect(value: .isReady) +// .delay(for: 1, scheduler: environment.globalSchedulers(qos: .default)) +// .receive(on: environment.mainQueue) +// .eraseToEffect() + struct Level0View: View { let store: Store - init(store: Store) { + + init(_ store: Store) { self.store = store } - + var body: some View { WithViewStore(store) { viewStore in VStack { Text("Random numbers") .font(.title) - Level1View(store: store.scope(state: \.level1, action: Level0Action.level1)) - .padding() - .disabled(!viewStore.isReady) + Level1View(store.scope( + state: \.level1, + action: Level0Action.level1 + )) + .padding() + .disabled(!viewStore.isReady) } .onAppear { viewStore.send(.onAppear) } .fixedSize() @@ -78,30 +103,28 @@ struct Level0View: View { struct Level0View_Preview: PreviewProvider { static var previews: some View { - Level0View(store: - .init(initialState: - .init( - level1: .init( - first: .init(randomNumber: 6), - second: .init(randomNumber: nil) - ) - ), - reducer: level0Reducer, - environment: Level0Environment() // Swift ≥ 5.4 can use .init() - .with(\.mainQueue, .immediate) - .with(\.backgroundQueue, .immediate) - // We can set the value of `rng` even if Level0Environment doesn't have a `rng` property: - .with(\.rng) { 4 }) - ) - Level0View(store: - .init(initialState: - .init(level1: .init( - first: .init(randomNumber: nil), + Level0View(Store( + initialState: .init( + level1: .init( + first: .init(randomNumber: 6), second: .init(randomNumber: nil) - )), - reducer: level0Reducer, - // An environment default dependencies: - environment: .init()) - ) + ) + ), + reducer: level0Reducer, + environment: Level0Environment() // Swift ≥ 5.4 can use .init() + .with(\.eventHandlingScheduler, AnyScheduler.immediate.ignoreOptions()) + .with(\.globalSchedulers, .init { _ in AnyScheduler.immediate.ignoreOptions() }) + // We can set the value of `rng` even if Level0Environment doesn't have a `rng` property: + .with(\.rng) { 4 } + )) + Level0View(Store( + initialState:.init(level1: .init( + first: .init(randomNumber: nil), + second: .init(randomNumber: nil) + )), + reducer: level0Reducer, + // An environment default dependencies: + environment: .init() + )) } } diff --git a/Example/Shared/Level1Feature.swift b/Example/Shared/Level1Feature.swift index 02f099c..3880339 100644 --- a/Example/Shared/Level1Feature.swift +++ b/Example/Shared/Level1Feature.swift @@ -14,44 +14,55 @@ enum Level1Action { case second(Level2Action) } -class Level1Environment: ComposableEnvironment { - @DerivedEnvironment var first - @DerivedEnvironment var second +// In this case, we could have used a shared `@DerivedEnvironment` property instead: +// @DerivedEnvironment var level2 +// And used its `KeyPath` `\.level2` twice when pulling-back in `level1Reducer` - // In this case, we could have used a shared `@DerivedEnvironment` property instead: - // @DerivedEnvironment var level2 - // And used its `KeyPath` `\.level2` twice when pulling-back in `level1Reducer` +// This environment doesn't have exposed dependencies, but this doesn't prevent derived +// environments to inherit dependencies that were set higher in the parents' chain, nor +// to access them using their global KeyPath. - // This environment doesn't have exposed dependencies, but this doesn't prevent derived - // environments to inherit dependencies that were set higher in the parents' chain, nor - // to access them using their global KeyPath. +class Level1Environment: ComposableEnvironment { + @DerivedEnvironment + var first: Level2Environment + + @DerivedEnvironment + var second: Level2Environment } // Alternatively, if we plan to use environment-less pullback variants, we can only declare an // empty environment: // class Level1Environment: ComposableEnvironment { } -let level1Reducer = Reducer.combine( - level2Reducer.pullback(state: \.first, - action: /Level1Action.first, - environment: \.first), // (or \.level2 if we had used only one property) - - level2Reducer.pullback(state: \.second, - action: /Level1Action.second, - environment: \.second) // (or \.level2 if we had used only one property) - - // Alternatively, we can forgo the `@DerivedEnvironment` declarations in `Level1Environment`, and - // use the environment-less pullback variants: - // level2Reducer.pullback(state: \.first, - // action: /Level1Action.first) - // - // level2Reducer.pullback(state: \.second, - // action: /Level1Action.second) +let level1Reducer = Reducer.combine( + level2Reducer.pullback( + state: \Level1State.first, + action: /Level1Action.first, + environment: get(\Level1Environment.first) + ), + level2Reducer.pullback( + state: \Level1State.second, + action: /Level1Action.second, + environment: get(\Level1Environment.second) + ) // (or \.level2 if we had used only one property) ) +// Alternatively, we can forgo the `@DerivedEnvironment` declarations in `Level1Environment`, and +// use the environment-less pullback variants: +// level2Reducer.pullback( +// state: \.first, +// action: /Level1Action.first +// ) +// +// level2Reducer.pullback( +// state: \.second, +// action: /Level1Action.second +// ) + struct Level1View: View { let store: Store - init(store: Store) { + + init(_ store: Store) { self.store = store } @@ -60,14 +71,20 @@ struct Level1View: View { VStack { Text("First random number") .font(.title3) - Level2View(store: store.scope(state: \.first, action: Level1Action.first)) - .padding() + Level2View(store.scope( + state: \.first, + action: Level1Action.first + )) + .padding() } VStack { Text("Second random number") .font(.title3) - Level2View(store: store.scope(state: \.second, action: Level1Action.second)) - .padding() + Level2View(store.scope( + state: \.second, + action: Level1Action.second + )) + .padding() } } } @@ -81,14 +98,13 @@ struct Level1View: View { struct Level1View_Preview: PreviewProvider { static var previews: some View { - Level1View(store: - .init(initialState: - .init( - first: .init(randomNumber: 6), - second: .init(randomNumber: nil) - ), - reducer: level1Reducer, - environment: .init()) - ) + Level1View(Store( + initialState: .init( + first: .init(randomNumber: 6), + second: .init(randomNumber: nil) + ), + reducer: level1Reducer, + environment: .init() + )) } } diff --git a/Example/Shared/Level2Feature.swift b/Example/Shared/Level2Feature.swift index 0d008a4..c2afc27 100644 --- a/Example/Shared/Level2Feature.swift +++ b/Example/Shared/Level2Feature.swift @@ -33,7 +33,8 @@ enum Level2Action { } class Level2Environment: ComposableEnvironment { - @Dependency(\.rng) var randomNumberGenerator + @Dependency(\.rng) + var randomNumberGenerator func randomNumber() -> Future { .init { [randomNumberGenerator] in @@ -43,26 +44,31 @@ class Level2Environment: ComposableEnvironment { } } -let level2Reducer = Reducer { - state, action, environment in +let level2Reducer = Reducer< + Level2State, + Level2Action, + Level2Environment +> { state, action, environment in switch action { case let .randomNumber(number): state.randomNumber = number return .none + case .requestRandomNumber: // Note that we don't have defined any `@Dependency(\.mainQueue)` in environment. // We use its global property name instead: return environment .randomNumber() .map(Level2Action.randomNumber) - .receive(on: environment.mainQueue) + .receive(on: environment.eventHandlingScheduler) .eraseToEffect() } } struct Level2View: View { let store: Store - init(store: Store) { + + init(_ store: Store) { self.store = store } @@ -86,27 +92,23 @@ struct Level2View: View { struct Level2View_Preview: PreviewProvider { static var previews: some View { - Level2View(store: - .init(initialState: - .init( - randomNumber: 5 - ), - reducer: level2Reducer, - environment: - Level2Environment() // Swift ≥ 5.4 can use .init() - .with(\.mainQueue, .immediate) - .with(\.rng) { 12 }) - ) - Level2View(store: - .init(initialState: - .init( - randomNumber: nil - ), - reducer: level2Reducer, - environment: - Level2Environment() // Swift ≥ 5.4 can use .init() - .with(\.mainQueue, .immediate) - .with(\.rng) { 54 }) - ) + Level2View(Store( + initialState: .init( + randomNumber: 5 + ), + reducer: level2Reducer, + environment: Level2Environment() // Swift ≥ 5.4 can use .init() + .with(\.eventHandlingScheduler, AnyScheduler.immediate.ignoreOptions()) + .with(\.rng) { 12 } + )) + Level2View(Store( + initialState: .init( + randomNumber: nil + ), + reducer: level2Reducer, + environment: Level2Environment() // Swift ≥ 5.4 can use .init() + .with(\.eventHandlingScheduler, AnyScheduler.immediate.ignoreOptions()) + .with(\.rng) { 54 } + )) } } diff --git a/Example/Shared/NoOptionsScheduler.swift b/Example/Shared/NoOptionsScheduler.swift new file mode 100644 index 0000000..279f23e --- /dev/null +++ b/Example/Shared/NoOptionsScheduler.swift @@ -0,0 +1,40 @@ +// https://github.com/CaptureContext/combine-extensions/blob/main/Sources/CombineExtensions/Schedulers/NoOptionsSchedulerOf.swift + +import CombineSchedulers +import Combine + +// Enables you to ignore scheduler options and use DispatchQueue or UIScheduler as NoOptionsSchedulerOf +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public typealias NoOptionsSchedulerOf = CombineSchedulers.AnyScheduler< + Scheduler.SchedulerTimeType, Never +> where Scheduler: Combine.Scheduler + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Scheduler { + public func ignoreOptions() -> NoOptionsSchedulerOf { + AnyScheduler( + minimumTolerance: { self.minimumTolerance }, + now: { self.now }, + scheduleImmediately: { options, action in + self.schedule(options: nil, action) + }, + delayed: { date, tolerance, options, action in + self.schedule( + after: date, + tolerance: tolerance, + options: nil, + action + ) + }, + interval: { date, interval, tolerance, options, action in + self.schedule( + after: date, + interval: interval, + tolerance: tolerance, + options: nil, + action + ) + } + ) + } +} diff --git a/Example/Shared/SharedDependencies.swift b/Example/Shared/SharedDependencies.swift index 1a78d94..7d5e8be 100644 --- a/Example/Shared/SharedDependencies.swift +++ b/Example/Shared/SharedDependencies.swift @@ -1,30 +1,40 @@ import ComposableEnvironment import ComposableArchitecture -// mainQueue dependency: -private struct MainQueueKey: DependencyKey { - static var defaultValue: AnySchedulerOf { - .main +// MARK: - EventHandlingScheduler + +// We keep this naming for the case if you want to use some background scheduler +// for your store events for some reason +private enum EventHandlingSchedulerKey: DependencyKey { + public static var defaultValue: NoOptionsSchedulerOf { + UIScheduler.shared.eraseToAnyScheduler() } } -public extension ComposableDependencies { - var mainQueue: AnySchedulerOf { - get { self[MainQueueKey.self] } - set { self[MainQueueKey.self] = newValue } +extension ComposableDependencies { + public var eventHandlingScheduler: NoOptionsSchedulerOf { + get { self[EventHandlingSchedulerKey.self] } + set { self[EventHandlingSchedulerKey.self] = newValue } } } -// backgroundQueue dependency: -private struct BackgroundQueueKey: DependencyKey { - static var defaultValue: AnySchedulerOf { - DispatchQueue.global().eraseToAnyScheduler() - } +// MARK: - GlobalSchedulers + +private enum GlobalSchedulersKey: DependencyKey { + public static var defaultValue: GlobalSchedulersClient { .live } } -public extension ComposableDependencies { - var backgroundQueue: AnySchedulerOf { - get { self[BackgroundQueueKey.self] } - set { self[BackgroundQueueKey.self] = newValue } +extension ComposableDependencies { + public var globalSchedulers: GlobalSchedulersClient { + get { self[GlobalSchedulersKey.self] } + set { self[GlobalSchedulersKey.self] = newValue } } } + +public final class StoreSchedulers: ComposableEnvironment { + @Dependency(\.eventHandlingScheduler) + public var main + + @Dependency(\.globalSchedulers) + public var background +}