From d03e9ec1c36ae9536c33ed5f23a4a7b79ad92f1e Mon Sep 17 00:00:00 2001 From: sergdort Date: Sun, 13 Jun 2021 12:44:01 +0100 Subject: [PATCH] Add Dependency in Feedback --- CombineFeedback.xcodeproj/project.pbxproj | 23 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- Example/CounterExample/Counter.swift | 3 +- Example/MoviesExample/Movies.swift | 13 +- Example/SceneDelegate.swift | 3 +- Example/SignIn/SignIn.swift | 15 +- .../Movies/MoviesState.swift | 132 ++++----- .../Signin/SigninState.swift | 222 +++++++------- Example/SingleStoreExample/State.swift | 34 ++- .../TrafficLight/TrafficLightState.swift | 148 +++++----- Example/TrafficLight/TrafficLight.swift | 15 +- Example/Views/StoreExtensions.swift | 2 +- Package.swift | 6 +- Sources/CombineFeedback/Atomic.swift | 112 ++++---- Sources/CombineFeedback/Feedback.swift | 139 +++++---- .../FeedbackEventConsumer.swift | 58 ++-- Sources/CombineFeedback/FeedbackLoop.swift | 54 ++-- Sources/CombineFeedback/FlatMapLatest.swift | 48 ++-- Sources/CombineFeedback/Floodgate.swift | 272 +++++++++--------- .../CombineFeedback/NSLockExtensions.swift | 10 +- Sources/CombineFeedback/Reducer.swift | 70 ++--- Sources/CombineFeedback/System.swift | 36 +-- Sources/CombineFeedbackUI/Store/Store.swift | 10 +- .../CombineFeedbackUI/Store/StoreBox.swift | 23 +- Sources/CombineFeedbackUI/ViewContext.swift | 4 +- .../CombineFeedbackUI/WithViewContext.swift | 4 +- 26 files changed, 796 insertions(+), 680 deletions(-) diff --git a/CombineFeedback.xcodeproj/project.pbxproj b/CombineFeedback.xcodeproj/project.pbxproj index baf9aaf..4607726 100644 --- a/CombineFeedback.xcodeproj/project.pbxproj +++ b/CombineFeedback.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 252BF08422BAE05700BC4265 /* SignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252BF08322BAE05700BC4265 /* SignIn.swift */; }; 253D324122B185FA002F3B7F /* ViewContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 253D323F22B1858C002F3B7F /* ViewContext.swift */; }; 254B4DB726755AE200653BB8 /* StoreBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 254B4DB626755AE200653BB8 /* StoreBox.swift */; }; + 254B4DBA2676650000653BB8 /* CombineSchedulers in Frameworks */ = {isa = PBXBuildFile; productRef = 254B4DB92676650000653BB8 /* CombineSchedulers */; }; 25C57B2C22BC2C33007CB4D6 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C57B2B22BC2C33007CB4D6 /* Activity.swift */; }; 25EBC08C23FD61B100719826 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD878239AC7D3004BE9CC /* Reducer.swift */; }; 25F23C2922CA984E00894863 /* TrafficLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F23C2822CA984E00894863 /* TrafficLight.swift */; }; @@ -195,6 +196,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 254B4DBA2676650000653BB8 /* CombineSchedulers in Frameworks */, 5822A2302434FEB400270514 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -307,8 +309,8 @@ 5800FFAB22A89C08005A860B /* FlatMapLatest.swift */, 58751A9523EC823C00EEF398 /* Atomic.swift */, 5800FFAC22A89C08005A860B /* System.swift */, - 5800FF9422A89BE6005A860B /* Info.plist */, 585CD878239AC7D3004BE9CC /* Reducer.swift */, + 5800FF9422A89BE6005A860B /* Info.plist */, ); path = CombineFeedback; sourceTree = ""; @@ -522,6 +524,7 @@ name = CombineFeedback; packageProductDependencies = ( 5822A22F2434FEB400270514 /* CasePaths */, + 254B4DB92676650000653BB8 /* CombineSchedulers */, ); productName = CombineFeedback; productReference = 5800FF9022A89BE6005A860B /* CombineFeedback.framework */; @@ -669,6 +672,7 @@ mainGroup = 5800FF8622A89BE6005A860B; packageReferences = ( 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */, + 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */, ); productRefGroup = 5800FF9122A89BE6005A860B /* Products */; projectDirPath = ""; @@ -1165,6 +1169,7 @@ DEVELOPMENT_TEAM = 3JZ7RUJD4Q; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Example/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1186,6 +1191,7 @@ DEVELOPMENT_TEAM = 3JZ7RUJD4Q; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Example/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1306,17 +1312,30 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/combine-schedulers.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.0; + }; + }; 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "git@github.com:pointfreeco/swift-case-paths.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.1.0; + minimumVersion = 0.2.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 254B4DB92676650000653BB8 /* CombineSchedulers */ = { + isa = XCSwiftPackageProductDependency; + package = 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */; + productName = CombineSchedulers; + }; 5822A22F2434FEB400270514 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */; diff --git a/CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2456dc1..bf66c28 100644 --- a/CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,30 @@ { "object": { "pins": [ + { + "package": "combine-schedulers", + "repositoryURL": "https://github.com/pointfreeco/combine-schedulers.git", + "state": { + "branch": null, + "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841", + "version": "0.5.0" + } + }, { "package": "swift-case-paths", "repositoryURL": "git@github.com:pointfreeco/swift-case-paths.git", "state": { "branch": null, - "revision": "a9c1e05518b6d95cf5844d823020376f2b6ff842", + "revision": "a313f0cc10e07bb5ce7e2ff5da600cce7efa8e8a", + "version": "0.2.0" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518", "version": "0.1.0" } } diff --git a/Example/CounterExample/Counter.swift b/Example/CounterExample/Counter.swift index a55e45d..889f5af 100644 --- a/Example/CounterExample/Counter.swift +++ b/Example/CounterExample/Counter.swift @@ -9,7 +9,8 @@ extension Counter { super.init( initial: State(), feedbacks: [], - reducer: Counter.reducer() + reducer: Counter.reducer(), + dependency: () ) } } diff --git a/Example/MoviesExample/Movies.swift b/Example/MoviesExample/Movies.swift index 42d00a1..3dc1c06 100644 --- a/Example/MoviesExample/Movies.swift +++ b/Example/MoviesExample/Movies.swift @@ -11,7 +11,7 @@ extension Movies { movies: [], status: .loading ) - var feedbacks: [Feedback] { + var feedbacks: [Feedback] { if #available(iOS 15.0, *) { return [ ViewModel.whenLoadingIOS15() @@ -27,12 +27,13 @@ extension Movies { super.init( initial: initial, feedbacks: [ViewModel.whenLoading()], - reducer: Movies.reducer() + reducer: Movies.reducer(), + dependency: () ) } - private static func whenLoading() -> Feedback { - .lensing(state: { $0.nextPage }) { page in + private static func whenLoading() -> Feedback { + .lensing(state: { $0.nextPage }) { page, _ in URLSession.shared .fetchMovies(page: page) .map(Event.didLoad) @@ -42,8 +43,8 @@ extension Movies { } @available(iOS 15.0, *) - private static func whenLoadingIOS15() -> Feedback { - .lensing(state: \.nextPage) { page in + private static func whenLoadingIOS15() -> Feedback { + .lensing(state: \.nextPage) { page, _ in do { return Event.didLoad(try await URLSession.shared.movies(page: page)) } catch { diff --git a/Example/SceneDelegate.swift b/Example/SceneDelegate.swift index 304f275..cddc53e 100644 --- a/Example/SceneDelegate.swift +++ b/Example/SceneDelegate.swift @@ -25,7 +25,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { signInFeedback, trafficLightFeedback ], - reducer: appReducer + reducer: appReducer, + dependency: AppDependency() ) ) ) diff --git a/Example/SignIn/SignIn.swift b/Example/SignIn/SignIn.swift index fd68c3f..66e4ba9 100644 --- a/Example/SignIn/SignIn.swift +++ b/Example/SignIn/SignIn.swift @@ -12,12 +12,13 @@ extension SignIn { ViewModel.whenChangingUserName(api: GithubAPI()), ViewModel.whenSubmitting(api: GithubAPI()) ], - reducer: SignIn.reducer() + reducer: SignIn.reducer(), + dependency: () ) } - static func whenChangingUserName(api: GithubAPI) -> Feedback { - return Feedback.custom { state, consumer in + static func whenChangingUserName(api: GithubAPI) -> Feedback { + return Feedback.custom { state, consumer, _ in state .map { $0.0.userName @@ -37,14 +38,14 @@ extension SignIn { } } - static func whenSubmitting(api: GithubAPI) -> Feedback { - return .middleware { (state) -> AnyPublisher in + static func whenSubmitting(api: GithubAPI) -> Feedback { + return .middleware { (state, _) -> AnyPublisher in guard state.status.isSubmitting else { return Empty().eraseToAnyPublisher() } return api - .singIn(username: state.userName, email: state.email, password: state.password) + .signIn(username: state.userName, email: state.email, password: state.password) .map(Event.didSignIn) .eraseToAnyPublisher() } @@ -153,7 +154,7 @@ final class GithubAPI { .eraseToAnyPublisher() } - func singIn(username: String, email: String, password: String) -> AnyPublisher { + func signIn(username: String, email: String, password: String) -> AnyPublisher { // Fake implementation return Result.Publisher(true) .delay(for: 0.3, scheduler: DispatchQueue.main) diff --git a/Example/SingleStoreExample/Movies/MoviesState.swift b/Example/SingleStoreExample/Movies/MoviesState.swift index 9550faa..a0636f1 100644 --- a/Example/SingleStoreExample/Movies/MoviesState.swift +++ b/Example/SingleStoreExample/Movies/MoviesState.swift @@ -1,81 +1,85 @@ import Foundation import CombineFeedback +import Combine enum Movies { - struct State: Equatable { - var batch: Results - var movies: [Movie] - var status: Status + struct Dependencies { + var movies: (Int) async throws -> Results + var fetchMovies: (Int) -> AnyPublisher + } - var nextPage: Int? { - switch status { - case .loading: - return batch.page + 1 - case .failed: - return nil - case .idle: - return nil - } - } + struct State: Equatable { + var batch: Results + var movies: [Movie] + var status: Status - var error: NSError? { - switch status { - case .failed(let error): - return error - default: - return nil - } - } + var nextPage: Int? { + switch status { + case .loading: + return batch.page + 1 + case .failed: + return nil + case .idle: + return nil + } + } - enum Status: Equatable { - case idle - case loading - case failed(NSError) - } + var error: NSError? { + switch status { + case .failed(let error): + return error + default: + return nil + } } - enum Event { - case didLoad(Results) - case didFail(NSError) - case retry - case fetchNext + enum Status: Equatable { + case idle + case loading + case failed(NSError) } + } - static func reducer() -> Reducer { - .init { state, event in - switch event { - case .didLoad(let batch): - state.movies += batch.results - state.status = .idle - state.batch = batch - case .didFail(let error): - state.status = .failed(error) - case .retry: - state.status = .loading - case .fetchNext: - state.status = .loading - } - } + enum Event { + case didLoad(Results) + case didFail(NSError) + case retry + case fetchNext + } + + static func reducer() -> Reducer { + .init { state, event in + switch event { + case .didLoad(let batch): + state.movies += batch.results + state.status = .idle + state.batch = batch + case .didFail(let error): + state.status = .failed(error) + case .retry: + state.status = .loading + case .fetchNext: + state.status = .loading + } } + } - static var feedback: Feedback { - if #available(iOS 15.0, *) { - return .lensing(state: \.nextPage) { page in - do { - return Event.didLoad(try await URLSession.shared.movies(page: page)) - } catch { - return Event.didFail(error as NSError) - } - } - } else { - return .lensing(state: { $0.nextPage }) { page in - URLSession.shared - .fetchMovies(page: page) - .map(Event.didLoad) - .replaceError(replace: Event.didFail) - .receive(on: DispatchQueue.main) + static var feedback: Feedback { + if #available(iOS 15.0, *) { + return .lensing(state: \.nextPage) { page, dependency in + do { + return Event.didLoad(try await URLSession.shared.movies(page: page)) + } catch { + return Event.didFail(error as NSError) } } + } else { + return .lensing(state: { $0.nextPage }) { page, dependency in + dependency.fetchMovies(page) + .map(Event.didLoad) + .replaceError(replace: Event.didFail) + .receive(on: DispatchQueue.main) + } } - + } } diff --git a/Example/SingleStoreExample/Signin/SigninState.swift b/Example/SingleStoreExample/Signin/SigninState.swift index 485bdc8..0893075 100644 --- a/Example/SingleStoreExample/Signin/SigninState.swift +++ b/Example/SingleStoreExample/Signin/SigninState.swift @@ -3,127 +3,139 @@ import CombineFeedback import Foundation enum SignIn { - struct State: Equatable { - var userName = "" - var email = "" - var password = "" - var repeatPassword = "" - var termsAccepted = false - var status = Status.idle - var showSignedInAlert = false - fileprivate(set) var isAvailable = false + struct Dependencies { + var signIn: ( + _ userName: String, + _ email: String, + _ password: String + ) -> AnyPublisher - var canSubmit: Bool { - return isAvailable - && !userName.isEmpty - && !email.isEmpty - && !password.isEmpty - && !repeatPassword.isEmpty - && password == repeatPassword - && termsAccepted - } + var usernameAvailable: ( + _ username: String + ) -> AnyPublisher + } - enum Status: Equatable { - case checkingUserName - case idle - case submitting - case signedIn + struct State: Equatable { + var userName = "" + var email = "" + var password = "" + var repeatPassword = "" + var termsAccepted = false + var status = Status.idle + var showSignedInAlert = false + fileprivate(set) var isAvailable = false - var isCheckingUserName: Bool { - switch self { - case .checkingUserName: - return true - default: - return false - } - } + var canSubmit: Bool { + return isAvailable + && !userName.isEmpty + && !email.isEmpty + && !password.isEmpty + && !repeatPassword.isEmpty + && password == repeatPassword + && termsAccepted + } - var isSubmitting: Bool { - switch self { - case .submitting: - return true - default: - return false - } - } + enum Status: Equatable { + case checkingUserName + case idle + case submitting + case signedIn - var isSignedIn: Bool { - switch self { - case .signedIn: - return true - default: - return false - } - } + var isCheckingUserName: Bool { + switch self { + case .checkingUserName: + return true + default: + return false } - } + } - enum Event { - case isAvailable(Bool) - case didSignIn(Bool) - case didChangeUserName(String) - case signIn - case dismissAlertTap - } + var isSubmitting: Bool { + switch self { + case .submitting: + return true + default: + return false + } + } - static func reducer() -> Reducer { - return .init { state, event in - switch event { - case .didChangeUserName(let userName): - state.userName = userName - state.status = userName.isEmpty ? .idle : .checkingUserName - case .isAvailable(let isAvailable): - state.isAvailable = isAvailable - state.status = .idle - case .signIn: - state.status = .submitting - state.showSignedInAlert = true - case .didSignIn: - state.status = .idle - case .dismissAlertTap: - state.showSignedInAlert = false - } + var isSignedIn: Bool { + switch self { + case .signedIn: + return true + default: + return false } + } } + } - static var feedback: Feedback { - return Feedback.combine( - whenChangingUserName(api: GithubAPI()), - whenSubmitting(api: GithubAPI()) - ) + enum Event { + case isAvailable(Bool) + case didSignIn(Bool) + case didChangeUserName(String) + case signIn + case dismissAlertTap + } + + static func reducer() -> Reducer { + return .init { state, event in + switch event { + case .didChangeUserName(let userName): + state.userName = userName + state.status = userName.isEmpty ? .idle : .checkingUserName + case .isAvailable(let isAvailable): + state.isAvailable = isAvailable + state.status = .idle + case .signIn: + state.status = .submitting + state.showSignedInAlert = true + case .didSignIn: + state.status = .idle + case .dismissAlertTap: + state.showSignedInAlert = false + } } + } + + static var feedback: Feedback { + return Feedback.combine( + whenChangingUserName(), + whenSubmitting() + ) + } - static func whenChangingUserName(api: GithubAPI) -> Feedback { - return Feedback.custom { state, consumer in - state - .map { - $0.0.userName - } - .filter { $0.isEmpty == false } - .removeDuplicates() - .debounce( - for: 0.5, - scheduler: DispatchQueue.main - ) - .flatMapLatest { userName in - return api.usernameAvailable(username: userName) - .map(Event.isAvailable) - .enqueue(to: consumer) - } - .start() + static func whenChangingUserName() -> Feedback { + return Feedback.custom { state, consumer, dependency in + state + .map { + $0.0.userName } + .filter { $0.isEmpty == false } + .removeDuplicates() + .debounce( + for: 0.5, + scheduler: DispatchQueue.main + ) + .flatMapLatest { userName in + dependency.usernameAvailable(userName) + .map(Event.isAvailable) + .enqueue(to: consumer) + } + .start() } + } - static func whenSubmitting(api: GithubAPI) -> Feedback { - return .middleware { (state) -> AnyPublisher in - guard state.status.isSubmitting else { - return Empty().eraseToAnyPublisher() - } + static func whenSubmitting() -> Feedback { + return .middleware { (state: State, dependency: Dependencies) -> AnyPublisher in + guard state.status.isSubmitting else { + return Empty().eraseToAnyPublisher() + } - return api - .singIn(username: state.userName, email: state.email, password: state.password) - .map(Event.didSignIn) - .eraseToAnyPublisher() - } + return dependency + .signIn(state.userName, state.email, state.password) + .map(Event.didSignIn) + .eraseToAnyPublisher() } + } } diff --git a/Example/SingleStoreExample/State.swift b/Example/SingleStoreExample/State.swift index 46032e4..a2d8d2c 100644 --- a/Example/SingleStoreExample/State.swift +++ b/Example/SingleStoreExample/State.swift @@ -30,10 +30,11 @@ let moviesReducer: Reducer = Movies.reducer() event: /Event.movies ) -let moviesFeedback: Feedback = Movies.feedback +let moviesFeedback: Feedback = Movies.feedback .pullback( value: \.movies, - event: /Event.movies + event: /Event.movies, + dependency: \.movies ) let signInReducer: Reducer = SignIn.reducer().pullback( @@ -41,10 +42,11 @@ let signInReducer: Reducer = SignIn.reducer().pullback( event: /Event.signIn ) -let signInFeedback: Feedback = SignIn.feedback +let signInFeedback: Feedback = SignIn.feedback .pullback( value: \.signIn, - event: /Event.signIn + event: /Event.signIn, + dependency: \.signIn ) let traficLightReducer: Reducer = TrafficLight.reducer() @@ -53,9 +55,10 @@ let traficLightReducer: Reducer = TrafficLight.reducer() event: /Event.trafficLight ) -let trafficLightFeedback: Feedback = TrafficLight.feedback.pullback( +let trafficLightFeedback: Feedback = TrafficLight.feedback.pullback( value: \.traficLight, - event: /Event.trafficLight + event: /Event.trafficLight, + dependency: { _ in } ) let appReducer = Reducer.combine( @@ -64,3 +67,22 @@ let appReducer = Reducer.combine( signInReducer, traficLightReducer ) + +struct AppDependency { + let urlSession = URLSession.shared + let api = GithubAPI() + + var movies: Movies.Dependencies { + .init( + movies: urlSession.movies(page:), + fetchMovies: urlSession.fetchMovies(page:) + ) + } + + var signIn: SignIn.Dependencies { + .init( + signIn: api.signIn, + usernameAvailable: api.usernameAvailable(username:) + ) + } +} diff --git a/Example/SingleStoreExample/TrafficLight/TrafficLightState.swift b/Example/SingleStoreExample/TrafficLight/TrafficLightState.swift index 7e4312e..d800ef5 100644 --- a/Example/SingleStoreExample/TrafficLight/TrafficLightState.swift +++ b/Example/SingleStoreExample/TrafficLight/TrafficLightState.swift @@ -3,97 +3,93 @@ import CombineFeedback import Foundation enum TrafficLight { - enum State: Equatable { - case red - case yellow - case green + enum State: Equatable { + case red + case yellow + case green - var isRed: Bool { - switch self { - case .red: - return true - default: - return false - } - } - - var isYellow: Bool { - switch self { - case .yellow: - return true - default: - return false - } - } - - var isGreen: Bool { - switch self { - case .green: - return true - default: - return false - } - } + var isRed: Bool { + switch self { + case .red: + return true + default: + return false + } } - enum Event { - case next + var isYellow: Bool { + switch self { + case .yellow: + return true + default: + return false + } } - static func reducer() -> Reducer { - .init { state, event in - switch state { - case .red: - state = .yellow - case .yellow: - state = .green - case .green: - state = .red - } - } + var isGreen: Bool { + switch self { + case .green: + return true + default: + return false + } } + } + + enum Event { + case next + } - static var feedback: Feedback { - return Feedback.combine(whenRed(), whenYellow(), whenGreen()) -// return Feedback { (_) in -// return Empty() -// } + static func reducer() -> Reducer { + .init { state, _ in + switch state { + case .red: + state = .yellow + case .yellow: + state = .green + case .green: + state = .red + } } + } - private static func whenRed() -> Feedback { - .middleware { state -> AnyPublisher in - guard case .red = state else { - return Empty().eraseToAnyPublisher() - } + static var feedback: Feedback { + return Feedback.combine(whenRed(), whenYellow(), whenGreen()) + } - return Result.Publisher(Event.next) - .delay(for: 1, scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } + private static func whenRed() -> Feedback { + .middleware { state, _ -> AnyPublisher in + guard case .red = state else { + return Empty().eraseToAnyPublisher() + } + + return Result.Publisher(Event.next) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToAnyPublisher() } + } - private static func whenYellow() -> Feedback { - .middleware { state -> AnyPublisher in - guard case .yellow = state else { - return Empty().eraseToAnyPublisher() - } + private static func whenYellow() -> Feedback { + .middleware { state, _ -> AnyPublisher in + guard case .yellow = state else { + return Empty().eraseToAnyPublisher() + } - return Result.Publisher(Event.next) - .delay(for: 1, scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } + return Result.Publisher(Event.next) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToAnyPublisher() } + } - private static func whenGreen() -> Feedback { - .middleware { state -> AnyPublisher in - guard case .green = state else { - return Empty().eraseToAnyPublisher() - } + private static func whenGreen() -> Feedback { + .middleware { state, _ -> AnyPublisher in + guard case .green = state else { + return Empty().eraseToAnyPublisher() + } - return Result.Publisher(Event.next) - .delay(for: 1, scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } + return Result.Publisher(Event.next) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToAnyPublisher() } - + } } diff --git a/Example/TrafficLight/TrafficLight.swift b/Example/TrafficLight/TrafficLight.swift index 2fea8c6..b24dc7e 100644 --- a/Example/TrafficLight/TrafficLight.swift +++ b/Example/TrafficLight/TrafficLight.swift @@ -13,12 +13,13 @@ extension TrafficLight { ViewModel.whenYellow(), ViewModel.whenGreen() ], - reducer: TrafficLight.reducer() + reducer: TrafficLight.reducer(), + dependency: () ) } - private static func whenRed() -> Feedback { - .middleware { state -> AnyPublisher in + private static func whenRed() -> Feedback { + .middleware { state, _ -> AnyPublisher in guard case .red = state else { return Empty().eraseToAnyPublisher() } @@ -29,8 +30,8 @@ extension TrafficLight { } } - private static func whenYellow() -> Feedback { - .middleware { state -> AnyPublisher in + private static func whenYellow() -> Feedback { + .middleware { state, _ -> AnyPublisher in guard case .yellow = state else { return Empty().eraseToAnyPublisher() } @@ -41,8 +42,8 @@ extension TrafficLight { } } - private static func whenGreen() -> Feedback { - .middleware { state -> AnyPublisher in + private static func whenGreen() -> Feedback { + .middleware { state, _ -> AnyPublisher in guard case .green = state else { return Empty().eraseToAnyPublisher() } diff --git a/Example/Views/StoreExtensions.swift b/Example/Views/StoreExtensions.swift index 47d7eee..801b2b5 100644 --- a/Example/Views/StoreExtensions.swift +++ b/Example/Views/StoreExtensions.swift @@ -3,7 +3,7 @@ import CombineFeedback extension Store { static func empty(_ state: State) -> Store { - Store(initial: state, feedbacks: [], reducer: .empty) + Store(initial: state, feedbacks: [], reducer: .empty, dependency: ()) } } diff --git a/Package.swift b/Package.swift index 4206a72..7aaa0bb 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/pointfreeco/swift-case-paths.git", - from: Version(0, 1, 0) + from: Version(0, 2, 0) + ), + .package( + url: "https://github.com/pointfreeco/combine-schedulers.git", + from: Version(0, 5, 0) ) ], targets: [ diff --git a/Sources/CombineFeedback/Atomic.swift b/Sources/CombineFeedback/Atomic.swift index 389e6e5..d163b80 100644 --- a/Sources/CombineFeedback/Atomic.swift +++ b/Sources/CombineFeedback/Atomic.swift @@ -2,70 +2,70 @@ import Foundation final class Atomic { - private let lock: NSLock - private var _value: Value + private let lock: NSLock + private var _value: Value - /// Atomically get or set the value of the variable. - var value: Value { - get { - return withValue { $0 } - } - - set(newValue) { - swap(newValue) - } + /// Atomically get or set the value of the variable. + var value: Value { + get { + return withValue { $0 } } - /// Initialize the variable with the given initial value. - /// - /// - parameters: - /// - value: Initial value for `self`. - init(_ value: Value) { - _value = value - lock = NSLock() + set(newValue) { + swap(newValue) } + } - /// Atomically modifies the variable. - /// - /// - parameters: - /// - action: A closure that takes the current value. - /// - /// - returns: The result of the action. - @discardableResult - func modify(_ action: (inout Value) throws -> Result) rethrows -> Result { - lock.lock() - defer { lock.unlock() } + /// Initialize the variable with the given initial value. + /// + /// - parameters: + /// - value: Initial value for `self`. + init(_ value: Value) { + _value = value + lock = NSLock() + } - return try action(&_value) - } + /// Atomically modifies the variable. + /// + /// - parameters: + /// - action: A closure that takes the current value. + /// + /// - returns: The result of the action. + @discardableResult + func modify(_ action: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } - /// Atomically perform an arbitrary action using the current value of the - /// variable. - /// - /// - parameters: - /// - action: A closure that takes the current value. - /// - /// - returns: The result of the action. - @discardableResult - func withValue(_ action: (Value) throws -> Result) rethrows -> Result { - lock.lock() - defer { lock.unlock() } + return try action(&_value) + } - return try action(_value) - } + /// Atomically perform an arbitrary action using the current value of the + /// variable. + /// + /// - parameters: + /// - action: A closure that takes the current value. + /// + /// - returns: The result of the action. + @discardableResult + func withValue(_ action: (Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + + return try action(_value) + } - /// Atomically replace the contents of the variable. - /// - /// - parameters: - /// - newValue: A new value for the variable. - /// - /// - returns: The old value. - @discardableResult - func swap(_ newValue: Value) -> Value { - return modify { (value: inout Value) in - let oldValue = value - value = newValue - return oldValue - } + /// Atomically replace the contents of the variable. + /// + /// - parameters: + /// - newValue: A new value for the variable. + /// + /// - returns: The old value. + @discardableResult + func swap(_ newValue: Value) -> Value { + return modify { (value: inout Value) in + let oldValue = value + value = newValue + return oldValue } + } } diff --git a/Sources/CombineFeedback/Feedback.swift b/Sources/CombineFeedback/Feedback.swift index 78cd48d..e16d0c6 100644 --- a/Sources/CombineFeedback/Feedback.swift +++ b/Sources/CombineFeedback/Feedback.swift @@ -1,10 +1,18 @@ import CasePaths import Combine -public struct Feedback { - let events: (_ state: AnyPublisher<(State, Event?), Never>, _ output: FeedbackEventConsumer) -> Cancellable - - internal init(events: @escaping (_ state: AnyPublisher<(State, Event?), Never>, _ output: FeedbackEventConsumer) -> Cancellable) { +public struct Feedback { + let events: ( + _ state: AnyPublisher<(State, Event?), Never>, + _ output: FeedbackEventConsumer, + _ dependency: Dependency + ) -> Cancellable + + internal init(events: @escaping ( + _ state: AnyPublisher<(State, Event?), Never>, + _ output: FeedbackEventConsumer, + _ dependency: Dependency + ) -> Cancellable) { self.events = events } @@ -20,9 +28,10 @@ public struct Feedback { public static func custom( _ setup: @escaping ( _ state: AnyPublisher<(State, Event?), Never>, - _ output: FeedbackEventConsumer + _ output: FeedbackEventConsumer, + _ dependency: Dependency ) -> Cancellable - ) -> Feedback { + ) -> Feedback { return Feedback(events: setup) } @@ -40,28 +49,28 @@ public struct Feedback { /// the state. public static func compacting( state transform: @escaping (AnyPublisher) -> AnyPublisher, - effects: @escaping (U) -> Effect + effects: @escaping (U, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { - custom { (state, output) -> Cancellable in + custom { (state, output, dependency) -> Cancellable in // NOTE: `observe(on:)` should be applied on the inner producers, so // that cancellation due to state changes would be able to // cancel outstanding events that have already been scheduled. transform(state.map(\.0).eraseToAnyPublisher()) - .flatMapLatest { effects($0).enqueue(to: output) } + .flatMapLatest { effects($0, dependency).enqueue(to: output) } .start() } } public static func compacting( events transform: @escaping (AnyPublisher) -> AnyPublisher, - effects: @escaping (U) -> Effect + effects: @escaping (U, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { - custom { (state, output) -> Cancellable in + custom { (state, output, dependency) -> Cancellable in // NOTE: `observe(on:)` should be applied on the inner producers, so // that cancellation due to state changes would be able to // cancel outstanding events that have already been scheduled. transform(state.map(\.1).compactMap { $0 }.eraseToAnyPublisher()) - .flatMapLatest { effects($0).enqueue(to: output) } + .flatMapLatest { effects($0, dependency).enqueue(to: output) } .start() } } @@ -80,14 +89,15 @@ public struct Feedback { /// the state. public static func skippingRepeated( state transform: @escaping (State) -> Control?, - effects: @escaping (Control) -> Effect + effects: @escaping (Control, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { compacting(state: { $0.map(transform) .removeDuplicates() .eraseToAnyPublisher() - }, effects: { - $0.map(effects)? + }, effects: { control, dependency in + control + .map { effects($0, dependency) }? .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() }) } @@ -95,16 +105,16 @@ public struct Feedback { @available(iOS 15.0, *) public static func skippingRepeated( state transform: @escaping (State) -> Control?, - effect: @escaping (Control) async -> Event + effect: @escaping (Control, Dependency) async -> Event ) -> Feedback { compacting(state: { $0.map(transform) .removeDuplicates() .eraseToAnyPublisher() - }, effects: { control -> AnyPublisher in + }, effects: { control, dependency -> AnyPublisher in if let control = control { return TaskPublisher { - await effect(control) + await effect(control, dependency) }.eraseToAnyPublisher() } else { return Empty().eraseToAnyPublisher() @@ -125,26 +135,27 @@ public struct Feedback { /// the state. public static func lensing( state transform: @escaping (State) -> Control?, - effects: @escaping (Control) -> Effect + effects: @escaping (Control, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { compacting(state: { $0.map(transform).eraseToAnyPublisher() - }, effects: { - $0.map(effects)?.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() + }, effects: { control, dependency in + control.map { effects($0, dependency) }? + .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() }) } @available(iOS 15.0, *) public static func lensing( state transform: @escaping (State) -> Control?, - effects: @escaping (Control) async -> Event + effects: @escaping (Control, Dependency) async -> Event ) -> Feedback { compacting(state: { $0.map(transform).eraseToAnyPublisher() - }, effects: { control -> AnyPublisher in + }, effects: { control, dependency -> AnyPublisher in if let control = control { return TaskPublisher { - await effects(control) + await effects(control, dependency) } .eraseToAnyPublisher() } else { @@ -165,22 +176,22 @@ public struct Feedback { /// that eventually affect the state. public static func predicate( predicate: @escaping (State) -> Bool, - effects: @escaping (State) -> Effect + effects: @escaping (State, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { - compacting(state: { $0 }, effects: { state in - predicate(state) ? effects(state).eraseToAnyPublisher() : Empty().eraseToAnyPublisher() + compacting(state: { $0 }, effects: { state, dependency in + predicate(state) ? effects(state, dependency).eraseToAnyPublisher() : Empty().eraseToAnyPublisher() }) } @available(iOS 15.0, *) public static func predicate( predicate: @escaping (State) -> Bool, - effect: @escaping (State) async -> Event + effect: @escaping (State, Dependency) async -> Event ) -> Feedback { - compacting(state: { $0 }, effects: { state -> AnyPublisher in + compacting(state: { $0 }, effects: { state, dependency -> AnyPublisher in if predicate(state) { return TaskPublisher { - await effect(state) + await effect(state, dependency) } .eraseToAnyPublisher() } else { @@ -191,26 +202,27 @@ public struct Feedback { public static func lensing( event transform: @escaping (Event) -> Payload?, - effects: @escaping (Payload) -> Effect + effects: @escaping (Payload, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { compacting(events: { $0.map(transform).eraseToAnyPublisher() - }, effects: { - $0.map(effects)?.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() + }, effects: { payload, dependency in + payload.map { effects($0, dependency) }? + .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() }) } @available(iOS 15.0, *) public static func lensing( event transform: @escaping (Event) -> Payload?, - effect: @escaping (Payload) async -> Event + effect: @escaping (Payload, Dependency) async -> Event ) -> Feedback { compacting(events: { $0.map(transform).eraseToAnyPublisher() - }, effects: { payload -> AnyPublisher in + }, effects: { payload, dependency -> AnyPublisher in if let payload = payload { return TaskPublisher { - await effect(payload) + await effect(payload, dependency) } .eraseToAnyPublisher() } else { @@ -229,18 +241,18 @@ public struct Feedback { /// - effects: The side effect accepting the state and yielding events /// that eventually affect the state. public static func middleware( - _ effects: @escaping (State) -> Effect + _ effects: @escaping (State, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { compacting(state: { $0 }, effects: effects) } @available(iOS 15.0, *) public static func middleware( - _ effect: @escaping (State) async -> Event + _ effect: @escaping (State, Dependency) async -> Event ) -> Feedback { - compacting(state: { $0 }, effects: { state in + compacting(state: { $0 }, effects: { state, dependency in TaskPublisher { - await effect(state) + await effect(state, dependency) } }) } @@ -257,9 +269,9 @@ public struct Feedback { /// - effects: The side effect accepting the state and yielding events /// that eventually affect the state. public static func middleware( - _ effects: @escaping (State, Event) -> Effect + _ effects: @escaping (State, Event, Dependency) -> Effect ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { - custom { (state, output) -> Cancellable in + custom { (state, output, dependency) -> Cancellable in state.compactMap { s, e -> (State, Event)? in guard let e = e else { return nil @@ -267,7 +279,7 @@ public struct Feedback { return (s, e) } .flatMapLatest { - effects($0, $1).enqueue(to: output) + effects($0, $1, dependency).enqueue(to: output) } .start() } @@ -275,9 +287,9 @@ public struct Feedback { @available(iOS 15.0, *) public static func middleware( - _ effects: @escaping (State, Event) async -> Event + _ effects: @escaping (State, Event, Dependency) async -> Event ) -> Feedback { - custom { (state, output) -> Cancellable in + custom { (state, output, dependency) -> Cancellable in state.compactMap { s, e -> (State, Event)? in guard let e = e else { return nil @@ -286,7 +298,7 @@ public struct Feedback { } .flatMapLatest { state, event in TaskPublisher { - await effects(state, event) + await effects(state, event, dependency) } .enqueue(to: output) } @@ -296,10 +308,10 @@ public struct Feedback { @available(iOS 15.0, *) public static func middleware( - _ effect: @escaping (Event) async -> Event + _ effect: @escaping (Event, Dependency) async -> Event ) -> Feedback { - custom { (state, output) -> Cancellable in - state.compactMap { s, e -> Event? in + custom { (state, output, dependency) -> Cancellable in + state.compactMap { _, e -> Event? in guard let e = e else { return nil } @@ -307,7 +319,7 @@ public struct Feedback { } .flatMapLatest { event in TaskPublisher { - await effect(event) + await effect(event, dependency) } .enqueue(to: output) } @@ -317,32 +329,36 @@ public struct Feedback { } public extension Feedback { - func pullback( + func pullback( value: KeyPath, - event: CasePath - ) -> Feedback { - return .custom { (state, consumer) -> Cancellable in + event: CasePath, + dependency toLocal: @escaping (GlobalDependency) -> Dependency + ) -> Feedback { + return .custom { (state, consumer, dependency) -> Cancellable in let state = state.map { ($0[keyPath: value], $1.flatMap(event.extract(from:))) }.eraseToAnyPublisher() return self.events( state, - consumer.pullback(event.embed) + consumer.pullback(event.embed), + toLocal(dependency) ) } } - static func combine(_ feedbacks: Feedback...) -> Feedback { - return Feedback.custom { (state, consumer) -> Cancellable in + static func combine( + _ feedbacks: Feedback...) -> Feedback + { + return Feedback.custom { (state, consumer, dependency) -> Cancellable in feedbacks.map { (feedback) -> Cancellable in - feedback.events(state, consumer) + feedback.events(state, consumer, dependency) } } } static var input: (feedback: Feedback, observer: (Event) -> Void) { let subject = PassthroughSubject() - let feedback = Feedback.custom { (_, consumer) -> Cancellable in + let feedback = Feedback.custom { (_, consumer, _) -> Cancellable in subject.enqueue(to: consumer).start() } return (feedback, subject.send) @@ -358,7 +374,7 @@ extension Array: Cancellable where Element == Cancellable { } @available(iOS 15.0, *) -struct TaskPublisher: Publisher{ +struct TaskPublisher: Publisher { typealias Failure = Never let work: () async -> Output @@ -367,7 +383,7 @@ struct TaskPublisher: Publisher{ self.work = work } - func receive(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { + func receive(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { let subscription = TaskSubscription(work: work, subscriber: subscriber) subscriber.receive(subscription: subscription) subscription.start() @@ -378,7 +394,6 @@ struct TaskPublisher: Publisher{ private let work: () async -> Output private let subscriber: Downstream - init(work: @escaping () async -> Output, subscriber: Downstream) { self.work = work self.subscriber = subscriber diff --git a/Sources/CombineFeedback/FeedbackEventConsumer.swift b/Sources/CombineFeedback/FeedbackEventConsumer.swift index f0264b2..c8a4689 100644 --- a/Sources/CombineFeedback/FeedbackEventConsumer.swift +++ b/Sources/CombineFeedback/FeedbackEventConsumer.swift @@ -1,44 +1,44 @@ import Foundation struct Token: Equatable { - let value: UUID + let value: UUID - init() { - value = UUID() - } + init() { + value = UUID() + } } public class FeedbackEventConsumer { - func process(_ event: Event, for token: Token) { - fatalError("This is an abstract class. You must subclass this and provide your own implementation") - } + func process(_ event: Event, for token: Token) { + fatalError("This is an abstract class. You must subclass this and provide your own implementation") + } - func dequeueAllEvents(for token: Token) { - fatalError("This is an abstract class. You must subclass this and provide your own implementation") - } + func dequeueAllEvents(for token: Token) { + fatalError("This is an abstract class. You must subclass this and provide your own implementation") + } } extension FeedbackEventConsumer { - func pullback(_ f: @escaping (LocalEvent) -> Event) -> FeedbackEventConsumer { - return PullBackConsumer(upstream: self, pull: f) - } + func pullback(_ f: @escaping (LocalEvent) -> Event) -> FeedbackEventConsumer { + return PullBackConsumer(upstream: self, pull: f) + } } final class PullBackConsumer: FeedbackEventConsumer { - private let upstream: FeedbackEventConsumer - private let pull: (LocalEvent) -> Event - - init(upstream: FeedbackEventConsumer, pull: @escaping (LocalEvent) -> Event) { - self.pull = pull - self.upstream = upstream - super.init() - } - - override func process(_ event: LocalEvent, for token: Token) { - self.upstream.process(pull(event), for: token) - } - - override func dequeueAllEvents(for token: Token) { - self.upstream.dequeueAllEvents(for: token) - } + private let upstream: FeedbackEventConsumer + private let pull: (LocalEvent) -> Event + + init(upstream: FeedbackEventConsumer, pull: @escaping (LocalEvent) -> Event) { + self.pull = pull + self.upstream = upstream + super.init() + } + + override func process(_ event: LocalEvent, for token: Token) { + self.upstream.process(pull(event), for: token) + } + + override func dequeueAllEvents(for token: Token) { + self.upstream.dequeueAllEvents(for: token) + } } diff --git a/Sources/CombineFeedback/FeedbackLoop.swift b/Sources/CombineFeedback/FeedbackLoop.swift index 312549b..8960ab4 100644 --- a/Sources/CombineFeedback/FeedbackLoop.swift +++ b/Sources/CombineFeedback/FeedbackLoop.swift @@ -1,31 +1,35 @@ import Combine -extension Publishers { - public struct FeedbackLoop: Publisher { - public typealias Failure = Never - let initial: Output - let reduce: Reducer - let feedbacks: [Feedback] +public extension Publishers { + struct FeedbackLoop: Publisher { + public typealias Failure = Never + let initial: Output + let reduce: Reducer + let feedbacks: [Feedback] + let dependency: Dependency - public init( - initial: Output, - reduce: Reducer, - feedbacks: [Feedback] - ) { - self.initial = initial - self.reduce = reduce - self.feedbacks = feedbacks - } + public init( + initial: Output, + reduce: Reducer, + feedbacks: [Feedback], + dependency: Dependency + ) { + self.initial = initial + self.reduce = reduce + self.feedbacks = feedbacks + self.dependency = dependency + } - public func receive(subscriber: S) where S: Combine.Subscriber, Failure == S.Failure, Output == S.Input { - let floodgate = Floodgate( - state: initial, - feedbacks: feedbacks, - sink: subscriber, - reducer: reduce - ) - subscriber.receive(subscription: floodgate) - floodgate.bootstrap() - } + public func receive(subscriber: S) where S: Combine.Subscriber, Failure == S.Failure, Output == S.Input { + let floodgate = Floodgate( + state: initial, + feedbacks: feedbacks, + sink: subscriber, + reducer: reduce, + dependency: dependency + ) + subscriber.receive(subscription: floodgate) + floodgate.bootstrap() } + } } diff --git a/Sources/CombineFeedback/FlatMapLatest.swift b/Sources/CombineFeedback/FlatMapLatest.swift index 9234871..8822830 100644 --- a/Sources/CombineFeedback/FlatMapLatest.swift +++ b/Sources/CombineFeedback/FlatMapLatest.swift @@ -1,32 +1,34 @@ import Combine -extension Publisher { - public func flatMapLatest( - _ transformation: @escaping (Self.Output) -> U - ) -> Publishers.FlatMapLatest - where U: Publisher, U.Failure == Self.Failure { - return Publishers.FlatMapLatest(upstream: self, transform: transformation) - } +public extension Publisher { + func flatMapLatest( + _ transformation: @escaping (Self.Output) -> U + ) -> Publishers.FlatMapLatest + where U: Publisher, U.Failure == Self.Failure + { + return Publishers.FlatMapLatest(upstream: self, transform: transformation) + } } -extension Publishers { - public struct FlatMapLatest: Publisher - where P: Publisher, Upstream: Publisher, P.Failure == Upstream.Failure { - public typealias Output = P.Output - public typealias Failure = Upstream.Failure +public extension Publishers { + struct FlatMapLatest: Publisher + where P: Publisher, Upstream: Publisher, P.Failure == Upstream.Failure + { + public typealias Output = P.Output + public typealias Failure = Upstream.Failure - private let upstream: Upstream - private let transform: (Upstream.Output) -> P + private let upstream: Upstream + private let transform: (Upstream.Output) -> P - init(upstream: Upstream, transform: @escaping (Upstream.Output) -> P) { - self.upstream = upstream - self.transform = transform - } + init(upstream: Upstream, transform: @escaping (Upstream.Output) -> P) { + self.upstream = upstream + self.transform = transform + } - public func receive(subscriber: S) where S: Subscriber, P.Output == S.Input, Upstream.Failure == S.Failure { - self.upstream.map(self.transform) - .switchToLatest() - .receive(subscriber: subscriber) - } + public func receive(subscriber: S) where S: Subscriber, P.Output == S.Input, Upstream.Failure == S.Failure { + self.upstream.map(self.transform) + .switchToLatest() + .receive(subscriber: subscriber) } + } } diff --git a/Sources/CombineFeedback/Floodgate.swift b/Sources/CombineFeedback/Floodgate.swift index f32438d..4ead894 100644 --- a/Sources/CombineFeedback/Floodgate.swift +++ b/Sources/CombineFeedback/Floodgate.swift @@ -1,159 +1,163 @@ import Foundation import Combine -final class Floodgate: FeedbackEventConsumer, Subscription where S.Input == State, S.Failure == Never { - struct QueueState { - var events: [(Event, Token)] = [] - var isOuterLifetimeEnded = false - var hasEvents: Bool { - events.isEmpty == false && isOuterLifetimeEnded == false - } +final class Floodgate: FeedbackEventConsumer, Subscription where S.Input == State, S.Failure == Never { + struct QueueState { + var events: [(Event, Token)] = [] + var isOuterLifetimeEnded = false + var hasEvents: Bool { + events.isEmpty == false && isOuterLifetimeEnded == false } - - let stateDidChange = PassthroughSubject<(State, Event?), Never>() - - private let reducerLock = NSLock() - private var state: State - private var hasStarted = false - private var cancelable: Cancellable? - - private let queue = Atomic(QueueState()) - private let reducer: Reducer - private let feedbacks: [Feedback] - private let sink: S - - init( - state: State, - feedbacks: [Feedback], - sink: S, - reducer: Reducer - ) { - self.state = state - self.feedbacks = feedbacks - self.sink = sink - self.reducer = reducer + } + + let stateDidChange = PassthroughSubject<(State, Event?), Never>() + + private let reducerLock = NSLock() + private var state: State + private var hasStarted = false + private var cancelable: Cancellable? + + private let queue = Atomic(QueueState()) + private let reducer: Reducer + private let feedbacks: [Feedback] + private let sink: S + private let dependency: Dependency + + init( + state: State, + feedbacks: [Feedback], + sink: S, + reducer: Reducer, + dependency: Dependency + ) { + self.state = state + self.feedbacks = feedbacks + self.sink = sink + self.reducer = reducer + self.dependency = dependency + } + + func bootstrap() { + reducerLock.lock() + defer { reducerLock.unlock() } + + guard !hasStarted else { return } + hasStarted = true + self.cancelable = feedbacks.map { + $0.events(stateDidChange.eraseToAnyPublisher(), self, dependency) } - - func bootstrap() { - reducerLock.lock() - defer { reducerLock.unlock() } - - guard !hasStarted else { return } - hasStarted = true - self.cancelable = feedbacks.map { $0.events(stateDidChange.eraseToAnyPublisher(), self) } - _ = self.sink.receive(state) - stateDidChange.send((state, nil)) - drainEvents() + _ = self.sink.receive(state) + stateDidChange.send((state, nil)) + drainEvents() + } + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + stateDidChange.send(completion: .finished) + cancelable?.cancel() + queue.modify { + $0.isOuterLifetimeEnded = true } + } - func request(_ demand: Subscribers.Demand) {} + override func process(_ event: Event, for token: Token) { + enqueue(event, for: token) - func cancel() { - stateDidChange.send(completion: .finished) - cancelable?.cancel() - queue.modify { - $0.isOuterLifetimeEnded = true - } - } - - override func process(_ event: Event, for token: Token) { - enqueue(event, for: token) - - if reducerLock.try() { - repeat { - drainEvents() - reducerLock.unlock() - } while queue.withValue({ $0.hasEvents }) && reducerLock.try() - // ^^^ - // Restart the event draining after we unlock the reducer lock, iff: - // - // 1. the queue still has unprocessed events; and - // 2. no concurrent actor has taken the reducer lock, which implies no event draining would be started - // unless we take active action. - // - // This eliminates a race condition in the following sequence of operations: - // - // | Thread A | Thread B | - // |------------------------------------|------------------------------------| - // | concurrent dequeue: no item | | - // | | concurrent enqueue | - // | | trylock lock: BUSY | - // | unlock lock | | - // | | | - // | <<< The enqueued event is left unprocessed. >>> | - // - // The trylock-unlock duo has a synchronize-with relationship, which ensures that Thread A must see any - // concurrent enqueue that *happens before* the trylock. - } + if reducerLock.try() { + repeat { + drainEvents() + reducerLock.unlock() + } while queue.withValue({ $0.hasEvents }) && reducerLock.try() + // ^^^ + // Restart the event draining after we unlock the reducer lock, iff: + // + // 1. the queue still has unprocessed events; and + // 2. no concurrent actor has taken the reducer lock, which implies no event draining would be started + // unless we take active action. + // + // This eliminates a race condition in the following sequence of operations: + // + // | Thread A | Thread B | + // |------------------------------------|------------------------------------| + // | concurrent dequeue: no item | | + // | | concurrent enqueue | + // | | trylock lock: BUSY | + // | unlock lock | | + // | | | + // | <<< The enqueued event is left unprocessed. >>> | + // + // The trylock-unlock duo has a synchronize-with relationship, which ensures that Thread A must see any + // concurrent enqueue that *happens before* the trylock. } + } - override func dequeueAllEvents(for token: Token) { - queue.modify { $0.events.removeAll(where: { _, t in t == token }) } - } + override func dequeueAllEvents(for token: Token) { + queue.modify { $0.events.removeAll(where: { _, t in t == token }) } + } - private func enqueue(_ event: Event, for token: Token) { - queue.modify { state -> QueueState in - state.events.append((event, token)) - return state - } + private func enqueue(_ event: Event, for token: Token) { + queue.modify { state -> QueueState in + state.events.append((event, token)) + return state } - - private func dequeue() -> Event? { - queue.modify { - guard !$0.isOuterLifetimeEnded, !$0.events.isEmpty else { - return nil - } - return $0.events.removeFirst().0 - } + } + + private func dequeue() -> Event? { + queue.modify { + guard !$0.isOuterLifetimeEnded, !$0.events.isEmpty else { + return nil + } + return $0.events.removeFirst().0 } + } - private func drainEvents() { - // Drain any recursively produced events. - while let next = dequeue() { - consume(next) - } + private func drainEvents() { + // Drain any recursively produced events. + while let next = dequeue() { + consume(next) } + } - private func consume(_ event: Event) { - reducer(&state, event) - _ = sink.receive(state) - stateDidChange.send((state, event)) - } + private func consume(_ event: Event) { + reducer(&state, event) + _ = sink.receive(state) + stateDidChange.send((state, event)) + } } -extension Publisher where Failure == Never { - public func enqueue(to consumer: FeedbackEventConsumer) -> Publishers.Enqueue { - return Publishers.Enqueue(upstream: self, consumer: consumer) - } +public extension Publisher where Failure == Never { + func enqueue(to consumer: FeedbackEventConsumer) -> Publishers.Enqueue { + return Publishers.Enqueue(upstream: self, consumer: consumer) + } } -extension Publishers { - public struct Enqueue: Publisher where Upstream.Failure == Never { - public typealias Output = Never - public typealias Failure = Never - private let upstream: Upstream - private let consumer: FeedbackEventConsumer +public extension Publishers { + struct Enqueue: Publisher where Upstream.Failure == Never { + public typealias Output = Never + public typealias Failure = Never + private let upstream: Upstream + private let consumer: FeedbackEventConsumer - init(upstream: Upstream, consumer: FeedbackEventConsumer) { - self.upstream = upstream - self.consumer = consumer - } + init(upstream: Upstream, consumer: FeedbackEventConsumer) { + self.upstream = upstream + self.consumer = consumer + } - public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - let token = Token() - self.upstream.handleEvents( - receiveOutput: { (value) in - self.consumer.process(value, for: token) - }, - receiveCancel: { - self.consumer.dequeueAllEvents(for: token) - } - ) - .flatMap { _ -> Empty in - return Empty() - } - .receive(subscriber: subscriber) + public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + let token = Token() + self.upstream.handleEvents( + receiveOutput: { value in + self.consumer.process(value, for: token) + }, + receiveCancel: { + self.consumer.dequeueAllEvents(for: token) } + ) + .flatMap { _ -> Empty in + Empty() + } + .receive(subscriber: subscriber) } + } } - diff --git a/Sources/CombineFeedback/NSLockExtensions.swift b/Sources/CombineFeedback/NSLockExtensions.swift index 85fbaa1..d334597 100644 --- a/Sources/CombineFeedback/NSLockExtensions.swift +++ b/Sources/CombineFeedback/NSLockExtensions.swift @@ -1,10 +1,10 @@ import Foundation extension NSLock { - internal func perform(_ action: () -> Result) -> Result { - lock() - defer { unlock() } + func perform(_ action: () -> Result) -> Result { + lock() + defer { unlock() } - return action() - } + return action() + } } diff --git a/Sources/CombineFeedback/Reducer.swift b/Sources/CombineFeedback/Reducer.swift index 0a935c3..cd270e1 100644 --- a/Sources/CombineFeedback/Reducer.swift +++ b/Sources/CombineFeedback/Reducer.swift @@ -1,47 +1,47 @@ import CasePaths public struct Reducer { - public let reduce: (inout State, Event) -> Void + public let reduce: (inout State, Event) -> Void - public init(reduce: @escaping (inout State, Event) -> Void) { - self.reduce = reduce - } + public init(reduce: @escaping (inout State, Event) -> Void) { + self.reduce = reduce + } - public func callAsFunction(_ state: inout State, _ event: Event) -> Void { - self.reduce(&state, event) - } + public func callAsFunction(_ state: inout State, _ event: Event) { + self.reduce(&state, event) + } - public static func combine(_ reducers: Reducer...) -> Reducer { - return .init { state, event in - for reducer in reducers { - reducer(&state, event) - } - } + public static func combine(_ reducers: Reducer...) -> Reducer { + return .init { state, event in + for reducer in reducers { + reducer(&state, event) + } } + } - public func pullback( - value: WritableKeyPath, - event: CasePath - ) -> Reducer { - return .init { globalState, globalEvent in - guard let localAction = event.extract(from: globalEvent) else { - return - } - self(&globalState[keyPath: value], localAction) - } + public func pullback( + value: WritableKeyPath, + event: CasePath + ) -> Reducer { + return .init { globalState, globalEvent in + guard let localAction = event.extract(from: globalEvent) else { + return + } + self(&globalState[keyPath: value], localAction) } + } - public func logging( - printer: @escaping (String) -> Void = { print($0) } - ) -> Reducer { - return .init { state, event in - self(&state, event) - printer("Action: \(event)") - printer("Value:") - var dumpedNewValue = "" - dump(state, to: &dumpedNewValue) - printer(dumpedNewValue) - printer("---") - } + public func logging( + printer: @escaping (String) -> Void = { print($0) } + ) -> Reducer { + return .init { state, event in + self(&state, event) + printer("Action: \(event)") + printer("Value:") + var dumpedNewValue = "" + dump(state, to: &dumpedNewValue) + printer(dumpedNewValue) + printer("---") } + } } diff --git a/Sources/CombineFeedback/System.swift b/Sources/CombineFeedback/System.swift index 8bd77e2..85082ab 100644 --- a/Sources/CombineFeedback/System.swift +++ b/Sources/CombineFeedback/System.swift @@ -1,23 +1,25 @@ import Combine import Foundation -extension Publishers { - public static func system( - initial: State, - feedbacks: [Feedback], - reduce: Reducer - ) -> AnyPublisher { - return Publishers.FeedbackLoop( - initial: initial, - reduce: reduce, - feedbacks: feedbacks - ) - .eraseToAnyPublisher() - } +public extension Publishers { + static func system( + initial: State, + feedbacks: [Feedback], + reduce: Reducer, + dependency: Dependency + ) -> AnyPublisher { + return Publishers.FeedbackLoop( + initial: initial, + reduce: reduce, + feedbacks: feedbacks, + dependency: dependency + ) + .eraseToAnyPublisher() + } } -extension Publisher where Output == Never, Failure == Never { - public func start() -> Cancellable { - return sink(receiveValue: { _ in }) - } +public extension Publisher where Output == Never, Failure == Never { + func start() -> Cancellable { + return sink(receiveValue: { _ in }) + } } diff --git a/Sources/CombineFeedbackUI/Store/Store.swift b/Sources/CombineFeedbackUI/Store/Store.swift index 2518ccd..ff19400 100644 --- a/Sources/CombineFeedbackUI/Store/Store.swift +++ b/Sources/CombineFeedbackUI/Store/Store.swift @@ -18,15 +18,17 @@ open class Store { self.box = box } - public init( + public init( initial: State, - feedbacks: [Feedback], - reducer: Reducer + feedbacks: [Feedback], + reducer: Reducer, + dependency: Dependency ) { self.box = RootStoreBox( initial: initial, feedbacks: feedbacks, - reducer: reducer + reducer: reducer, + dependency: dependency ) } diff --git a/Sources/CombineFeedbackUI/Store/StoreBox.swift b/Sources/CombineFeedbackUI/Store/StoreBox.swift index 955bb6b..8b684e9 100644 --- a/Sources/CombineFeedbackUI/Store/StoreBox.swift +++ b/Sources/CombineFeedbackUI/Store/StoreBox.swift @@ -1,11 +1,12 @@ import Combine import CombineFeedback import CasePaths +import SwiftUI internal class RootStoreBox: StoreBoxBase { private let subject: CurrentValueSubject - private let input = Feedback.input + private let inputObserver: (Update) -> Void private var bag = Set() override var _current: State { @@ -16,12 +17,15 @@ internal class RootStoreBox: StoreBoxBase { subject.eraseToAnyPublisher() } - public init( + public init( initial: State, - feedbacks: [Feedback], - reducer: Reducer + feedbacks: [Feedback], + reducer: Reducer, + dependency: Dependency ) { + let input = Feedback.input self.subject = CurrentValueSubject(initial) + self.inputObserver = input.observer Publishers.FeedbackLoop( initial: initial, reduce: .init { state, update in @@ -33,9 +37,10 @@ internal class RootStoreBox: StoreBoxBase { } }, feedbacks: feedbacks.map { - $0.pullback(value: \.self, event: /Update.event) + $0.pullback(value: \.self, event: /Update.event, dependency: { _ in dependency }) } - .appending(self.input.feedback) + .appending(input.feedback), + dependency: dependency ) .sink(receiveValue: { [subject] state in subject.send(state) @@ -44,15 +49,15 @@ internal class RootStoreBox: StoreBoxBase { } override func send(event: Event) { - self.input.observer(.event(event)) + self.inputObserver(.event(event)) } override func mutate(keyPath: WritableKeyPath, value: V) { - self.input.observer(.mutation(Mutation(keyPath: keyPath, value: value))) + self.inputObserver(.mutation(Mutation(keyPath: keyPath, value: value))) } override func mutate(with mutation: Mutation) { - self.input.observer(.mutation(mutation)) + self.inputObserver(.mutation(mutation)) } override func scoped(to scope: WritableKeyPath, event: @escaping (E) -> Event) -> StoreBoxBase { diff --git a/Sources/CombineFeedbackUI/ViewContext.swift b/Sources/CombineFeedbackUI/ViewContext.swift index 4a9ef50..318e23b 100644 --- a/Sources/CombineFeedbackUI/ViewContext.swift +++ b/Sources/CombineFeedbackUI/ViewContext.swift @@ -1,7 +1,8 @@ import SwiftUI import Combine +import CombineSchedulers -@available(*, deprecated, renamed:"ViewContext") +@available(*, deprecated, renamed: "ViewContext") public typealias Context = ViewContext @dynamicMemberLookup @@ -22,6 +23,7 @@ public final class ViewContext: ObservableObject { self.mutate = store.mutate store.publisher .removeDuplicates(by: isDuplicate) + .receive(on: UIScheduler.shared, options: nil) .assign(to: \.state, weakly: self) .store(in: &bag) } diff --git a/Sources/CombineFeedbackUI/WithViewContext.swift b/Sources/CombineFeedbackUI/WithViewContext.swift index 2bb4660..65cc90c 100644 --- a/Sources/CombineFeedbackUI/WithViewContext.swift +++ b/Sources/CombineFeedbackUI/WithViewContext.swift @@ -26,8 +26,8 @@ public struct WithViewContext: View { } } -extension WithViewContext where State: Equatable { - public init( +public extension WithViewContext where State: Equatable { + init( store: Store, @ViewBuilder content: @escaping (ViewContext) -> Content ) {