From 370e18f90d20ac555890d652bf223a6fc55825a7 Mon Sep 17 00:00:00 2001 From: Matus Mistrik Date: Tue, 6 Aug 2024 14:33:14 +0200 Subject: [PATCH 1/4] feat: Swift 6 support --- .../project.pbxproj | 4 +-- .../Coordinators/Coordinator.swift | 1 + .../Extensions/UIControlExtensions.swift | 10 +++--- Package.swift | 9 +++-- Sources/GoodReactor/GoodReactor.swift | 35 ++++++++++--------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj index 2bb0f3a..bd285fa 100644 --- a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj @@ -469,7 +469,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -498,7 +498,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/GoodReactor-Sample/GoodReactor-Sample/Coordinators/Coordinator.swift b/GoodReactor-Sample/GoodReactor-Sample/Coordinators/Coordinator.swift index e677f9b..3bcc777 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Coordinators/Coordinator.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Coordinators/Coordinator.swift @@ -43,6 +43,7 @@ enum StepAction { } +@MainActor class Coordinator: GoodCoordinator { @discardableResult diff --git a/GoodReactor-Sample/GoodReactor-Sample/Extensions/UIControlExtensions.swift b/GoodReactor-Sample/GoodReactor-Sample/Extensions/UIControlExtensions.swift index 22585a0..94c1e5f 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Extensions/UIControlExtensions.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Extensions/UIControlExtensions.swift @@ -10,7 +10,7 @@ import Combine extension UIControl { - class InteractionSubscription: Subscription where S.Input == Void { + @MainActor class InteractionSubscription: Subscription where S.Input == Void { private let subscriber: S? private let control: UIControl @@ -28,12 +28,12 @@ extension UIControl { _ = self.subscriber?.receive(()) } - func request(_ demand: Subscribers.Demand) {} + nonisolated func request(_ demand: Subscribers.Demand) {} - func cancel() {} + nonisolated func cancel() {} } - struct InteractionPublisher: Publisher { + struct InteractionPublisher: @preconcurrency Publisher { typealias Output = Void typealias Failure = Never @@ -46,7 +46,7 @@ extension UIControl { self.event = event } - func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { + @MainActor func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { let subscription = InteractionSubscription( subscriber: subscriber, control: control, diff --git a/Package.swift b/Package.swift index 461a766..7f7bdd0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -26,10 +26,13 @@ let package = Package( dependencies: [ .product(name: "CombineExt", package: "CombineExt") ], - path: "./Sources/GoodReactor" + path: "./Sources/GoodReactor", + swiftSettings: [.swiftLanguageVersion(.v6)] ), .testTarget( name: "GoodReactorTests", - dependencies: ["GoodReactor"]), + dependencies: ["GoodReactor"], + swiftSettings: [.swiftLanguageVersion(.v6)] + ) ] ) diff --git a/Sources/GoodReactor/GoodReactor.swift b/Sources/GoodReactor/GoodReactor.swift index 8639764..d3c3a1b 100644 --- a/Sources/GoodReactor/GoodReactor.swift +++ b/Sources/GoodReactor/GoodReactor.swift @@ -1,6 +1,5 @@ // -// Reactor.swift -// Gopass +// GoodReactor.swift // // Created by Dominik Pethö on 8/13/20. // Copyright © 2020 GoodRequest. All rights reserved. @@ -54,10 +53,10 @@ open class GoodCoordinator: NSObject { @available(iOS 13.0, *) private enum MapTables { - static let cancellables = WeakMapTable>() - static let currentState = WeakMapTable() - static let action = WeakMapTable() - static let state = WeakMapTable() + nonisolated(unsafe) static let cancellables = WeakMapTable>() + nonisolated(unsafe) static let currentState = WeakMapTable() + nonisolated(unsafe) static let action = WeakMapTable() + nonisolated(unsafe) static let state = WeakMapTable() } @@ -121,13 +120,13 @@ public protocol GoodReactor: AnyObject, ObservableObject { // MARK: - Associated Object Keys -private var configKey = "config" -private var actionKey = "action" -private var currentStateKey = "currentState" -private var stateKey = "state" -private var cancellablesKey = "cancellables" -private var isStubEnabledKey = "isStubEnabled" -private var stubKey = "stub" +nonisolated(unsafe) private var configKey = "config" +nonisolated(unsafe) private var actionKey = "action" +nonisolated(unsafe) private var currentStateKey = "currentState" +nonisolated(unsafe) private var stateKey = "state" +nonisolated(unsafe) private var cancellablesKey = "cancellables" +nonisolated(unsafe) private var isStubEnabledKey = "isStubEnabled" +nonisolated(unsafe) private var stubKey = "stub" // MARK: - Default Implementations @@ -248,8 +247,10 @@ public extension GoodReactor where Self.ObjectWillChangePublisher == ObservableO self.send(action: action) await withCheckedContinuation { [weak self] (continuation: CheckedContinuation) in + guard let self = self else { return } + var cancelled = false - self?.state + self.state .dropFirst() .filter { !`while`($0) } .subscribe(on: DispatchQueue.main) @@ -263,7 +264,7 @@ public extension GoodReactor where Self.ObjectWillChangePublisher == ObservableO continuation.resume() cancelled = true } - .store(in: &cancellables) + .store(in: &self.cancellables) } } @@ -274,7 +275,7 @@ public extension GoodReactor where Self.ObjectWillChangePublisher == ObservableO /// - get: A closure that projects the global state to a local state. /// - localStateToViewAction: A closure that takes the local state as input and returns the action to send it to the view. /// - Returns: A binding to the local state. - func binding( + @MainActor func binding( get: @escaping (State) -> LocalState, send localStateToViewAction: @escaping (LocalState) -> Action ) -> Binding { @@ -287,7 +288,7 @@ public extension GoodReactor where Self.ObjectWillChangePublisher == ObservableO /// - get: A closure that projects the global state to a local state. /// - action: The action to send to the view when the local state changes. /// - Returns: A binding to the local state. - func binding( + @MainActor func binding( get: @escaping (State) -> LocalState, send action: Action ) -> Binding { From fb9258d4e03c398978f020a734cfc91fa499303a Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Tue, 27 Aug 2024 17:10:41 +0200 Subject: [PATCH 2/4] feat: NewReactor --- .swiftpm/configuration/Package.resolved | 42 ++ .../xcschemes/GoodReactor-Package.xcscheme | 103 ++++ .../xcschemes/GoodReactor.xcscheme | 84 +++ .../xcschemes/NewReactor.xcscheme | 67 +++ .../project.pbxproj | 200 ++++++- .../xcshareddata/swiftpm/Package.resolved | 57 +- .../xcschemes/GoodReactor-Sample.xcscheme | 78 +++ .../Coordinators/HomeCoordinator.swift | 7 +- .../Helpers/Constants.swift | 1 + .../Models/RNGEndpoint.swift | 45 ++ .../Models/RandomNumber.swift | 38 ++ .../Screens/Home/HomeViewController.swift | 37 +- .../Screens/Home/HomeViewModel.swift | 5 +- .../App/AppReactor.swift | 47 ++ .../App/AppSwiftUI.swift | 38 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../App/Assets.xcassets/Contents.json | 6 + .../Screens/Content/ContentView.swift | 139 +++++ .../Screens/Content/ContentViewModel.swift | 75 +++ .../Screens/Detail/DetailView.swift | 47 ++ .../Screens/Detail/DetailViewModel.swift | 42 ++ .../Screens/HomeView/HomeView.swift | 42 ++ .../Screens/HomeView/HomeViewModel.swift | 46 ++ .../Screens/LoginView/LoginView.swift | 35 ++ .../Screens/LoginView/LoginViewModel.swift | 56 ++ .../Screens/Profile/ProfileView.swift | 29 ++ .../Screens/Profile/ProfileViewModel.swift | 42 ++ Package.resolved | 30 +- Package.swift | 38 +- Sources/GoodReactor/GoodReactor.swift | 9 +- Sources/GoodReactor/WeakMapTable.swift | 2 +- Sources/NewReactor/AnyTask.swift | 54 ++ Sources/NewReactor/AsyncSemaphore.swift | 242 +++++++++ Sources/NewReactor/Coordinator.swift | 21 + Sources/NewReactor/Event.swift | 30 ++ Sources/NewReactor/Identifier.swift | 22 + Sources/NewReactor/MapTables.swift | 25 + .../NewReactor/NSPointerArrayExtension.swift | 58 +++ Sources/NewReactor/NewReactor.swift | 490 ++++++++++++++++++ Sources/NewReactor/Publisher.swift | 62 +++ Sources/NewReactor/Subscriber.swift | 97 ++++ Sources/NewReactor/ViewModel.swift | 32 ++ Sources/NewReactor/WeakMapTable.swift | 213 ++++++++ Tests/GoodReactorTests/GoodReactorTests.swift | 1 - Tests/NewReactorTests/NewReactorTests.swift | 68 +++ Tests/NewReactorTests/PublisherTests.swift | 108 ++++ .../Samples/ExternalTimer.swift | 28 + .../NewReactorTests/Samples/LegacyModel.swift | 111 ++++ .../Samples/ObservableModel.swift | 115 ++++ 50 files changed, 3275 insertions(+), 35 deletions(-) create mode 100644 .swiftpm/configuration/Package.resolved create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/GoodReactor.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/NewReactor.xcscheme create mode 100644 GoodReactor-Sample/GoodReactor-Sample.xcodeproj/xcshareddata/xcschemes/GoodReactor-Sample.xcscheme create mode 100644 GoodReactor-Sample/GoodReactor-Sample/Models/RNGEndpoint.swift create mode 100644 GoodReactor-Sample/GoodReactor-Sample/Models/RandomNumber.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/Contents.json create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift create mode 100644 GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift create mode 100644 Sources/NewReactor/AnyTask.swift create mode 100644 Sources/NewReactor/AsyncSemaphore.swift create mode 100644 Sources/NewReactor/Coordinator.swift create mode 100644 Sources/NewReactor/Event.swift create mode 100644 Sources/NewReactor/Identifier.swift create mode 100644 Sources/NewReactor/MapTables.swift create mode 100644 Sources/NewReactor/NSPointerArrayExtension.swift create mode 100644 Sources/NewReactor/NewReactor.swift create mode 100644 Sources/NewReactor/Publisher.swift create mode 100644 Sources/NewReactor/Subscriber.swift create mode 100644 Sources/NewReactor/ViewModel.swift create mode 100644 Sources/NewReactor/WeakMapTable.swift create mode 100644 Tests/NewReactorTests/NewReactorTests.swift create mode 100644 Tests/NewReactorTests/PublisherTests.swift create mode 100644 Tests/NewReactorTests/Samples/ExternalTimer.swift create mode 100644 Tests/NewReactorTests/Samples/LegacyModel.swift create mode 100644 Tests/NewReactorTests/Samples/ObservableModel.swift diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved new file mode 100644 index 0000000..35cf400 --- /dev/null +++ b/.swiftpm/configuration/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "e02e0b665e9cefb9f8137d5163f4cfc5715c7930de953536704b5aad449d5aa2", + "pins" : [ + { + "identity" : "combineext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineExt.git", + "state" : { + "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", + "version" : "1.8.1" + } + }, + { + "identity" : "goodlogger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoodRequest/GoodLogger.git", + "state" : { + "revision" : "7e0a11ffa920889c8d289c1dca60d6b0c94b0ae9", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" + } + } + ], + "version" : 3 +} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme new file mode 100644 index 0000000..46fb2dd --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor.xcscheme new file mode 100644 index 0000000..18a7e84 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor.xcscheme @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/NewReactor.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/NewReactor.xcscheme new file mode 100644 index 0000000..622d71d --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/NewReactor.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj index bd285fa..c218938 100644 --- a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj @@ -3,10 +3,17 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ + 0908A6462C91C0B10035A749 /* GoodCoordinator in Frameworks */ = {isa = PBXBuildFile; productRef = 0908A6452C91C0B10035A749 /* GoodCoordinator */; }; + 092188C22C8CD57900C6085A /* NewReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 092188C12C8CD57900C6085A /* NewReactor */; }; + 099D61632C8211B500B86922 /* NewReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 099D61622C8211B500B86922 /* NewReactor */; }; + 099D61702C83447900B86922 /* GoodCoordinator in Frameworks */ = {isa = PBXBuildFile; productRef = 099D616F2C83447900B86922 /* GoodCoordinator */; }; + 099D61732C8472D300B86922 /* GoodNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 099D61722C8472D300B86922 /* GoodNetworking */; }; + 099D617F2C84A3CC00B86922 /* RandomNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099D617C2C84A3CC00B86922 /* RandomNumber.swift */; }; + 099D61802C84A3CC00B86922 /* RNGEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099D617D2C84A3CC00B86922 /* RNGEndpoint.swift */; }; 5D4A9750299CCA4800DFAEAE /* GoodReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 5D4A974F299CCA4800DFAEAE /* GoodReactor */; }; EA51F944299424A900B14A7C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA51F943299424A900B14A7C /* AppDelegate.swift */; }; EA51F94F299424AA00B14A7C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA51F94E299424AA00B14A7C /* Assets.xcassets */; }; @@ -30,6 +37,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 092188B02C8CD2A100C6085A /* goodreactor-swiftui-sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "goodreactor-swiftui-sample.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 099D617C2C84A3CC00B86922 /* RandomNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomNumber.swift; sourceTree = ""; }; + 099D617D2C84A3CC00B86922 /* RNGEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNGEndpoint.swift; sourceTree = ""; }; 5D4A974E299CCA0F00DFAEAE /* GoodReactor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GoodReactor; path = ..; sourceTree = ""; }; EA51F940299424A900B14A7C /* GoodReactor-Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GoodReactor-Sample.app"; sourceTree = BUILT_PRODUCTS_DIR; }; EA51F943299424A900B14A7C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -54,18 +64,43 @@ EACEC3EB299524AB008242AA /* UINavigationBarExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationBarExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 092188B12C8CD2A100C6085A /* goodreactor-swiftui-sample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "goodreactor-swiftui-sample"; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 092188AD2C8CD2A100C6085A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0908A6462C91C0B10035A749 /* GoodCoordinator in Frameworks */, + 092188C22C8CD57900C6085A /* NewReactor in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; EA51F93D299424A900B14A7C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 099D61702C83447900B86922 /* GoodCoordinator in Frameworks */, + 099D61632C8211B500B86922 /* NewReactor in Frameworks */, 5D4A9750299CCA4800DFAEAE /* GoodReactor in Frameworks */, + 099D61732C8472D300B86922 /* GoodNetworking in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 099D617E2C84A3CC00B86922 /* Models */ = { + isa = PBXGroup; + children = ( + 099D617C2C84A3CC00B86922 /* RandomNumber.swift */, + 099D617D2C84A3CC00B86922 /* RNGEndpoint.swift */, + ); + path = Models; + sourceTree = ""; + }; 5D4A974D299CCA0F00DFAEAE /* Packages */ = { isa = PBXGroup; children = ( @@ -86,6 +121,7 @@ children = ( 5D4A974D299CCA0F00DFAEAE /* Packages */, EA51F942299424A900B14A7C /* GoodReactor-Sample */, + 092188B12C8CD2A100C6085A /* goodreactor-swiftui-sample */, EA51F941299424A900B14A7C /* Products */, EA282A7E2994FA5E00D0D1E2 /* Frameworks */, ); @@ -95,6 +131,7 @@ isa = PBXGroup; children = ( EA51F940299424A900B14A7C /* GoodReactor-Sample.app */, + 092188B02C8CD2A100C6085A /* goodreactor-swiftui-sample.app */, ); name = Products; sourceTree = ""; @@ -104,6 +141,7 @@ children = ( EA51F95D299425A600B14A7C /* Application */, EA751BAF29964F24004016E1 /* Helpers */, + 099D617E2C84A3CC00B86922 /* Models */, EA51F97E2994E03700B14A7C /* Extensions */, EA51F95E29942B1300B14A7C /* Coordinators */, EA51F96529942B9400B14A7C /* Screens */, @@ -225,6 +263,30 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 092188AF2C8CD2A100C6085A /* goodreactor-swiftui-sample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 092188BD2C8CD2A200C6085A /* Build configuration list for PBXNativeTarget "goodreactor-swiftui-sample" */; + buildPhases = ( + 092188AC2C8CD2A100C6085A /* Sources */, + 092188AD2C8CD2A100C6085A /* Frameworks */, + 092188AE2C8CD2A100C6085A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 092188B12C8CD2A100C6085A /* goodreactor-swiftui-sample */, + ); + name = "goodreactor-swiftui-sample"; + packageProductDependencies = ( + 092188C12C8CD57900C6085A /* NewReactor */, + 0908A6452C91C0B10035A749 /* GoodCoordinator */, + ); + productName = "goodreactor-swiftui-sample"; + productReference = 092188B02C8CD2A100C6085A /* goodreactor-swiftui-sample.app */; + productType = "com.apple.product-type.application"; + }; EA51F93F299424A900B14A7C /* GoodReactor-Sample */ = { isa = PBXNativeTarget; buildConfigurationList = EA51F956299424AA00B14A7C /* Build configuration list for PBXNativeTarget "GoodReactor-Sample" */; @@ -240,6 +302,9 @@ name = "GoodReactor-Sample"; packageProductDependencies = ( 5D4A974F299CCA4800DFAEAE /* GoodReactor */, + 099D61622C8211B500B86922 /* NewReactor */, + 099D616F2C83447900B86922 /* GoodCoordinator */, + 099D61722C8472D300B86922 /* GoodNetworking */, ); productName = "GoodReactor-Sample"; productReference = EA51F940299424A900B14A7C /* GoodReactor-Sample.app */; @@ -252,9 +317,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1610; LastUpgradeCheck = 1410; TargetAttributes = { + 092188AF2C8CD2A100C6085A = { + CreatedOnToolsVersion = 16.1; + }; EA51F93F299424A900B14A7C = { CreatedOnToolsVersion = 14.1; }; @@ -270,17 +338,27 @@ ); mainGroup = EA51F937299424A900B14A7C; packageReferences = ( + 099D61712C8472D300B86922 /* XCLocalSwiftPackageReference "../../GoodNetworking" */, + 0908A6442C91C0B10035A749 /* XCLocalSwiftPackageReference "../../GoodCoordinator-iOS" */, ); productRefGroup = EA51F941299424A900B14A7C /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( EA51F93F299424A900B14A7C /* GoodReactor-Sample */, + 092188AF2C8CD2A100C6085A /* goodreactor-swiftui-sample */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 092188AE2C8CD2A100C6085A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EA51F93E299424A900B14A7C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -294,6 +372,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 092188AC2C8CD2A100C6085A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EA51F93C299424A900B14A7C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -314,6 +399,8 @@ EABE433C2995128400EB51BD /* Constants.swift in Sources */, EA6B6D8A2994F30E0035186A /* AboutViewController.swift in Sources */, EA6B6D892994F30E0035186A /* AboutViewModel.swift in Sources */, + 099D617F2C84A3CC00B86922 /* RandomNumber.swift in Sources */, + 099D61802C84A3CC00B86922 /* RNGEndpoint.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -331,6 +418,73 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 092188BB2C8CD2A200C6085A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.goodrequest.goodreactor-swiftui-sample"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 092188BC2C8CD2A200C6085A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.goodrequest.goodreactor-swiftui-sample"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; EA51F954299424AA00B14A7C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -452,6 +606,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "GoodReactor-Sample/Application/Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -481,6 +636,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "GoodReactor-Sample/Application/Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -506,6 +662,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 092188BD2C8CD2A200C6085A /* Build configuration list for PBXNativeTarget "goodreactor-swiftui-sample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 092188BB2C8CD2A200C6085A /* Debug */, + 092188BC2C8CD2A200C6085A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; EA51F93B299424A900B14A7C /* Build configuration list for PBXProject "GoodReactor-Sample" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -526,7 +691,38 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 0908A6442C91C0B10035A749 /* XCLocalSwiftPackageReference "../../GoodCoordinator-iOS" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../GoodCoordinator-iOS"; + }; + 099D61712C8472D300B86922 /* XCLocalSwiftPackageReference "../../GoodNetworking" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../GoodNetworking; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 0908A6452C91C0B10035A749 /* GoodCoordinator */ = { + isa = XCSwiftPackageProductDependency; + productName = GoodCoordinator; + }; + 092188C12C8CD57900C6085A /* NewReactor */ = { + isa = XCSwiftPackageProductDependency; + productName = NewReactor; + }; + 099D61622C8211B500B86922 /* NewReactor */ = { + isa = XCSwiftPackageProductDependency; + productName = NewReactor; + }; + 099D616F2C83447900B86922 /* GoodCoordinator */ = { + isa = XCSwiftPackageProductDependency; + productName = GoodCoordinator; + }; + 099D61722C8472D300B86922 /* GoodNetworking */ = { + isa = XCSwiftPackageProductDependency; + productName = GoodNetworking; + }; 5D4A974F299CCA4800DFAEAE /* GoodReactor */ = { isa = XCSwiftPackageProductDependency; productName = GoodReactor; diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8189d6d..f6509d9 100644 --- a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,24 @@ { + "originHash" : "29f6f72823407f70983528a7c3a0a061ad6f1aa0c17e358f4623a31f0b714680", "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", + "version" : "5.9.1" + } + }, + { + "identity" : "alamofireimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/AlamofireImage.git", + "state" : { + "revision" : "1eaf3b6c6882bed10f6e7b119665599dd2329aa1", + "version" : "4.3.0" + } + }, { "identity" : "combineext", "kind" : "remoteSourceControl", @@ -8,7 +27,43 @@ "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", "version" : "1.8.1" } + }, + { + "identity" : "goodlogger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoodRequest/GoodLogger.git", + "state" : { + "revision" : "4c5761a062fd2a98c9b81078a029a14b67ccab2a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "515f79b522918f83483068d99c68daeb5116342d", + "version" : "600.0.0-prerelease-2024-09-04" + } } ], - "version" : 2 + "version" : 3 } diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/xcshareddata/xcschemes/GoodReactor-Sample.xcscheme b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/xcshareddata/xcschemes/GoodReactor-Sample.xcscheme new file mode 100644 index 0000000..d9a2e5a --- /dev/null +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/xcshareddata/xcschemes/GoodReactor-Sample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodReactor-Sample/GoodReactor-Sample/Coordinators/HomeCoordinator.swift b/GoodReactor-Sample/GoodReactor-Sample/Coordinators/HomeCoordinator.swift index 84441ed..c90d490 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Coordinators/HomeCoordinator.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Coordinators/HomeCoordinator.swift @@ -5,7 +5,7 @@ // Created by GoodRequest on 08/02/2023. // -import UIKit +import SwiftUI enum HomeStep { @@ -46,9 +46,8 @@ class HomeCoordinator: Coordinator { let aboutViewController = AboutCoordinator( rootViewController: rootViewController, parentCoordinator: self - ) - .start() - + ).start() + return .push(aboutViewController) } } diff --git a/GoodReactor-Sample/GoodReactor-Sample/Helpers/Constants.swift b/GoodReactor-Sample/GoodReactor-Sample/Helpers/Constants.swift index d5dd01d..fda3992 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Helpers/Constants.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Helpers/Constants.swift @@ -40,6 +40,7 @@ struct Constants { static let increase = "Increase counter" static let decrease = "Decrease counter" static let aboutApp = "About app" + static let swiftUIButton = "SwiftUI preview" } diff --git a/GoodReactor-Sample/GoodReactor-Sample/Models/RNGEndpoint.swift b/GoodReactor-Sample/GoodReactor-Sample/Models/RNGEndpoint.swift new file mode 100644 index 0000000..b766cb3 --- /dev/null +++ b/GoodReactor-Sample/GoodReactor-Sample/Models/RNGEndpoint.swift @@ -0,0 +1,45 @@ +// +// RNGEndpoint.swift +// GoodReactor-Sample +// +// Created by Filip Šašala on 01/09/2024. +// + +import Alamofire +import Foundation +import GoodNetworking + +// https://www.randomnumberapi.com/api/v1.0/random?min=1&max=100 + +enum RNGEndpoint: Endpoint { + + case randomNumber + + var path: String { + "https://www.randomnumberapi.com/api/v1.0/random" + } + + var method: Alamofire.HTTPMethod { + return .get + } + + var parameters: GoodNetworking.EndpointParameters? { + return .parameters([ + "min": "0", + "max": "100" + ]) + } + + var headers: Alamofire.HTTPHeaders? { + nil + } + + var encoding: any Alamofire.ParameterEncoding { + return URLEncoding.default + } + + func url(on _: String) throws -> URL { + return try path.asURL() + } + +} diff --git a/GoodReactor-Sample/GoodReactor-Sample/Models/RandomNumber.swift b/GoodReactor-Sample/GoodReactor-Sample/Models/RandomNumber.swift new file mode 100644 index 0000000..6ed23bf --- /dev/null +++ b/GoodReactor-Sample/GoodReactor-Sample/Models/RandomNumber.swift @@ -0,0 +1,38 @@ +// +// RandomNumber.swift +// GoodReactor-Sample +// +// Created by Filip Šašala on 01/09/2024. +// + +import GoodNetworking + +extension Int: @retroactive Placeholdable { + + public static let placeholder: Int = 0 + +} + +struct RandomNumberResource: Readable { + + typealias ReadRequest = Void + typealias ReadResponse = [Int] + typealias Resource = Int + + nonisolated static func endpoint(_ request: Void) throws(NetworkError) -> any Endpoint { + RNGEndpoint.randomNumber + } + + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> Void? { + () + } + + nonisolated static func resource(from response: [Int]) throws(NetworkError) -> Int { + if let first = response.first { + return first + } else { + throw .missingRemoteData + } + } + +} diff --git a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift index 1a42ca3..b2cab01 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift @@ -5,8 +5,9 @@ // Created by GoodRequest on 08/02/2023. // -import UIKit import Combine +import UIKit +import NewReactor final class HomeViewController: BaseViewController { @@ -52,6 +53,13 @@ final class HomeViewController: BaseViewController { return aboutAppButton }() + private let swiftUIButton: ActionButton = { + let swiftUIButton = ActionButton() + swiftUIButton.setTitle(Constants.Texts.Home.swiftUIButton, for: .normal) + + return swiftUIButton + }() + } // MARK: - Lifecycle @@ -65,6 +73,8 @@ extension HomeViewController { bindState(reactor: viewModel) bindActions(reactor: viewModel) + + viewModel.start() } } @@ -76,8 +86,10 @@ private extension HomeViewController { view.backgroundColor = UIColor(named: "background") title = Constants.Texts.Home.title - [increasingButton, decreasingButton, aboutAppButton].forEach{ actionsStackView.addArrangedSubview($0) } - [counterValueLabel, actionsStackView].forEach { view.addSubview($0) } + [increasingButton, decreasingButton, aboutAppButton, swiftUIButton] + .forEach{ actionsStackView.addArrangedSubview($0) } + [counterValueLabel, actionsStackView] + .forEach { view.addSubview($0) } setupConstraints() } @@ -100,21 +112,28 @@ private extension HomeViewController { private extension HomeViewController { func bindState(reactor: HomeViewModel) { - reactor.state + reactor.stateStream .map { String($0.counterValue) } .removeDuplicates() .assign(to: \.text, on: counterValueLabel, ownership: .weak) .store(in: &cancellables) - } func bindActions(reactor: HomeViewModel) { - Publishers.Merge3( + Publishers.Merge( increasingButton.publisher(for: .touchUpInside).map { _ in .updateCounterValue(.increase) }, - decreasingButton.publisher(for: .touchUpInside).map { _ in .updateCounterValue(.decrease) }, - aboutAppButton.publisher(for: .touchUpInside).map { _ in .goToAbout } + decreasingButton.publisher(for: .touchUpInside).map { _ in .updateCounterValue(.decrease) } + ) + .map { .action($0) } + .subscribe(reactor.eventStream) + .store(in: &cancellables) + + Publishers.Merge( + aboutAppButton.publisher(for: .touchUpInside).map { _ in .about }, + swiftUIButton.publisher(for: .touchUpInside).map { _ in .swiftUISample } ) - .subscribe(reactor.action) + .map { .destination($0) } + .subscribe(reactor.eventStream) .store(in: &cancellables) } diff --git a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewModel.swift b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewModel.swift index 0555322..b61aec8 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewModel.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewModel.swift @@ -1,4 +1,4 @@ -// +// // HomeViewModel.swift // GoodReactor-Sample // @@ -10,6 +10,7 @@ import GoodReactor final class HomeViewModel: GoodReactor { + // MARK: - Enums enum CounterMode { @@ -21,8 +22,8 @@ final class HomeViewModel: GoodReactor { enum Action { - case updateCounterValue(CounterMode) case goToAbout + case updateCounterValue(CounterMode) } diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift new file mode 100644 index 0000000..f48fb8f --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift @@ -0,0 +1,47 @@ +// +// AppReactor.swift +// GoodReactor-Sample +// +// Created by Filip Šašala on 07/09/2024. +// + +import GoodCoordinator +import NewReactor +import Observation +import SwiftUI + +// TODO: Coordinator reactor macro for empty reactors +@Observable final class AppReactor: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + } + + enum Mutation { + + } + + @Observable final class State { + + } + + @Navigable enum Destination: Tabs { + + static let initialDestination = Self.loggedOut + + case loggedOut + case loggedIn + + } + + func makeInitialState() -> State { + return State() + } + + func reduce(state: inout State, event: Event) { + + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift new file mode 100644 index 0000000..d5daa84 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift @@ -0,0 +1,38 @@ +// +// goodreactor_swiftui_sampleApp.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 07/09/2024. +// + +import GoodCoordinator +import NewReactor +import SwiftUI + +@main struct goodreactor_swiftui_sampleApp: App { + + var body: some Scene { + WindowGroup { + MainWindow() + } + } +} + +@NavigationRoot struct MainWindow: View { + + @ViewModel private var mainWindowModel = AppReactor() + + var body: some View { + switch mainWindowModel.destination { + case .loggedIn: + HomeView() + + case .loggedOut: + LoginView() + + case .none: + EmptyView() + } + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AccentColor.colorset/Contents.json b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/Contents.json b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift new file mode 100644 index 0000000..6b0606b --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift @@ -0,0 +1,139 @@ +// +// ContentView.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 07/09/2024. +// + +import GoodCoordinator +import NewReactor +import SwiftUI + +struct ContentView: View { + + @ViewModel var model = ContentViewModel() + + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + + Text(model.text) + Text("Value: \(model.counter)") + Text("Error: \(model.destinations.errorAlert)") + + Button { + model.send(destination: .presentSheet) + } label: { + Text("Present") + } + + Button { + model.send(destination: .presentSheet2) + } label: { + Text("Present other") + } + + Button { + model.send(destination: .pushScreen) + } label: { + Text("Push") + } + + Button { + model.send(destination: .errorAlert) + } label: { + Text("Error alert") + } + + Button { + model.send(destination: .confirmationDialog) + } label: { + Text("Confirmation dialog") + } + + Divider() + TextField("Text", text: model.bind(\.text, action: { .setText($0) })).padding() + Divider() + + Button { + model.send(destination: .detail(3)) + } label: { + Text("Go to detail") + } + + Button { + #router.route(HomeViewModel.self, .profile) + } label: { + Text("Go to profile") + } + + Button { + #router.pop() + } label: { + Text("Try to pop a Tab") + } + + Button { + #router.route( + type: HomeViewModel.self, + ContentViewModel.self, + destination: .home, + .detail(8) + ) + } label: { + Text("Go 3 levels deep") + } + } + .padding() + .sheet(isPresented: $model.destinations.presentSheet, content: { + VStack { + Text("Present sheet") + Button { + model.send(destination: .pushScreen) + } label: { + Text("Go to pushed screen") + } + } + }) + .sheet(isPresented: $model.destinations.presentSheet2, content: { + VStack { + Text("Other sheet") + Button { + model.send(destination: .presentSheet) + } label: { + Text("Go to first sheet") + } + } + }) + .navigationDestination(isPresented: $model.destinations.pushScreen, destination: { + VStack { + Text("Push screen") + Button { + model.send(destination: .presentSheet) + } label: { + Text("Go to presented screen") + } + } + }) + .navigationDestination(isPresented: $model.destinations.detail, destination: { + if case .detail(let value) = model.destination { + DetailView(value: value) + } + }) + .alert("Error", isPresented: $model.destinations.errorAlert) { + Button("Cancel", role: .cancel) {} + } + .confirmationDialog("Confirm action", isPresented: $model.destinations.confirmationDialog) { + Button("OK", role: .none) {} + Button("Delete", role: .destructive) { + model.send(destination: .presentSheet) + } + } + } +} + +#Preview { + ContentView() +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift new file mode 100644 index 0000000..4222fff --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift @@ -0,0 +1,75 @@ +// +// ContentViewModel.swift +// GoodReactor-Sample +// +// Created by Filip Šašala on 07/09/2024. +// + +import GoodCoordinator +import NewReactor +import Observation +import SwiftUI + +@Observable final class ContentViewModel: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + case fetchData + case setText(String) + + } + + enum Mutation { + + case didFetchData + + } + + @Observable final class State { + + var isLoading = false + var counter: Int = 10 + var text = "Hello, world!" + + } + + @Navigable enum Destination { + + case detail(Int) + + case presentSheet + case presentSheet2 + case pushScreen + case errorAlert + case confirmationDialog + + } + + func makeInitialState() -> State { + return State() + } + + func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.setText(let newText)): + state.text = newText + + case .action(.fetchData): + state.isLoading = true + run(event) { await self.fetchData() } + + case .mutation(.didFetchData): + print("data fetching done") + + case .destination: + break + } + } + + func fetchData() async -> Mutation { + return .didFetchData + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift new file mode 100644 index 0000000..40e6354 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift @@ -0,0 +1,47 @@ +// +// DetailView.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 11/09/2024. +// + +import GoodCoordinator +import SwiftUI +import NewReactor + +struct DetailView: View { + + @ViewModel private var model = DetailViewModel() + + let value: Int + + var body: some View { + VStack { + Text("Detail for value \(value)") + + Button { + #router.route(HomeViewModel.self, .profile) + } label: { + Text("Change path to profile") + } + + Button { + #router.pop(last: 3) + } label: { + Text("Pop three") + } + + Button { + model.send(destination: .detail(value - 1)) + } label: { + Text("Push detail with one less") + } + } + .navigationDestination(isPresented: $model.destinations.detail) { + if case .detail(let value) = model.destination { + DetailView(value: value) + } + } + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift new file mode 100644 index 0000000..1527637 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift @@ -0,0 +1,42 @@ +// +// DetailViewModel.swift +// GoodReactor-Sample +// +// Created by Filip Šašala on 11/09/2024. +// + +import GoodCoordinator +import NewReactor +import Observation + +@Observable final class DetailViewModel: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + } + + enum Mutation { + + } + + @Navigable enum Destination { + + case detail(Int) + + } + + @Observable final class State { + + } + + func makeInitialState() -> State { + State() + } + + func reduce(state: inout State, event: Event) { + + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift new file mode 100644 index 0000000..1f50f76 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift @@ -0,0 +1,42 @@ +// +// TabBarView.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 26/09/2024. +// + +import SwiftUI +import NewReactor + +struct HomeView: View { + + // MARK: - Wrappers + + @ViewModel private var model = HomeViewModel() + + // MARK: - View state + + // MARK: - Properties + + // MARK: - Initialization + + // MARK: - Computed properties + + // MARK: - Body + + var body: some View { + TabView(selection: $model.destination) { + Tab("Home", systemImage: "swift", value: .home) { + NavigationStack { + ContentView() + } + } + Tab("Profile", systemImage: "person", value: .profile) { + NavigationStack { + ProfileView() + } + } + } + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift new file mode 100644 index 0000000..140499b --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift @@ -0,0 +1,46 @@ +// +// TabBarViewModel.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 26/09/2024. +// + +import Foundation +import GoodCoordinator +import NewReactor +import Observation + +@Observable final class HomeViewModel: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + } + + enum Mutation { + + } + + @Observable final class State { + + } + + @Navigable enum Destination: Tabs { + + static let initialDestination = Self.home + + case home + case profile + + } + + func makeInitialState() -> State { + return State() + } + + func reduce(state: inout State, event: Event) { + + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift new file mode 100644 index 0000000..91ee475 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift @@ -0,0 +1,35 @@ +// +// LoginView.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 26/09/2024. +// + +import SwiftUI +import NewReactor + +struct LoginView: View { + + @ViewModel private var model = LoginViewModel() + + // MARK: - Wrappers + + // MARK: - View state + + // MARK: - Properties + + // MARK: - Initialization + + // MARK: - Computed properties + + // MARK: - Body + + var body: some View { + Button { + model.send(action: .sendLoginRequest) + } label: { + Text("Login") + } + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift new file mode 100644 index 0000000..f41501c --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift @@ -0,0 +1,56 @@ +// +// LoginViewModel.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 26/09/2024. +// + +import Foundation +import GoodCoordinator +import NewReactor +import Observation + +@Observable final class LoginViewModel: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + case sendLoginRequest + + } + + enum Mutation { + + } + + @Observable final class State { + + } + + @Navigable enum Destination: CaseIterable { + + } + + func makeInitialState() -> State { + return State() + } + + func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.sendLoginRequest): + run(event) { + // send request here (asynchronously) + // process + + // route (on MainActor) + await #router.route(AppReactor.self, .loggedIn) + + // no mutation + return nil + } + } + } + +} + diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift new file mode 100644 index 0000000..e8c67ae --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift @@ -0,0 +1,29 @@ +// +// ProfileView.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 24/09/2024. +// + +import NewReactor +import GoodCoordinator +import SwiftUI + +struct ProfileView: View { + + @ViewModel private var model = ProfileViewModel() + + var body: some View { + VStack { + Text("Profile") + + Button { + #router.route(AppReactor.self, .loggedOut) + #router.cleanup() + } label: { + Text("Logout") + } + } + } + +} diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift new file mode 100644 index 0000000..15b73c4 --- /dev/null +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift @@ -0,0 +1,42 @@ +// +// ProfileViewModel.swift +// goodreactor-swiftui-sample +// +// Created by Filip Šašala on 24/09/2024. +// + +import Foundation +import GoodCoordinator +import NewReactor +import Observation + +@Observable final class ProfileViewModel: Reactor { + + typealias Event = NewReactor.Event + + enum Action { + + } + + enum Mutation { + + } + + @Observable final class State { + + } + + @Navigable enum Destination { + + } + + func makeInitialState() -> State { + return State() + } + + func reduce(state: inout State, event: Event) { + + } + +} + diff --git a/Package.resolved b/Package.resolved index 8189d6d..85cf1f7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "7d09911a2b9fdc8c8d6e885386ea8fa518defa1e0331fa9ad1b1204a6836b658", "pins" : [ { "identity" : "combineext", @@ -8,7 +9,34 @@ "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", "version" : "1.8.1" } + }, + { + "identity" : "goodlogger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoodRequest/GoodLogger.git", + "state" : { + "revision" : "4c5761a062fd2a98c9b81078a029a14b67ccab2a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 7f7bdd0..af1b176 100644 --- a/Package.swift +++ b/Package.swift @@ -6,33 +6,53 @@ import PackageDescription let package = Package( name: "GoodReactor", platforms: [ - .iOS(.v13) + .iOS(.v13), + .macOS(.v11) ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "GoodReactor", - targets: ["GoodReactor"]), + targets: ["GoodReactor"] + ), + .library( + name: "NewReactor", + targets: ["NewReactor"] + ) ], dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/CombineCommunity/CombineExt.git", from: "1.8.1") + .package(url: "https://github.com/CombineCommunity/CombineExt.git", from: "1.8.1"), + .package(url: "https://github.com/apple/swift-async-algorithms.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.3")), + .package(url: "https://github.com/GoodRequest/GoodLogger.git", .upToNextMajor(from: "1.1.0")) ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "GoodReactor", dependencies: [ .product(name: "CombineExt", package: "CombineExt") ], path: "./Sources/GoodReactor", - swiftSettings: [.swiftLanguageVersion(.v6)] + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target( + name: "NewReactor", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "Collections", package: "swift-collections"), + .product(name: "GoodLogger", package: "GoodLogger") + ], + path: "./Sources/NewReactor", + swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "GoodReactorTests", dependencies: ["GoodReactor"], - swiftSettings: [.swiftLanguageVersion(.v6)] + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "NewReactorTests", + dependencies: ["NewReactor"], + swiftSettings: [.swiftLanguageMode(.v6)] ) ] ) diff --git a/Sources/GoodReactor/GoodReactor.swift b/Sources/GoodReactor/GoodReactor.swift index d3c3a1b..2c246db 100644 --- a/Sources/GoodReactor/GoodReactor.swift +++ b/Sources/GoodReactor/GoodReactor.swift @@ -53,10 +53,10 @@ open class GoodCoordinator: NSObject { @available(iOS 13.0, *) private enum MapTables { - nonisolated(unsafe) static let cancellables = WeakMapTable>() - nonisolated(unsafe) static let currentState = WeakMapTable() - nonisolated(unsafe) static let action = WeakMapTable() - nonisolated(unsafe) static let state = WeakMapTable() + static let cancellables = WeakMapTable>() + static let currentState = WeakMapTable() + static let action = WeakMapTable() + static let state = WeakMapTable() } @@ -138,7 +138,6 @@ public extension GoodReactor where Self.ObjectWillChangePublisher == ObservableO set { objectWillChange.send() MapTables.currentState.setValue(newValue, forKey: self) - } } diff --git a/Sources/GoodReactor/WeakMapTable.swift b/Sources/GoodReactor/WeakMapTable.swift index 942e5f5..a45ecae 100644 --- a/Sources/GoodReactor/WeakMapTable.swift +++ b/Sources/GoodReactor/WeakMapTable.swift @@ -12,7 +12,7 @@ import Foundation /// The WeakMapTable class is a dictionary that uses weak references as its keys. It provides a way to associate values with keys, where the keys are objects. /// The values are stored as long as the key objects are alive, and they are automatically removed when the key objects are deallocated. -final public class WeakMapTable where Key: AnyObject { +final public class WeakMapTable: @unchecked Sendable where Key: AnyObject { private var dictionary: [Weak: Value] = [:] private let lock = NSRecursiveLock() diff --git a/Sources/NewReactor/AnyTask.swift b/Sources/NewReactor/AnyTask.swift new file mode 100644 index 0000000..282c992 --- /dev/null +++ b/Sources/NewReactor/AnyTask.swift @@ -0,0 +1,54 @@ +// +// AnyTask.swift +// GoodReactor +// +// Created by Filip Šašala on 28/08/2024. +// + +import Foundation + +public final class AnyTask: Identifiable { + + /// Cancel the task manually. + let cancel: () -> Void + + /// Checks whether the task is cancelled. + var isCancelled: Bool { isCancelledBlock() } + + private let isCancelledBlock: () -> Bool + + deinit { + if !isCancelled { cancel() } + } + + init(_ task: Task) { + cancel = task.cancel + isCancelledBlock = { task.isCancelled } + } +} + +extension AnyTask: Hashable, Equatable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + public static func == (lhs: AnyTask, rhs: AnyTask) -> Bool { + return lhs.id == rhs.id + } + +} + +public extension Task { + + var eraseToAnyTask: AnyTask { AnyTask(self) } + + func store(in collection: inout some RangeReplaceableCollection) { + collection.append(eraseToAnyTask) + } + + func store(in set: inout some SetAlgebra) { + set.insert(eraseToAnyTask) + } + +} diff --git a/Sources/NewReactor/AsyncSemaphore.swift b/Sources/NewReactor/AsyncSemaphore.swift new file mode 100644 index 0000000..baede9b --- /dev/null +++ b/Sources/NewReactor/AsyncSemaphore.swift @@ -0,0 +1,242 @@ +// +// AsyncSemaphore.swift +// GoodReactor +// +// Created by Filip Šašala on 26/08/2024. +// + +// Implementation of DispatchSemaphore using Swift Concurrency +// https://github.com/groue/Semaphore + +import Foundation + +/// An object that controls access to a resource across multiple execution +/// contexts through use of a traditional counting semaphore. +/// +/// You increment a semaphore count by calling the ``signal()`` method, and +/// decrement a semaphore count by calling ``wait()`` or one of its variants. +/// +/// ## Topics +/// +/// ### Creating a Semaphore +/// +/// - ``init(value:)`` +/// +/// ### Signaling the Semaphore +/// +/// - ``signal()`` +/// +/// ### Waiting for the Semaphore +/// +/// - ``wait()`` +/// - ``waitUnlessCancelled()`` +public final class AsyncSemaphore: @unchecked Sendable { + /// `Suspension` is the state of a task waiting for a signal. + /// + /// It is a class because instance identity helps `waitUnlessCancelled()` + /// deal with both early and late cancellation. + /// + /// We make it @unchecked Sendable in order to prevent compiler warnings: + /// instances are always protected by the semaphore's lock. + private class Suspension: @unchecked Sendable { + enum State { + /// Initial state. Next is suspendedUnlessCancelled, or cancelled. + case pending + + /// Waiting for a signal, with support for cancellation. + case suspendedUnlessCancelled(UnsafeContinuation) + + /// Waiting for a signal, with no support for cancellation. + case suspended(UnsafeContinuation) + + /// Cancelled before we have started waiting. + case cancelled + } + + var state: State + + init(state: State) { + self.state = state + } + } + + // MARK: - Internal State + + /// The semaphore value. + private(set) public var value: Int + + /// As many elements as there are suspended tasks waiting for a signal. + private var suspensions: [Suspension] = [] + + /// The lock that protects `value` and `suspensions`. + /// + /// It is recursive in order to handle cancellation (see the implementation + /// of ``waitUnlessCancelled()``). + private let _lock = NSRecursiveLock() + + // MARK: - Creating a Semaphore + + /// Creates a semaphore. + /// + /// - parameter value: The starting value for the semaphore. Do not pass a + /// value less than zero. + public init(value: Int) { + precondition(value >= 0, "AsyncSemaphore requires a value equal or greater than zero") + self.value = value + } + + deinit { + precondition(suspensions.isEmpty, "AsyncSemaphore is deallocated while some task(s) are suspended waiting for a signal.") + } + + // MARK: - Locking + + // Let's hide the locking primitive in order to avoid a compiler warning: + // + // > Instance method 'lock' is unavailable from asynchronous contexts; + // > Use async-safe scoped locking instead; this is an error in Swift 6. + // + // We're not sweeping bad stuff under the rug. We really need to protect + // our inner state (`value` and `suspension`) across the calls to + // `withUnsafeContinuation`. Unfortunately, this method introduces a + // suspension point. So we need a lock. + private func lock() { _lock.lock() } + private func unlock() { _lock.unlock() } + + // MARK: - Waiting for the Semaphore + + /// Waits for, or decrements, a semaphore. + /// + /// Decrement the counting semaphore. If the resulting value is less than + /// zero, this function suspends the current task until a signal occurs, + /// without blocking the underlying thread. Otherwise, no suspension happens. + public func wait() async { + lock() + + value -= 1 + if value >= 0 { + unlock() + return + } + + await withUnsafeContinuation { continuation in + // Register the continuation that `signal` will resume. + let suspension = Suspension(state: .suspended(continuation)) + suspensions.insert(suspension, at: 0) // FIFO + unlock() + } + } + + /// Waits for, or decrements, a semaphore, with support for cancellation. + /// + /// Decrement the counting semaphore. If the resulting value is less than + /// zero, this function suspends the current task until a signal occurs, + /// without blocking the underlying thread. Otherwise, no suspension happens. + /// + /// If the task is canceled before a signal occurs, this function + /// throws `CancellationError`. + public func waitUnlessCancelled() async throws { + lock() + + value -= 1 + if value >= 0 { + defer { unlock() } + + do { + // All code paths check for cancellation + try Task.checkCancellation() + } catch { + // Cancellation is like a signal: we don't really "consume" + // the semaphore, and restore the value. + value += 1 + throw error + } + + return + } + + // Get ready for being suspended waiting for a continuation, or for + // early cancellation. + let suspension = Suspension(state: .pending) + + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in + if case .cancelled = suspension.state { + // Early cancellation: waitUnlessCancelled() is called from + // a cancelled task, and the `onCancel` closure below + // has marked the suspension as cancelled. + // Resume with a CancellationError. + unlock() + continuation.resume(throwing: CancellationError()) + } else { + // Current task is not cancelled: register the continuation + // that `signal` will resume. + suspension.state = .suspendedUnlessCancelled(continuation) + suspensions.insert(suspension, at: 0) // FIFO + unlock() + } + } + } onCancel: { + // withTaskCancellationHandler may immediately call this block (if + // the current task is cancelled), or call it later (if the task is + // cancelled later). In the first case, we're still holding the lock, + // waiting for the continuation. In the second case, we do not hold + // the lock. Being able to handle both situations is the reason why + // we use a recursive lock. + lock() + + // We're no longer waiting for a signal + value += 1 + if let index = suspensions.firstIndex(where: { $0 === suspension }) { + suspensions.remove(at: index) + } + + if case let .suspendedUnlessCancelled(continuation) = suspension.state { + // Late cancellation: the task is cancelled while waiting + // from the semaphore. Resume with a CancellationError. + unlock() + continuation.resume(throwing: CancellationError()) + } else { + // Early cancellation: waitUnlessCancelled() is called from + // a cancelled task. + // + // The next step is the `withTaskCancellationHandler` + // operation closure right above. + suspension.state = .cancelled + unlock() + } + } + } + + // MARK: - Signaling the Semaphore + + /// Signals (increments) a semaphore. + /// + /// Increment the counting semaphore. If the previous value was less than + /// zero, this function resumes a task currently suspended in ``wait()`` + /// or ``waitUnlessCancelled()``. + /// + /// - returns: This function returns true if a suspended task is + /// resumed. Otherwise, the result is false, meaning that no task was + /// waiting for the semaphore. + @discardableResult + public func signal() -> Bool { + lock() + + value += 1 + + switch suspensions.popLast()?.state { // FIFO + case let .suspendedUnlessCancelled(continuation): + unlock() + continuation.resume() + return true + case let .suspended(continuation): + unlock() + continuation.resume() + return true + default: + unlock() + return false + } + } +} diff --git a/Sources/NewReactor/Coordinator.swift b/Sources/NewReactor/Coordinator.swift new file mode 100644 index 0000000..841f804 --- /dev/null +++ b/Sources/NewReactor/Coordinator.swift @@ -0,0 +1,21 @@ +// +// Coordinator.swift +// GoodReactor +// +// Created by Filip Šašala on 09/09/2024. +// + +import SwiftUI + +@MainActor public protocol Screen: View { + +} + +@MainActor public protocol Coordinator { + + associatedtype R: Reactor + associatedtype S: Screen + + @ViewBuilder func makeScreen() -> S + +} diff --git a/Sources/NewReactor/Event.swift b/Sources/NewReactor/Event.swift new file mode 100644 index 0000000..9f66863 --- /dev/null +++ b/Sources/NewReactor/Event.swift @@ -0,0 +1,30 @@ +// +// Event.swift +// GoodReactor +// +// Created by Filip Šašala on 27/08/2024. +// + +public final class Event: Sendable where A: Sendable, M: Sendable, D: Sendable { + + public enum Kind: Sendable { + case action(A) + case mutation(M) + case destination(D) + } + + internal let id: EventIdentifier + public let kind: Event.Kind + + internal init(kind: Event.Kind) { + self.id = EventIdentifier() + self.kind = kind + } + + convenience public init(destination: D) { + self.init(kind: .destination(destination)) + } + +} + +internal final class EventIdentifier: Identifier, Sendable {} diff --git a/Sources/NewReactor/Identifier.swift b/Sources/NewReactor/Identifier.swift new file mode 100644 index 0000000..c8e1c0d --- /dev/null +++ b/Sources/NewReactor/Identifier.swift @@ -0,0 +1,22 @@ +// +// Identifier.swift +// GoodReactor +// +// Created by Filip Šašala on 27/08/2024. +// + +// MARK: - Identifier + +public protocol Identifier: AnyObject, Identifiable, Equatable, Hashable {} + +public extension Identifier { + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + +} diff --git a/Sources/NewReactor/MapTables.swift b/Sources/NewReactor/MapTables.swift new file mode 100644 index 0000000..ad79b24 --- /dev/null +++ b/Sources/NewReactor/MapTables.swift @@ -0,0 +1,25 @@ +// +// MapTables.swift +// GoodReactor +// +// Created by Filip Šašala on 27/08/2024. +// + +import GoodLogger + +internal enum MapTables { + + typealias AnyReactor = AnyObject + + static let state = WeakMapTable() + static let initialState = WeakMapTable() + static let destinations = WeakMapTable() + static let runningEvents = WeakMapTable>() + static let subscriptions = WeakMapTable>() + static let stateStreams = WeakMapTable() + static let eventStreams = WeakMapTable() + static let loggers = WeakMapTable() + + static let eventLocks = WeakMapTable() + +} diff --git a/Sources/NewReactor/NSPointerArrayExtension.swift b/Sources/NewReactor/NSPointerArrayExtension.swift new file mode 100644 index 0000000..cb0bff2 --- /dev/null +++ b/Sources/NewReactor/NSPointerArrayExtension.swift @@ -0,0 +1,58 @@ +// +// NSPointerArrayExtension.swift +// GoodReactor +// +// Created by Filip Šašala on 28/08/2024. +// + +import Foundation + +public extension NSPointerArray { + + func addObject(_ object: AnyObject?) { + guard let strongObject = object else { return } + + let pointer = Unmanaged.passUnretained(strongObject).toOpaque() + addPointer(pointer) + } + + func insertObject(_ object: AnyObject?, at index: Int) { + guard index < count, let strongObject = object else { return } + + let pointer = Unmanaged.passUnretained(strongObject).toOpaque() + insertPointer(pointer, at: index) + } + + func replaceObject(at index: Int, withObject object: AnyObject?) { + guard index < count, let strongObject = object else { return } + + let pointer = Unmanaged.passUnretained(strongObject).toOpaque() + replacePointer(at: index, withPointer: pointer) + } + + func object(at index: Int) -> AnyObject? { + guard index < count, let pointer = self.pointer(at: index) else { return nil } + return Unmanaged.fromOpaque(pointer).takeUnretainedValue() + } + + func removeObject(at index: Int) { + guard index < count else { return } + + removePointer(at: index) + } + + func popLast() -> AnyObject? { + guard count > 0 else { return nil } + + let object = object(at: count - 1) + removeObject(at: count - 1) + + return object + } + + func removeNils() { + addPointer(nil) + compact() + } + +} diff --git a/Sources/NewReactor/NewReactor.swift b/Sources/NewReactor/NewReactor.swift new file mode 100644 index 0000000..55e0bab --- /dev/null +++ b/Sources/NewReactor/NewReactor.swift @@ -0,0 +1,490 @@ +// +// NewReactor.swift +// GoodReactor +// +// Created by Filip Šašala on 23/08/2024. +// + +import AsyncAlgorithms +import Collections +import GoodLogger +import Observation + +#if canImport(Combine) +import Combine +#endif + +#if canImport(SwiftUI) +import SwiftUI +#endif + +// MARK: - Reactor protocol + +@MainActor @dynamicMemberLookup public protocol Reactor: AnyObject, Identifiable, Hashable { + + /// Internal events + /// + /// Used for actions invoked from an interaction with the UI. + /// See ``send(action:)-4t47e`` + /// + /// Example usage: + /// ```swift + /// enum Action { + /// case increment + /// case decrement + /// } + /// ``` + associatedtype Action: Sendable + + /// External events + /// + /// Used for mutations which are a response to an external event or a side effect, + /// such as response to a request or result of an asynchronous action. + /// + /// Example usage: + /// ```swift + /// enum Mutation { + /// case timerDidFinish + /// case didReceiveProfile(UserProfile) + /// } + /// ``` + associatedtype Mutation: Sendable + + #warning("TODO: documentation") + associatedtype Destination: Sendable + + var destination: Destination? { get set } + + /// State of the view + /// + /// ## In iOS 17+: + /// Mark the state as an `@Observable` final class (from Observation framework). + /// + /// ```swift + /// @Observable final class State { + /// var count: Int = 10 + ///} + ///``` + /// + /// ## In iOS 16 and earlier: + /// Mark the state as a `struct` and add `ObservableObject` conformance to + /// the entire Reactor model. + /// + /// ```swift + /// final class SampleViewModel: Reactor, ObservableObject { + /// // ... + /// struct State { + /// var count: Int = 10 + /// } + /// // ... + /// } + /// ``` + associatedtype State + + /// Logger used for logging reactor events + static var logger: GoodLogger { get } + + /// Initial state of the reactor + /// + /// This is a separate instance from ``state-1ufdb``, created by calling + /// ``makeInitialState()``. + /// + /// - note: Using `initialState` is discouraged and is provided only for + /// migration/backwards compatibility reasons. + /// + /// - warning: Take caution when using object references inside ``State``, + /// as they can point to an object that may get mutated after the creation of initialState. + var initialState: State { get } + + /// Constructor for this reactor's logger. Gets called only once during the lifetime + /// of a Reactor. + /// + /// Default logger is `OSLogLogger` in iOS 14 and newer, or + /// `PrintLogger` in older iOS versions. + /// + /// - Returns: Logger used for logging reactor events. See `GoodLogger` package + /// for more information. + static func makeLogger() -> GoodLogger + + /// Constructor for this reactor's initial state. + /// + /// This function may get called twice (once for default state, once + /// for ``initialState-9l2w6``). If you intend to use `initialState`, try to + /// make sure the returned state is a deep copy and has no references to shared + /// objects to prevent further mutability. + /// - Returns: Initial state of the reactor + func makeInitialState() -> State + + /// Creates subscriptions to external events, that supply mutations to this Reactor. + /// + /// Call ``subscribe(to:map:)`` inside this function to create subscriptions + /// to data ``Publisher``-s outside of this Reactor. + /// ```swift + /// func transform() { + /// subscribe { + /// await ExternalTimer.shared.timePublisher + /// } map: { + /// Mutation.didChangeTime(seconds: $0) + /// } + /// } + /// ``` + /// + /// This example will subscribe to an external ``Publisher`` that publishes + /// current UNIX time every second. + /// The result is mapped to a concrete mutation `didChangeTime` and + /// the mutation is later processed in `reduce` function to change the state + /// of the reactor. + /// + /// You can use multiple subscriptions to multiple external events. + func transform() + + #if canImport(Combine) + func transform(event: AnyPublisher) -> AnyPublisher + #endif + + /// Reducer for this reactor. Reducer takes the current state and event (action or + /// mutation), mutates the state and decides on the next steps. This is the main + /// "logic" block of the Reactor. + /// + /// Switch over the `event.kind` to see the exact event received. You should + /// handle all possible events that this reactor may receive. + /// + /// To chain another action to this event, call ``run(_:_:)`` and supply + /// the event that required an action, as well as the action closure itself. + /// + /// ```swift + /// func reduce(state: inout State, event: Event) { + /// switch event.kind { + /// case .action(.increment): + /// state.counter += 1 + /// case .action(.decrement): + /// state.counter -= 1 + /// case .mutation(.timerDidFinish): + /// state.counter = 0 + /// case .mutation(.didReceiveProfile(let userProfile): + /// state.counter = userProfile.counter + /// run(event) { await fetchProfilePhoto() } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: State of the Reactor at the time when an event was registered. + /// - event: Wrapper containing the action or mutation received. See ``Event``. + func reduce(state: inout State, event: Event) + +} + +// MARK: - Dynamic member lookup + +public extension Reactor { + + #if canImport(SwiftUI) + func bind(_ member: KeyPath, action: @escaping (T) -> Action) -> Binding { + Binding(get: { + self.state[keyPath: member] + }, set: { newValue in + self.send(action: action(newValue)) + }) + } + #endif + + subscript(dynamicMember dynamicMember: KeyPath) -> T { + return state[keyPath: dynamicMember] + } + + subscript(dynamicMember dynamicMember: ReferenceWritableKeyPath) -> T { + return state[keyPath: dynamicMember] + } + +} + +// MARK: - Default implementation + +public extension Reactor { + + typealias Event = NewReactor.Event + + static var logger: GoodLogger { + MapTables.loggers.forceCastedValue(forKey: self, default: makeLogger()) + } + + var state: State { + get { + MapTables.state.forceCastedValue(forKey: self, default: makeInitialState()) + } + set { + MapTables.state.setValue(newValue, forKey: self) + } + } + + var initialState: State { + MapTables.initialState.forceCastedValue(forKey: self, default: makeInitialState()) + } + + static func makeLogger() -> GoodLogger { + if #available(iOS 14, *) { + OSLogLogger() + } else { + PrintLogger() + } + } + + func transform() {} + + #if canImport(Combine) + func transform(event: AnyPublisher) -> AnyPublisher { + return event + } + #endif + +} + +// MARK: - Public + +public extension Reactor { + + /// Send an action to this Reactor. The Reactor will decide how to modify + /// the ``State`` according to the implementation of ``reduce(state:event:)`` + /// function. + /// + /// - Parameter action: Action to process. See ``Action``. + /// + /// This is a "fire and forget" style of sending the action, as the action + /// will be executed asynchronously in the background. There is no way + /// to cancel the action after sending it. + func send(action: Action) { + let event = Event(kind: .action(action)) + _send(event: event) + } + + /// Send an action to this Reactor. The Reactor will decide how to modify + /// the ``State`` according to the implementation of ``reduce(state:event:)`` + /// function. + /// + /// - Parameter action: Action to process. See ``Action``. + /// + /// This is an asynchronous function that will resume once the action + /// has completed all its side effects. You can cancel the action by cancelling the + /// Task that has sent this action. Cancelling the task will not undo the changes + /// to state that were already finished. + /// + /// - important: Depending on the action executed, processing an event may + /// take a long time. + func send(action: Action) async { + let event = Event(kind: .action(action)) + await _sendAsync(event: event) + } + + #warning("TODO: add documentation") + func send(destination: Destination?) { + self.destination = destination + } + + /// Starts the Reactor - the Reactor starts listening to external events + /// by calling the ``transform()-7ttgl`` function. If the Reactor + /// uses Combine, subscribes the event stream and publishes the + /// initial state. + func start() { + self.transform() + + #if canImport(Combine) + self.makeCombineEventStream() + #endif + } + + /// Asynchronously runs a handler associated with an event. If handler returns a mutation, + /// handler waits until the mutation is reduced before returning. + /// + /// - Parameters: + /// - event: Event responsible for starting the asynchronous task. + /// - eventHandler: Asynchronous task to start, eg. a network request or database fetch. + /// This function should not have any side effects on this Reactor's state. Returns ``Mutation`` + /// or `nil`, depending on whether any further action is neccessary. + /// + /// This function doesn't block. + /// + /// - important: Start async events only from ``reduce(state:event:)`` to ensure correct behaviour. + func run(_ event: Event, _ eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) { + let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)] + MapTables.runningEvents[key: self, default: []].insert(event.id) + + Task { @MainActor [weak self] in + guard let self else { return } + + defer { + MapTables.runningEvents[key: self, default: []].remove(event.id) + semaphore.signal() + } + + let mutation = await Task.detached(operation: eventHandler()).value + + guard !Task.isCancelled else { + _debugLog(message: "Task cancelled") + return + } + + if let mutation { + let mutationEvent = Event(kind: .mutation(mutation)) + await _sendAsync(event: mutationEvent) + } + } + } + + /// <#Description#> + /// - Parameters: + /// - publisherProvider: <#publisherProvider description#> + /// - mapper: <#mapper description#> + func subscribe( + to publisherProvider: @escaping @autoclosure () -> @Sendable () async -> Publisher, + map mapper: @escaping @autoclosure () -> @Sendable (Value) async -> (Mutation) + ) { + let publisher = publisherProvider() + let subscription = Task { [weak self] in + let newSubscriber = Subscriber() + await newSubscriber.subscribe(to: publisher()) + + let map = mapper() + for await value in newSubscriber { + let mutation = await map(value) + let event = Event(kind: .mutation(mutation)) + + guard let self else { return } + _send(event: event) + } + + Self._debugLog(message: "Subscription finished") + } + + subscription.store(in: &MapTables.subscriptions[key: self, default: []]) + } + +} + +// MARK: - Private + +private extension Reactor { + + private static var name: String { + String(describing: Self.self) + } + + private func _send(event: Event) { + _reduce(state: &state, event: event) + } + + private func _sendAsync(event: Event) async { + let eventId = event.id + let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)] + + _reduce(state: &state, event: event) + + if MapTables.runningEvents[key: self, default: []].contains(eventId) { + try? await semaphore.waitUnlessCancelled() + } + } + + private func _reduce(state: inout State, event: Event) { + if let self = self as? any ObservableObject, + let objectWillChange = self.objectWillChange as? ObservableObjectPublisher { + objectWillChange.send() + } + + reduce(state: &state, event: event) + + #if canImport(Combine) + _stateStream.send(state) + #endif + } + + private func _debugLog(message: String) { + Self._debugLog(message: message) + } + + private static func _debugLog(message: String) { + logger.log(level: .debug, message: "[GoodReactor] \(Self.name) - \(message)", privacy: .auto) + } + +} + +// MARK: - Combine + +#if canImport(Combine) +import Combine + +public extension Reactor { + + private var _stateStream: Combine.PassthroughSubject { + MapTables.stateStreams.forceCastedValue(forKey: self, default: PassthroughSubject()) + } + + var stateStream: Combine.AnyPublisher { + _stateStream.eraseToAnyPublisher() + } + + var eventStream: Combine.PassthroughSubject { + MapTables.eventStreams.forceCastedValue(forKey: self, default: PassthroughSubject()) + } + + private func makeCombineEventStream() { + let eventStream = self.eventStream + let transformedEventStream = transform(event: eventStream.eraseToAnyPublisher()) + + let eventSubscriber = Subscriber() + transformedEventStream.subscribe(eventSubscriber) + + let subscription = Task { [weak self] in + for await eventKind in eventSubscriber { + let event = Event(kind: eventKind) + + guard let self else { return } + _send(event: event) + } + } + + subscription.store(in: &MapTables.subscriptions[key: self, default: []]) + + _stateStream.send(initialState) + } + +} +#endif + +// MARK: - Hashable & Identifiable + +public extension Reactor { + + nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs === rhs + } + +} + +// MARK: - CustomStringConvertible + +//public extension Reactor { +// +// nonisolated var description: String { +// String(describing: Self.self) +// } +// +//} + +// MARK: - Migration + +public extension Reactor { + + @available(*, deprecated, message: "Call members directly from Reactor instead of using `currentState` property.") + var currentState: State { + state + } + +} diff --git a/Sources/NewReactor/Publisher.swift b/Sources/NewReactor/Publisher.swift new file mode 100644 index 0000000..89001ad --- /dev/null +++ b/Sources/NewReactor/Publisher.swift @@ -0,0 +1,62 @@ +// +// Publisher.swift +// GoodReactor +// +// Created by Filip Šašala on 28/08/2024. +// + +import Foundation + +public actor Publisher { + + // MARK: - Variables + + internal var subscribers = NSPointerArray.weakObjects() + + // MARK: - Initialization + + public init() {} + +} + +// MARK: - Public + +public extension Publisher { + + func send(_ value: Value) { + eachSubscriber { await $0.receive(value: value) } + } + + func finish() { + eachSubscriber { await $0.finish() } + } + +} + +// MARK: - Internal + +internal extension Publisher { + + func connect(to subscriber: Subscriber) { + subscribers.addObject(subscriber) + } + +} + +// MARK: - Private + +private extension Publisher { + + func eachSubscriber(_ action: @autoclosure @escaping () -> (Subscriber) async -> ()) { + guard subscribers.count > 0 else { return } + + subscribers.removeNils() + + for index in 0.. else { continue } + let action = action() + Task { await action(subscriber) } + } + } + +} diff --git a/Sources/NewReactor/Subscriber.swift b/Sources/NewReactor/Subscriber.swift new file mode 100644 index 0000000..1cb0efd --- /dev/null +++ b/Sources/NewReactor/Subscriber.swift @@ -0,0 +1,97 @@ +// +// Subscriber.swift +// GoodReactor +// +// Created by Filip Šašala on 28/08/2024. +// + +import Foundation +import Collections + +public actor Subscriber: AsyncSequence, AsyncIteratorProtocol { + + // MARK: - Typealiases + + public typealias AsyncIterator = Subscriber + public typealias Element = Value + + // MARK: - Variables + + private let semaphore = AsyncSemaphore(value: 0) + + private var valueQueue = Deque() + private var publisher: Publisher? + + // MARK: - Initialization + + public init() {} + + // MARK: - Iterator + + nonisolated public func makeAsyncIterator() -> Subscriber { + self + } + + public func next() async -> Value? { + do { + try await semaphore.waitUnlessCancelled() + } catch { + return nil + } + + return valueQueue.popFirst() + } + +} + +// MARK: - Public + +public extension Subscriber { + + func subscribe(to publisher: Publisher) async { + await publisher.connect(to: self) + } + +} + +// MARK: - Internal + +internal extension Subscriber { + + func receive(value: Value) { + valueQueue.append(value) + semaphore.signal() + } + + func finish() { + semaphore.signal() + } + +} + +// MARK: - Combine interoperability + +#if canImport(Combine) +import Combine + +extension Subscriber: Combine.Subscriber { + + public typealias Input = Value + public typealias Failure = Never + + nonisolated public func receive(subscription: any Subscription) { + subscription.request(.unlimited) + } + + nonisolated public func receive(_ input: Value) -> Subscribers.Demand { + Task { await self.receive(value: input) } + return .unlimited + } + + nonisolated public func receive(completion: Subscribers.Completion) { + Task { await self.finish() } + } + +} + +#endif diff --git a/Sources/NewReactor/ViewModel.swift b/Sources/NewReactor/ViewModel.swift new file mode 100644 index 0000000..80b4f02 --- /dev/null +++ b/Sources/NewReactor/ViewModel.swift @@ -0,0 +1,32 @@ +// +// ViewModel.swift +// GoodReactor +// +// Created by Filip Šašala on 30/08/2024. +// + +#if canImport(SwiftUI) +import SwiftUI + +//import Observation +@available(iOS 17.0, *) +@available(macOS 14, *) +public typealias ViewModel = State + +@available(iOS, obsoleted: 17.0, message: "Migrate to ViewModel and Observation framework in iOS 17 and newer.") +@available(macOS, unavailable) +@MainActor @propertyWrapper public struct LegacyViewModel: DynamicProperty { + + @ObservedObject private var model: Model + + public var wrappedValue: Model { + model + } + + public init(wrappedValue: Model) { + self._model = ObservedObject(initialValue: wrappedValue) + wrappedValue.start() + } + +} +#endif diff --git a/Sources/NewReactor/WeakMapTable.swift b/Sources/NewReactor/WeakMapTable.swift new file mode 100644 index 0000000..dcb7b56 --- /dev/null +++ b/Sources/NewReactor/WeakMapTable.swift @@ -0,0 +1,213 @@ +// +// WeakMapTable.swift +// +// Created by Dominik Pethö on 9/3/20. +// Copyright © 2020 GoodRequest. All rights reserved. +// + +import Foundation + +// MARK: - WeakMapTable +/// https://github.com/ReactorKit/WeakMapTable + +/// The WeakMapTable class is a dictionary that uses weak references as its keys. It provides a way to associate values with keys, where the keys are objects. +/// The values are stored as long as the key objects are alive, and they are automatically removed when the key objects are deallocated. +final public class WeakMapTable: @unchecked Sendable where Key: AnyObject { + + private var dictionary: [Weak: Value] = [:] + private let lock = NSRecursiveLock() + + // MARK: Initializing + + /// Creates a new instance of WeakMapTable + public init() {} + + // MARK: Getting and Setting Values + + /// Returns the value associated with the given key. + /// This method locks the underlying dictionary to ensure thread safety, + /// and installs a dealloc hook for the given key if it does not exist yet. + /// + /// - Parameter key: The key for which to return the associated value. + /// - Returns: The value associated with the given key, or `nil` if no value is associated with the key. + public func value(forKey key: Key) -> Value? { + let weakKey = Weak(key) + + self.lock.lock() + defer { + self.lock.unlock() + self.installDeallocHook(to: key) + } + + return self.unsafeValue(forKey: weakKey) + } + + /// Retrieves the value associated with the given key. If the key is not present, returns the default value passed in. + /// - Parameters: + /// - key: The key for which to retrieve the associated value. + /// - default: A closure that provides a default value if the key is not present. + /// - Returns: The value associated with the given key or the default value if the key is not present. + public func value(forKey key: Key, default: @autoclosure () -> Value) -> Value { + let weakKey = Weak(key) + + self.lock.lock() + defer { + self.lock.unlock() + self.installDeallocHook(to: key) + } + + if let value = self.unsafeValue(forKey: weakKey) { + return value + } + + let defaultValue = `default`() + self.unsafeSetValue(defaultValue, forKey: weakKey) + return defaultValue + } + + // swiftlint:disable force_cast + + /// Retrieves the value associated with the given key, cast to the desired type T. + /// - Parameters: + /// - key: The key whose associated value is to be retrieved + /// - default: A closure that returns the default value to be returned if the key is not found. + /// - Returns: The value associated with key, cast to type T, or the default value if the key is not found. + public func forceCastedValue(forKey key: Key, default: @autoclosure () -> T) -> T { + return self.value(forKey: key, default: `default`() as! Value) as! T + } + + /// This method is used to set the value for a given key in the dictionary. + /// - Parameters: + /// - value: The value to be stored. + /// - key: The key associated with the value. + public func setValue(_ value: Value?, forKey key: Key) { + let weakKey = Weak(key) + + self.lock.lock() + defer { + self.lock.unlock() + if value != nil { + self.installDeallocHook(to: key) + } + } + + if let value = value { + self.dictionary[weakKey] = value + } else { + self.dictionary.removeValue(forKey: weakKey) + } + } + + // MARK: Getting and Setting Values without Locking + + /// Returns the value stored in the dictionary for the given key. + /// This function is called unsafe because it does not provide thread-safety. + /// - Parameter key: The key of the value stored in the dictonary + /// - Returns: The value stored in dictonary + private func unsafeValue(forKey key: Weak) -> Value? { + return self.dictionary[key] + } + + /// This function sets the value for the given key key in the dictionary. If value is non-nil, it is added to the dictionary, otherwise the value is removed from the dictionary. + /// This function is called unsafe because it does not provide thread-safety. + /// - Parameters: + /// - value: The value to be set. + /// - key: The key associated with the value. + private func unsafeSetValue(_ value: Value?, forKey key: Weak) { + if let value = value { + self.dictionary[key] = value + } else { + self.dictionary.removeValue(forKey: key) + } + } + + // MARK: Dealloc Hook + + private var deallocHookKey: Void? + + /// Adds a hook to an object's deallocation, so that the hook can clean up any resources associated with that object. + /// - Parameter key: The key of the object to install the hook to. + private func installDeallocHook(to key: Key) { + let isInstalled = (objc_getAssociatedObject(key, &deallocHookKey) != nil) + guard !isInstalled else { return } + + let weakKey = Weak(key) + let hook = DeallocHook(handler: { [weak self] in + self?.lock.lock() + self?.dictionary.removeValue(forKey: weakKey) + self?.lock.unlock() + }) + objc_setAssociatedObject(key, &deallocHookKey, hook, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } +} + +/// This class is used in the WeakMapTable class as a key in a dictionary to store values, where the keys are weak references to objects of type T. +/// This allows the values in the dictionary to be automatically removed when the objects they are associated with are deallocated. +public final class Weak: Hashable where T: AnyObject { + + ///A hash value that is derived from the object's ObjectIdentifier. + private let objectHashValue: Int + /// A weak reference to an object of type T. + public weak var object: T? + + /// Initialization of class Weak + /// - Parameter object: the object to be stored as a weak reference + public init(_ object: T) { + self.objectHashValue = ObjectIdentifier(object).hashValue + self.object = object + } + + /// Combines the objectHashValue into the Hasher instance provided as an argument. + /// - Parameter hasher: Hasher instance to hash + public func hash(into hasher: inout Hasher) { + hasher.combine(self.objectHashValue) + } + + public static func == (lhs: Weak, rhs: Weak) -> Bool { + return lhs.objectHashValue == rhs.objectHashValue + } +} + +// MARK: - DeallocHook + +/// DeallocHook is a private class that allows you to run a closure when its object is deallocated. It is used in WeakMapTable to handle deallocation of the keys in the table. +public final class DeallocHook { + + /// A closure that will be called when the object is deallocated. + private let handler: () -> Void + + private var deallocHookKey: Void? + + public init(on object: AnyObject, handler: @escaping () -> Void) { + self.handler = handler + + let isInstalled = (objc_getAssociatedObject(object, &deallocHookKey) != nil) + guard !isInstalled else { return } + objc_setAssociatedObject(object, &deallocHookKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + ///DeallocHook is initialized with a closure that is to be called when the object is deallocated. The closure is stored in the handler property. + public init(handler: @escaping () -> Void) { + self.handler = handler + } + + /// Call the handler closure when DeallocHook is deallocated + deinit { + self.handler() + } +} + +// MARK: - Subscripts + +public extension WeakMapTable { + + subscript(key key: Key, default default: @autoclosure () -> Value) -> Value { + get { + forceCastedValue(forKey: key, default: `default`()) + } + set { + setValue(newValue, forKey: key) + } + } + +} diff --git a/Tests/GoodReactorTests/GoodReactorTests.swift b/Tests/GoodReactorTests/GoodReactorTests.swift index 0e61b42..6897868 100644 --- a/Tests/GoodReactorTests/GoodReactorTests.swift +++ b/Tests/GoodReactorTests/GoodReactorTests.swift @@ -1,6 +1,5 @@ import XCTest import GoodReactor -import UIKit final class GoodCoordinatorTests: XCTestCase { diff --git a/Tests/NewReactorTests/NewReactorTests.swift b/Tests/NewReactorTests/NewReactorTests.swift new file mode 100644 index 0000000..753e234 --- /dev/null +++ b/Tests/NewReactorTests/NewReactorTests.swift @@ -0,0 +1,68 @@ +// +// File.swift +// GoodReactor +// +// Created by Filip Šašala on 23/08/2024. +// + +import XCTest +@testable import NewReactor +import Combine + +@available(iOS 17.0, macOS 14.0, *) +final class NewReactorTests: XCTestCase { + + @MainActor func testSendAction() { + let model = ObservableModel() + + model.send(action: .addOne) + + XCTAssertEqual(model.initialState.counter, 9, "Initial state mutated") + XCTAssertEqual(model.state.counter, 10, "Sending action failed") + } + + @MainActor func testInitialState() { + let model = ObservableModel() + + XCTAssertEqual(model.initialState.counter, 9, "Invalid initial state") + XCTAssertEqual(model.state.counter, 9, "Invalid initial state") + + XCTAssertNotIdentical(model.state, model.initialState, "Initial state is NOT A COPY but a reference!") + XCTAssertNotIdentical(model.state.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") + } + + @MainActor func testActionMutation() async throws { + let model = ObservableModel() + + XCTAssertEqual(model.state.counter, 9) + + let expectation = XCTestExpectation() + Task { + await model.send(action: .resetToZero) + XCTAssertEqual(model.state.counter, 0, "Reset to zero failed") + expectation.fulfill() + } + + XCTAssertEqual(model.state.counter, 9, "State mutated immediately") + + await fulfillment(of: [expectation], timeout: 3) + + XCTAssertEqual(model.state.counter, 0, "State did not mutate properly") + } + + @MainActor func testLegacyModel() { + let model = LegacyModel() + + model.send(action: .addOne) + + let expectation = XCTestExpectation(description: "Change notification was sent") + let cancellable = model.objectWillChange.sink { + expectation.fulfill() + } + + XCTAssertEqual(model.state.counter, 10) + wait(for: [expectation], timeout: 3) + withExtendedLifetime(cancellable, {}) + } + +} diff --git a/Tests/NewReactorTests/PublisherTests.swift b/Tests/NewReactorTests/PublisherTests.swift new file mode 100644 index 0000000..538cbe3 --- /dev/null +++ b/Tests/NewReactorTests/PublisherTests.swift @@ -0,0 +1,108 @@ +// +// PublisherTests.swift +// GoodReactor +// +// Created by Filip Šašala on 28/08/2024. +// + +import XCTest +@testable import NewReactor + +final class PublisherTests: XCTestCase { + + func testSubscriptions() async { + let publisher = Publisher() + let subscriber = Subscriber() + + await subscriber.subscribe(to: publisher) + + let subscribers = await publisher.subscribers + let x = subscribers.object(at: 0) === subscriber + XCTAssert(x) + } + + func testSendValue() async { + let publisher = Publisher() + let subscriber = Subscriber() + + await subscriber.subscribe(to: publisher) + + let valueReceived = XCTestExpectation(description: "Value received - 10") + Task { + let element = await subscriber.next() + if element == 10 { + valueReceived.fulfill() + } else { + XCTFail("Did not receive expected value") + } + } + + await publisher.send(10) + await fulfillment(of: [valueReceived], timeout: 3) + } + + func testSequence() async { + let publisher = Publisher() + let subscriber = Subscriber() + + await subscriber.subscribe(to: publisher) + + Task { + for i in stride(from: 0, to: 100, by: 2) { + await publisher.send(i) + } + } + Task { + for i in stride(from: 1, to: 100, by: 2) { + await publisher.send(i) + } + } + + let expectation = XCTestExpectation(description: "All sent values received in order") + expectation.assertForOverFulfill = true + expectation.expectedFulfillmentCount = 100 + + Task { + for try await _ in subscriber { + expectation.fulfill() + } + } + + await fulfillment(of: [expectation], timeout: 3) + } + + func testMultipleSubscribers() async throws { + let publisher = Publisher() + let subscriber1 = Subscriber() + let subscriber2 = Subscriber() + + await subscriber1.subscribe(to: publisher) + await subscriber2.subscribe(to: publisher) + + Task { + for i in 0..<10 { + await publisher.send(i) + } + await publisher.finish() + } + + let expectation = XCTestExpectation(description: "Both subscribers received the same max value") + expectation.expectedFulfillmentCount = 2 + Task { + let max1 = await subscriber1.max() + XCTAssertEqual(max1, 9) + expectation.fulfill() + } + Task { + let max2 = await subscriber2.max() + XCTAssertEqual(max2, 9) + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 3) + } + +} + +// Only for testing purposes +extension NSPointerArray: @retroactive @unchecked Sendable {} diff --git a/Tests/NewReactorTests/Samples/ExternalTimer.swift b/Tests/NewReactorTests/Samples/ExternalTimer.swift new file mode 100644 index 0000000..548dafe --- /dev/null +++ b/Tests/NewReactorTests/Samples/ExternalTimer.swift @@ -0,0 +1,28 @@ +// +// ExternalTimer.swift +// GoodReactor +// +// Created by Filip Šašala on 30/08/2024. +// + +import Foundation +import NewReactor + +// MARK: - Example - external dependency + +/// Sample timer that publishes current time for 100 seconds +final class ExternalTimer: @unchecked Sendable { + + @MainActor static let shared = ExternalTimer() + let timePublisher = NewReactor.Publisher() + + init() { + Task { + for _ in 0..<100 { + try await Task.sleep(nanoseconds: UInt64(1e9)) + await self.timePublisher.send(Int(Date().timeIntervalSince1970)) + } + } + } + +} diff --git a/Tests/NewReactorTests/Samples/LegacyModel.swift b/Tests/NewReactorTests/Samples/LegacyModel.swift new file mode 100644 index 0000000..9b9d385 --- /dev/null +++ b/Tests/NewReactorTests/Samples/LegacyModel.swift @@ -0,0 +1,111 @@ +// +// LegacyModel.swift +// GoodReactor +// +// Created by Filip Šašala on 30/08/2024. +// + +import NewReactor +import SwiftUI + +// MARK: - Example - Counter model using ObservableObject API + +@available(iOS 17.0, *) +final class LegacyModel: Reactor, ObservableObject { + + func transform() { + subscribe { + await ExternalTimer.shared.timePublisher + } map: { + Mutation.didChangeTime(seconds: $0) + } + } + + typealias Event = NewReactor.Event + + // MARK: Enums + + enum Action { + case addOne + case subtractOne + case resetToZero + case cascade + } + + enum Mutation { + case didChangeTime(seconds: Int) + case didAddOne + case didReceiveValue(newValue: Int) + } + + // MARK: State + + struct State: Sendable { + + var counter: Int = 9 + var time: Int = 0 + + } + + // MARK: Initialization + + func makeInitialState() -> State { + State() + } + + // MARK: Reactive + + func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.addOne): + state.counter += 1 + + case .action(.subtractOne): + state.counter -= 1 + + case .action(.resetToZero): + run(event) { await self.asyncResetToZero() } + + case .action(.cascade): + let oldValue = state.counter + run(event) { await self.asyncAddOne(oldValue: oldValue) } + + case .mutation(.didAddOne): + state.counter += 1 + + let counterValue = state.counter + if counterValue < 10 { + run(event) { await self.asyncAddOne(oldValue: counterValue) } + } + + case .mutation(.didReceiveValue(let newValue)): + state.counter = newValue + + case .mutation(.didChangeTime(let seconds)): + state.time = seconds + } + } + + // MARK: Async/side effects + + func asyncAddOne(oldValue: Int) async -> Mutation? { + try? await Task.sleep(nanoseconds: UInt64(33e7)) + + if oldValue < 10 { + return .didAddOne + } else { + return .none + } + } + + func asyncResetToZero() async -> Mutation { + try? await Task.sleep(nanoseconds: UInt64(1e9)) + return .didReceiveValue(newValue: 0) + } + + func asyncResetToTen() async -> Mutation { + try? await Task.sleep(nanoseconds: UInt64(1e9)) + return .didReceiveValue(newValue: 10) + } + +} diff --git a/Tests/NewReactorTests/Samples/ObservableModel.swift b/Tests/NewReactorTests/Samples/ObservableModel.swift new file mode 100644 index 0000000..93fdc75 --- /dev/null +++ b/Tests/NewReactorTests/Samples/ObservableModel.swift @@ -0,0 +1,115 @@ +// +// ObservableModel.swift +// GoodReactor +// +// Created by Filip Šašala on 30/08/2024. +// + +import NewReactor +import Observation + +final class EmptyObject {} + +// MARK: - Example - Counter model using Observation framework + +@available(iOS 17.0, *) +final class ObservableModel: Reactor { + + func transform() { + subscribe { + await ExternalTimer.shared.timePublisher + } map: { + Mutation.didChangeTime(seconds: $0) + } + } + + typealias Event = NewReactor.Event + + // MARK: Enums + + enum Action { + case addOne + case subtractOne + case resetToZero + case cascade + } + + enum Mutation { + case didChangeTime(seconds: Int) + case didAddOne + case didReceiveValue(newValue: Int) + } + + // MARK: State + + @Observable final class State { + + var counter: Int = 9 + var time: Int = 0 + var object: AnyObject = EmptyObject() + + } + + // MARK: Initialization + + func makeInitialState() -> State { + State() + } + + // MARK: Reactive + + func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.addOne): + state.counter += 1 + + case .action(.subtractOne): + state.counter -= 1 + + case .action(.resetToZero): + run(event) { await self.asyncResetToZero() } + + case .action(.cascade): + let oldValue = state.counter + run(event) { await self.asyncAddOne(oldValue: oldValue) } + + case .mutation(.didAddOne): + state.counter += 1 + + let counterValue = state.counter + if counterValue < 10 { + run(event) { await self.asyncAddOne(oldValue: counterValue) } + } + + case .mutation(.didReceiveValue(let newValue)): + state.counter = newValue + + case .mutation(.didChangeTime(let seconds)): + state.time = seconds + } + } + + // MARK: Async/side effects + + func asyncAddOne(oldValue: Int) async -> Mutation? { + try? await Task.sleep(nanoseconds: UInt64(33e7)) + + if oldValue < 10 { + return .didAddOne + } else { + return .none + } + } + + func asyncResetToZero() async -> Mutation { + try? await Task.sleep(nanoseconds: UInt64(1e9)) + return .didReceiveValue(newValue: 0) + } + + func asyncResetToTen() async -> Mutation { + try? await Task.sleep(nanoseconds: UInt64(1e9)) + return .didReceiveValue(newValue: 10) + } + +} + From 92722ad443fe7c81cbd56e6d1b3f94af82418eb0 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Fri, 27 Sep 2024 16:29:27 +0200 Subject: [PATCH 3/4] Update readme.md --- .../Screens/Home/HomeViewController.swift | 13 +- README.md | 156 +++++++++++------- 2 files changed, 100 insertions(+), 69 deletions(-) diff --git a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift index b2cab01..01d1262 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift @@ -112,7 +112,7 @@ private extension HomeViewController { private extension HomeViewController { func bindState(reactor: HomeViewModel) { - reactor.stateStream + reactor.state .map { String($0.counterValue) } .removeDuplicates() .assign(to: \.text, on: counterValueLabel, ownership: .weak) @@ -124,16 +124,7 @@ private extension HomeViewController { increasingButton.publisher(for: .touchUpInside).map { _ in .updateCounterValue(.increase) }, decreasingButton.publisher(for: .touchUpInside).map { _ in .updateCounterValue(.decrease) } ) - .map { .action($0) } - .subscribe(reactor.eventStream) - .store(in: &cancellables) - - Publishers.Merge( - aboutAppButton.publisher(for: .touchUpInside).map { _ in .about }, - swiftUIButton.publisher(for: .touchUpInside).map { _ in .swiftUISample } - ) - .map { .destination($0) } - .subscribe(reactor.eventStream) + .subscribe(reactor.action) .store(in: &cancellables) } diff --git a/README.md b/README.md index 2da501b..ba54171 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,12 @@ Create a `Package.swift` file and add the package dependency into the dependenci Or to integrate without package.swift add it through the Xcode add package interface. ```swift - import PackageDescription let package = Package( name: "SampleProject", dependencies: [ - .Package(url: "https://github.com/GoodRequest/GoodReactor" from: "addVersion") + .package(url: "https://github.com/GoodRequest/GoodReactor" .upToNextMajor("2.0.0")) ] ) @@ -41,102 +40,143 @@ let package = Package( ## GoodReactor ### ViewModel -In your ViewModel define State, Actions and Mutations +In your ViewModel define Actions, Mutations, Destinations and the State -- State defines all data that you work with -- Action user actions that are sent from the ViewController. -- Mutation represents state changes. +- State defines all data of a View (or a ViewController) +- Action represents user actions that are sent from the View. +- Mutation represents state changes from external sources. +- Destination represents all possible destinations, where user can navigate. ```swift - struct State { +@Observable final class ViewModel: Reactor { + enum Action { + case login(username: String, password: String) + } - var counterValue: Int + enum Mutation { + case didReceiveAuthResponse(Credentials) + } + enum Destination { + case homeScreen + case errorAlert } - enum Action { + @Observable final class State { + var username: String + var password: String + } +} +``` - case updateCounterValue(CounterMode) - case goToAbout +You can provide the initial state of the view in the `makeInitialState` function. - } +```swift +func makeInitialState() -> State { + return State() +} +``` - enum Mutation { +Finally in the `reduce` function you define how `state` changes, according to certain `event`s: - case counterValueUpdated(Int) +```swift +typealias Event = NewReactor.Event +func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.login(...)): + // ... + + case .mutation: + // ... + + case .destination: + // ... } +} ``` -In the `mutate` function define what will happen when certain actions are called: +You can run asynchronous tasks by using `run` and returning the result in form of a `Mutation`. ```swift -func mutate(action: Action) -> AnyPublisher { - switch action { - case .updateCounterValue(let mode): - return updateCounter(mode: mode) +func reduce(state: inout State, event: Event) { + switch event.kind { + case .action(.login(let username, let password)): + run(event) { + let credentials = await networking.login(username, password) + return Mutation.didReceiveAuthResponse(credentials) } -} - -func updateCounter(mode: CounterMode) -> AnyPublisher { - var actualValue = currentState.counterValue - switch mode { - case .increase: - actualValue += 1 + // ... - case .decrease: - actualValue -= 1 + case .mutation(.didReceiveAuthResponse(let credentials)): + // proceed with login } +} +``` + +You can listen to external changes by `subscribe`-ing to event `Publisher`-s. +You start the subscriptions by calling the `start()` function. - return Just(.counterValueUpdated(actualValue)).eraseToAnyPublisher() +```swift +// in ViewModel: +func transform() { + subscribe { + await ExternalTimer.shared.timePublisher + } map: { + Mutation.didChangeTime(seconds: $0) + } } +// in View (SwiftUI): +var body: some View { + MyContentView() + .task { viewModel.start() } +} ``` -Finally in the `reduce` function define `state` changes according to certain `mutation`: +### View (SwiftUI) + +You add the ViewModel as a property wrapper to your view: + ```swift - func reduce(state: State, mutation: Mutation) -> State { - var state = state +@ViewModel private var model = MyViewModel() +``` - switch mutation { - case .counterValueUpdated(let newValue): - state.counterValue = newValue - } +To access the current `State` you use: - return state - } +```swift +// read-only access +Text(model.username) + +// binding (refactored to a variable for better readability) +let binding = model.bind(\.username, action: { .setUsername($0) }) +TextField("Username", text: binding) ``` -### ViewController +To send an event to the ViewModel you call: -From `ViewController` you can send actions to `ViewModel` via Combine just like in our `GoodReactor-Sample` or like this: ```swift -viewModel.send(event: yourAction) +model.send(action: .login(username, password)) +model.send(destination: .errorAlert) ``` -Then use combine to subscribe to state changes, so every time the state is changed, ViewController is updated as well: +### UIViewController (UIKit/Combine) + +From `UIViewController` (in UIKit, or any other frameworks) you can send actions to ViewModel via Combine: ```swift -viewModel.state - .map { String($0.counterValue) } - .removeDuplicates() - .assign(to: \.text, on: counterValueLabel, ownership: .weak) +myButton.publisher(for: .touchUpInside).map { _ in .login(username, password) } + .map { .action($0) } + .subscribe(model.eventStream) .store(in: &cancellables) - ``` -## GoodCoordinator -When viewModel's action is called, navigation function is called as well. There you can hande the app flow, for example: +Then use Combine to subscribe to state changes, so every time the state is changed, ViewController can be updated as well: ```swift -func navigate(action: Action) -> AppStep? { - switch action { - case .goToAbout: - return .home(.goToAbout) - - default: - return .none - } -} +reactor.stateStream + .map { String($0.username) } + .assign(to: \.text, on: usernameLabel, ownership: .weak) + .store(in: &cancellables) ``` # License From d88c3f2340817ed4e0175f73ed899ded3dc93a38 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Fri, 27 Sep 2024 16:47:58 +0200 Subject: [PATCH 4/4] task: Fix samples to pass tests --- .../xcschemes/GoodReactor-Package.xcscheme | 14 +++ Package.swift | 21 ++-- .../{NewReactor => GoodReactor}/AnyTask.swift | 0 .../AsyncSemaphore.swift | 0 .../{NewReactor => GoodReactor}/Event.swift | 0 .../Identifier.swift | 0 .../MapTables.swift | 0 .../NSPointerArrayExtension.swift | 0 .../Publisher.swift | 0 .../Reactor.swift} | 52 +++++++--- .../Subscriber.swift | 0 .../ViewModel.swift | 0 Sources/GoodReactor/WeakMapTable.swift | 39 ++++++-- .../GoodReactor.swift | 0 .../WeakMapTable.swift | 39 ++------ Sources/NewReactor/Coordinator.swift | 21 ---- Tests/GoodReactorTests/GoodReactorTests.swift | 99 ++++++++++--------- .../PublisherTests.swift | 2 +- .../Samples/ExternalTimer.swift | 4 +- .../Samples/LegacyModel.swift | 16 ++- .../Samples/ObservableModel.swift | 12 ++- Tests/NewReactorTests/NewReactorTests.swift | 68 ------------- 22 files changed, 176 insertions(+), 211 deletions(-) rename Sources/{NewReactor => GoodReactor}/AnyTask.swift (100%) rename Sources/{NewReactor => GoodReactor}/AsyncSemaphore.swift (100%) rename Sources/{NewReactor => GoodReactor}/Event.swift (100%) rename Sources/{NewReactor => GoodReactor}/Identifier.swift (100%) rename Sources/{NewReactor => GoodReactor}/MapTables.swift (100%) rename Sources/{NewReactor => GoodReactor}/NSPointerArrayExtension.swift (100%) rename Sources/{NewReactor => GoodReactor}/Publisher.swift (100%) rename Sources/{NewReactor/NewReactor.swift => GoodReactor/Reactor.swift} (91%) rename Sources/{NewReactor => GoodReactor}/Subscriber.swift (100%) rename Sources/{NewReactor => GoodReactor}/ViewModel.swift (100%) rename Sources/{GoodReactor => LegacyReactor}/GoodReactor.swift (100%) rename Sources/{NewReactor => LegacyReactor}/WeakMapTable.swift (87%) delete mode 100644 Sources/NewReactor/Coordinator.swift rename Tests/{NewReactorTests => GoodReactorTests}/PublisherTests.swift (99%) rename Tests/{NewReactorTests => GoodReactorTests}/Samples/ExternalTimer.swift (88%) rename Tests/{NewReactorTests => GoodReactorTests}/Samples/LegacyModel.swift (90%) rename Tests/{NewReactorTests => GoodReactorTests}/Samples/ObservableModel.swift (91%) delete mode 100644 Tests/NewReactorTests/NewReactorTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme index 46fb2dd..31b5962 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GoodReactor-Package.xcscheme @@ -35,6 +35,20 @@ ReferencedContainer = "container:"> + + + + ) -> AnyPublisher { + /// return event + /// .merge(with: myCustomPublisher.map { .mutation(.changeValue($0)) } + /// .eraseToAnyPublisher() + /// } + /// ``` func transform(event: AnyPublisher) -> AnyPublisher #endif @@ -203,7 +214,7 @@ public extension Reactor { public extension Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event static var logger: GoodLogger { MapTables.loggers.forceCastedValue(forKey: self, default: makeLogger()) @@ -330,11 +341,30 @@ public extension Reactor { } } } - - /// <#Description#> + + /// Create a new subscription to external event publisher for current reactor. + /// + /// The subscription is stored and kept active until the reactor is deallocated + /// or a `finish` event is received. + /// + /// + /// + /// ## Usage + /// ```swift + /// func transform() { + /// subscribe { + /// externalValuePublisher + /// } map: { + /// .changeValue($0) + /// } + /// } + /// ``` /// - Parameters: - /// - publisherProvider: <#publisherProvider description#> - /// - mapper: <#mapper description#> + /// - publisherProvider: Resolution of an external publisher of any type of value + /// - mapper: Map function mapping received values to this reactor's Mutations. + /// + /// - important: Call from the `transform` function. Remember to `start()` + /// the reactor to properly initalize the subscriptions. func subscribe( to publisherProvider: @escaping @autoclosure () -> @Sendable () async -> Publisher, map mapper: @escaping @autoclosure () -> @Sendable (Value) async -> (Mutation) @@ -468,16 +498,6 @@ public extension Reactor { } -// MARK: - CustomStringConvertible - -//public extension Reactor { -// -// nonisolated var description: String { -// String(describing: Self.self) -// } -// -//} - // MARK: - Migration public extension Reactor { diff --git a/Sources/NewReactor/Subscriber.swift b/Sources/GoodReactor/Subscriber.swift similarity index 100% rename from Sources/NewReactor/Subscriber.swift rename to Sources/GoodReactor/Subscriber.swift diff --git a/Sources/NewReactor/ViewModel.swift b/Sources/GoodReactor/ViewModel.swift similarity index 100% rename from Sources/NewReactor/ViewModel.swift rename to Sources/GoodReactor/ViewModel.swift diff --git a/Sources/GoodReactor/WeakMapTable.swift b/Sources/GoodReactor/WeakMapTable.swift index a45ecae..dcb7b56 100644 --- a/Sources/GoodReactor/WeakMapTable.swift +++ b/Sources/GoodReactor/WeakMapTable.swift @@ -143,27 +143,27 @@ final public class WeakMapTable: @unchecked Sendable where Key: AnyO /// This class is used in the WeakMapTable class as a key in a dictionary to store values, where the keys are weak references to objects of type T. /// This allows the values in the dictionary to be automatically removed when the objects they are associated with are deallocated. -private final class Weak: Hashable where T: AnyObject { +public final class Weak: Hashable where T: AnyObject { ///A hash value that is derived from the object's ObjectIdentifier. private let objectHashValue: Int /// A weak reference to an object of type T. - weak var object: T? + public weak var object: T? /// Initialization of class Weak /// - Parameter object: the object to be stored as a weak reference - init(_ object: T) { + public init(_ object: T) { self.objectHashValue = ObjectIdentifier(object).hashValue self.object = object } /// Combines the objectHashValue into the Hasher instance provided as an argument. /// - Parameter hasher: Hasher instance to hash - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.objectHashValue) } - static func == (lhs: Weak, rhs: Weak) -> Bool { + public static func == (lhs: Weak, rhs: Weak) -> Bool { return lhs.objectHashValue == rhs.objectHashValue } } @@ -171,13 +171,23 @@ private final class Weak: Hashable where T: AnyObject { // MARK: - DeallocHook /// DeallocHook is a private class that allows you to run a closure when its object is deallocated. It is used in WeakMapTable to handle deallocation of the keys in the table. -private final class DeallocHook { +public final class DeallocHook { /// A closure that will be called when the object is deallocated. private let handler: () -> Void + private var deallocHookKey: Void? + + public init(on object: AnyObject, handler: @escaping () -> Void) { + self.handler = handler + + let isInstalled = (objc_getAssociatedObject(object, &deallocHookKey) != nil) + guard !isInstalled else { return } + objc_setAssociatedObject(object, &deallocHookKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + ///DeallocHook is initialized with a closure that is to be called when the object is deallocated. The closure is stored in the handler property. - init(handler: @escaping () -> Void) { + public init(handler: @escaping () -> Void) { self.handler = handler } @@ -186,3 +196,18 @@ private final class DeallocHook { self.handler() } } + +// MARK: - Subscripts + +public extension WeakMapTable { + + subscript(key key: Key, default default: @autoclosure () -> Value) -> Value { + get { + forceCastedValue(forKey: key, default: `default`()) + } + set { + setValue(newValue, forKey: key) + } + } + +} diff --git a/Sources/GoodReactor/GoodReactor.swift b/Sources/LegacyReactor/GoodReactor.swift similarity index 100% rename from Sources/GoodReactor/GoodReactor.swift rename to Sources/LegacyReactor/GoodReactor.swift diff --git a/Sources/NewReactor/WeakMapTable.swift b/Sources/LegacyReactor/WeakMapTable.swift similarity index 87% rename from Sources/NewReactor/WeakMapTable.swift rename to Sources/LegacyReactor/WeakMapTable.swift index dcb7b56..a45ecae 100644 --- a/Sources/NewReactor/WeakMapTable.swift +++ b/Sources/LegacyReactor/WeakMapTable.swift @@ -143,27 +143,27 @@ final public class WeakMapTable: @unchecked Sendable where Key: AnyO /// This class is used in the WeakMapTable class as a key in a dictionary to store values, where the keys are weak references to objects of type T. /// This allows the values in the dictionary to be automatically removed when the objects they are associated with are deallocated. -public final class Weak: Hashable where T: AnyObject { +private final class Weak: Hashable where T: AnyObject { ///A hash value that is derived from the object's ObjectIdentifier. private let objectHashValue: Int /// A weak reference to an object of type T. - public weak var object: T? + weak var object: T? /// Initialization of class Weak /// - Parameter object: the object to be stored as a weak reference - public init(_ object: T) { + init(_ object: T) { self.objectHashValue = ObjectIdentifier(object).hashValue self.object = object } /// Combines the objectHashValue into the Hasher instance provided as an argument. /// - Parameter hasher: Hasher instance to hash - public func hash(into hasher: inout Hasher) { + func hash(into hasher: inout Hasher) { hasher.combine(self.objectHashValue) } - public static func == (lhs: Weak, rhs: Weak) -> Bool { + static func == (lhs: Weak, rhs: Weak) -> Bool { return lhs.objectHashValue == rhs.objectHashValue } } @@ -171,23 +171,13 @@ public final class Weak: Hashable where T: AnyObject { // MARK: - DeallocHook /// DeallocHook is a private class that allows you to run a closure when its object is deallocated. It is used in WeakMapTable to handle deallocation of the keys in the table. -public final class DeallocHook { +private final class DeallocHook { /// A closure that will be called when the object is deallocated. private let handler: () -> Void - private var deallocHookKey: Void? - - public init(on object: AnyObject, handler: @escaping () -> Void) { - self.handler = handler - - let isInstalled = (objc_getAssociatedObject(object, &deallocHookKey) != nil) - guard !isInstalled else { return } - objc_setAssociatedObject(object, &deallocHookKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - ///DeallocHook is initialized with a closure that is to be called when the object is deallocated. The closure is stored in the handler property. - public init(handler: @escaping () -> Void) { + init(handler: @escaping () -> Void) { self.handler = handler } @@ -196,18 +186,3 @@ public final class DeallocHook { self.handler() } } - -// MARK: - Subscripts - -public extension WeakMapTable { - - subscript(key key: Key, default default: @autoclosure () -> Value) -> Value { - get { - forceCastedValue(forKey: key, default: `default`()) - } - set { - setValue(newValue, forKey: key) - } - } - -} diff --git a/Sources/NewReactor/Coordinator.swift b/Sources/NewReactor/Coordinator.swift deleted file mode 100644 index 841f804..0000000 --- a/Sources/NewReactor/Coordinator.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Coordinator.swift -// GoodReactor -// -// Created by Filip Šašala on 09/09/2024. -// - -import SwiftUI - -@MainActor public protocol Screen: View { - -} - -@MainActor public protocol Coordinator { - - associatedtype R: Reactor - associatedtype S: Screen - - @ViewBuilder func makeScreen() -> S - -} diff --git a/Tests/GoodReactorTests/GoodReactorTests.swift b/Tests/GoodReactorTests/GoodReactorTests.swift index 6897868..c90a9bd 100644 --- a/Tests/GoodReactorTests/GoodReactorTests.swift +++ b/Tests/GoodReactorTests/GoodReactorTests.swift @@ -1,58 +1,69 @@ +// +// GoodReactorTests.swift +// GoodReactor +// +// Created by Filip Šašala on 23/08/2024. +// + import XCTest -import GoodReactor +@testable import GoodReactor +import Combine + +@available(iOS 17.0, macOS 14.0, *) +final class GoodReactorTests: XCTestCase { -final class GoodCoordinatorTests: XCTestCase { + @MainActor func testSendAction() { + let model = ObservableModel() - func testGoodCoordinatorParent() { - let firstCoordinator = FirstCoordinator(parentCoordinator: nil) - let secondCoordinator = SecondCoordinator(parentCoordinator: firstCoordinator) - let thirdCoordinator = ThirdCoordinator(parentCoordinator: secondCoordinator) - let fourthCoordinator = FourthCoordinator(parentCoordinator: thirdCoordinator) + model.send(action: .addOne) - XCTAssert(fourthCoordinator.firstCoordinatorOfType(type: ThirdCoordinator.self) == thirdCoordinator) - XCTAssert(fourthCoordinator.firstCoordinatorOfType(type: SecondCoordinator.self) == secondCoordinator) - XCTAssert(fourthCoordinator.lastCoordinatorOfType(type: FirstCoordinator.self) == firstCoordinator) + XCTAssertEqual(model.initialState.counter, 9, "Initial state mutated") + XCTAssertEqual(model.state.counter, 10, "Sending action failed") } - func testFirstCoordinatorParent() { - // When - let firstCoordinator = FirstCoordinator() - let secondCoordinator = SecondCoordinator(parentCoordinator: firstCoordinator) - let thirdCoordinator = ThirdCoordinator(parentCoordinator: secondCoordinator) - let secondSecondCoordinator = SecondCoordinator(parentCoordinator: thirdCoordinator) - let lastCoordinator = FourthCoordinator(parentCoordinator: secondSecondCoordinator) - - // Then - XCTAssert(lastCoordinator.firstCoordinatorOfType(type: FirstCoordinator.self) === firstCoordinator) - XCTAssert(lastCoordinator.firstCoordinatorOfType(type: SecondCoordinator.self) === secondSecondCoordinator) - XCTAssert(lastCoordinator.firstCoordinatorOfType(type: FourthCoordinator.self) === lastCoordinator) - XCTAssertFalse(lastCoordinator.firstCoordinatorOfType(type: SecondCoordinator.self) === secondCoordinator) + @MainActor func testInitialState() { + let model = ObservableModel() + + XCTAssertEqual(model.initialState.counter, 9, "Invalid initial state") + XCTAssertEqual(model.state.counter, 9, "Invalid initial state") + + XCTAssertNotIdentical(model.state, model.initialState, "Initial state is NOT A COPY but a reference!") + XCTAssertNotIdentical(model.state.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") } - func testLastCoordinatorParent() { - // When - let firstCoordinator = FirstCoordinator() - let secondCoordinator = SecondCoordinator(parentCoordinator: firstCoordinator) - let thirdCoordinator = ThirdCoordinator(parentCoordinator: secondCoordinator) - let secondSecondCoordinator = SecondCoordinator(parentCoordinator: thirdCoordinator) - let lastCoordinator = FourthCoordinator(parentCoordinator: secondSecondCoordinator) - - // Then - XCTAssert(lastCoordinator.lastCoordinatorOfType(type: FourthCoordinator.self) === lastCoordinator) - XCTAssert(lastCoordinator.lastCoordinatorOfType(type: SecondCoordinator.self) === secondCoordinator) - XCTAssert(lastCoordinator.lastCoordinatorOfType(type: FirstCoordinator.self) === firstCoordinator) - XCTAssertFalse(lastCoordinator.lastCoordinatorOfType(type: SecondCoordinator.self) === secondSecondCoordinator) + @MainActor func testActionMutation() async throws { + let model = ObservableModel() + + XCTAssertEqual(model.state.counter, 9) + + let expectation = XCTestExpectation() + Task { + await model.send(action: .resetToZero) + XCTAssertEqual(model.state.counter, 0, "Reset to zero failed") + expectation.fulfill() + } + + XCTAssertEqual(model.state.counter, 9, "State mutated immediately") + + await fulfillment(of: [expectation], timeout: 3) + + XCTAssertEqual(model.state.counter, 0, "State did not mutate properly") } -} + @MainActor func testLegacyModel() { + let model = LegacyModel() -enum Steps { + let expectation = XCTestExpectation(description: "Change notification was sent") - case firstStep + let cancellable = model.objectWillChange.sink { + expectation.fulfill() + } -} + model.send(action: .addOne) -class FirstCoordinator: GoodCoordinator {} -class SecondCoordinator: GoodCoordinator {} -class ThirdCoordinator: GoodCoordinator {} -class FourthCoordinator: GoodCoordinator {} + XCTAssertEqual(model.state.counter, 10) + wait(for: [expectation], timeout: 3) + withExtendedLifetime(cancellable, {}) + } + +} diff --git a/Tests/NewReactorTests/PublisherTests.swift b/Tests/GoodReactorTests/PublisherTests.swift similarity index 99% rename from Tests/NewReactorTests/PublisherTests.swift rename to Tests/GoodReactorTests/PublisherTests.swift index 538cbe3..ed9c3ea 100644 --- a/Tests/NewReactorTests/PublisherTests.swift +++ b/Tests/GoodReactorTests/PublisherTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import NewReactor +@testable import GoodReactor final class PublisherTests: XCTestCase { diff --git a/Tests/NewReactorTests/Samples/ExternalTimer.swift b/Tests/GoodReactorTests/Samples/ExternalTimer.swift similarity index 88% rename from Tests/NewReactorTests/Samples/ExternalTimer.swift rename to Tests/GoodReactorTests/Samples/ExternalTimer.swift index 548dafe..412c4d7 100644 --- a/Tests/NewReactorTests/Samples/ExternalTimer.swift +++ b/Tests/GoodReactorTests/Samples/ExternalTimer.swift @@ -6,7 +6,7 @@ // import Foundation -import NewReactor +import GoodReactor // MARK: - Example - external dependency @@ -14,7 +14,7 @@ import NewReactor final class ExternalTimer: @unchecked Sendable { @MainActor static let shared = ExternalTimer() - let timePublisher = NewReactor.Publisher() + let timePublisher = GoodReactor.Publisher() init() { Task { diff --git a/Tests/NewReactorTests/Samples/LegacyModel.swift b/Tests/GoodReactorTests/Samples/LegacyModel.swift similarity index 90% rename from Tests/NewReactorTests/Samples/LegacyModel.swift rename to Tests/GoodReactorTests/Samples/LegacyModel.swift index 9b9d385..de2e465 100644 --- a/Tests/NewReactorTests/Samples/LegacyModel.swift +++ b/Tests/GoodReactorTests/Samples/LegacyModel.swift @@ -5,12 +5,11 @@ // Created by Filip Šašala on 30/08/2024. // -import NewReactor +import GoodReactor import SwiftUI // MARK: - Example - Counter model using ObservableObject API -@available(iOS 17.0, *) final class LegacyModel: Reactor, ObservableObject { func transform() { @@ -21,7 +20,7 @@ final class LegacyModel: Reactor, ObservableObject { } } - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event // MARK: Enums @@ -38,9 +37,15 @@ final class LegacyModel: Reactor, ObservableObject { case didReceiveValue(newValue: Int) } + // MARK: Destination + + var destination: Destination? + + enum Destination {} + // MARK: State - struct State: Sendable { + struct State { var counter: Int = 9 var time: Int = 0 @@ -83,6 +88,9 @@ final class LegacyModel: Reactor, ObservableObject { case .mutation(.didChangeTime(let seconds)): state.time = seconds + + case .destination: + break } } diff --git a/Tests/NewReactorTests/Samples/ObservableModel.swift b/Tests/GoodReactorTests/Samples/ObservableModel.swift similarity index 91% rename from Tests/NewReactorTests/Samples/ObservableModel.swift rename to Tests/GoodReactorTests/Samples/ObservableModel.swift index 93fdc75..91ee738 100644 --- a/Tests/NewReactorTests/Samples/ObservableModel.swift +++ b/Tests/GoodReactorTests/Samples/ObservableModel.swift @@ -5,7 +5,7 @@ // Created by Filip Šašala on 30/08/2024. // -import NewReactor +import GoodReactor import Observation final class EmptyObject {} @@ -13,7 +13,7 @@ final class EmptyObject {} // MARK: - Example - Counter model using Observation framework @available(iOS 17.0, *) -final class ObservableModel: Reactor { +@Observable final class ObservableModel: Reactor { func transform() { subscribe { @@ -23,7 +23,7 @@ final class ObservableModel: Reactor { } } - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event // MARK: Enums @@ -40,6 +40,12 @@ final class ObservableModel: Reactor { case didReceiveValue(newValue: Int) } + // MARK: Destination + + var destination: Destination? + + enum Destination {} + // MARK: State @Observable final class State { diff --git a/Tests/NewReactorTests/NewReactorTests.swift b/Tests/NewReactorTests/NewReactorTests.swift deleted file mode 100644 index 753e234..0000000 --- a/Tests/NewReactorTests/NewReactorTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// File.swift -// GoodReactor -// -// Created by Filip Šašala on 23/08/2024. -// - -import XCTest -@testable import NewReactor -import Combine - -@available(iOS 17.0, macOS 14.0, *) -final class NewReactorTests: XCTestCase { - - @MainActor func testSendAction() { - let model = ObservableModel() - - model.send(action: .addOne) - - XCTAssertEqual(model.initialState.counter, 9, "Initial state mutated") - XCTAssertEqual(model.state.counter, 10, "Sending action failed") - } - - @MainActor func testInitialState() { - let model = ObservableModel() - - XCTAssertEqual(model.initialState.counter, 9, "Invalid initial state") - XCTAssertEqual(model.state.counter, 9, "Invalid initial state") - - XCTAssertNotIdentical(model.state, model.initialState, "Initial state is NOT A COPY but a reference!") - XCTAssertNotIdentical(model.state.object, model.initialState.object, "Current state has a reference to initial state with possible mutations!") - } - - @MainActor func testActionMutation() async throws { - let model = ObservableModel() - - XCTAssertEqual(model.state.counter, 9) - - let expectation = XCTestExpectation() - Task { - await model.send(action: .resetToZero) - XCTAssertEqual(model.state.counter, 0, "Reset to zero failed") - expectation.fulfill() - } - - XCTAssertEqual(model.state.counter, 9, "State mutated immediately") - - await fulfillment(of: [expectation], timeout: 3) - - XCTAssertEqual(model.state.counter, 0, "State did not mutate properly") - } - - @MainActor func testLegacyModel() { - let model = LegacyModel() - - model.send(action: .addOne) - - let expectation = XCTestExpectation(description: "Change notification was sent") - let cancellable = model.objectWillChange.sink { - expectation.fulfill() - } - - XCTAssertEqual(model.state.counter, 10) - wait(for: [expectation], timeout: 3) - withExtendedLifetime(cancellable, {}) - } - -}