├── Sources └── CoreEngine │ ├── Assets │ └── .gitkeep │ ├── Classes │ ├── .gitkeep │ ├── Core.swift │ ├── PublisherCore.swift │ ├── AsyncCore.swift │ └── Async │ │ └── AsyncCoreSequence.swift │ └── Misc │ └── DidPublished.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── CoreEngine.xcscheme ├── Dockerfile ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── Tests └── CoreEngineTests │ ├── DidPublishedTest.swift │ └── AsyncCoreTest.swift ├── CoreEngine.podspec └── README.md /Sources/CoreEngine/Assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Classes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM swift:5.8 as build 3 | 4 | WORKDIR /app 5 | 6 | COPY Package.swift . 7 | COPY .swiftpm . 8 | COPY Sources ./Sources 9 | COPY Tests ./Tests 10 | 11 | RUN swift package resolve 12 | RUN swift build -c release 13 | # RUN swift test --parallel 14 | FROM swift:5.8-slim 15 | 16 | WORKDIR /app 17 | 18 | COPY --from=build /app/.build/release /app/build 19 | 20 | CMD ["./CoreEngine"] 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Integration Core Engine 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - '*' 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up SSH 21 | uses: webfactory/ssh-agent@v0.5.3 22 | with: 23 | ssh-private-key: ${{ secrets.VULTR_SSH_PRIVATE_KEY }} 24 | 25 | - name: Build Docker image 26 | run: docker build -t swift-app . 27 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Misc/DidPublished.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | 4 | @propertyWrapper 5 | public struct DidPublished { 6 | private var value: Value 7 | private let subject = PassthroughSubject() 8 | 9 | public var wrappedValue: Value { 10 | get { value } 11 | set { 12 | value = newValue 13 | subject.send(newValue) 14 | } 15 | } 16 | 17 | public var projectedValue: AnyPublisher { 18 | subject.eraseToAnyPublisher() 19 | } 20 | 21 | public init(wrappedValue: Value) { 22 | self.value = wrappedValue 23 | } 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | *.moved-aside 17 | DerivedData 18 | *.hmap 19 | *.ipa 20 | .build/ 21 | # Bundler 22 | .bundle 23 | 24 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 25 | # Carthage/Checkouts 26 | 27 | Carthage/Build 28 | 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 32 | # 33 | # Note: if you ignore the Pods directory, make sure to uncomment 34 | # `pod install` in .travis.yml 35 | # 36 | # Pods/ 37 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Classes/Core.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @dynamicMemberLookup 4 | @dynamicCallable 5 | public protocol Core: AnyObject { 6 | associatedtype Action 7 | associatedtype State 8 | 9 | var action: ((Action) -> ()) { get } 10 | var state: State { get set } 11 | func reduce(state: State, action: Action) -> State 12 | } 13 | 14 | extension Core { 15 | public var action: ((Action) -> ()) { 16 | let _newActionClosure: ((Action) -> ()) = { [weak self] _action in 17 | if let self = self { 18 | self.state = self.reduce(state: self.state, action: _action) 19 | } 20 | } 21 | return _newActionClosure 22 | } 23 | 24 | public func dynamicallyCall(withArguments actions: [Action]) { 25 | actions.forEach({ self.action($0) }) 26 | } 27 | 28 | public subscript(dynamicMember keyPath: KeyPath) -> T { 29 | return state[keyPath: keyPath] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 stareta1202 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CoreEngine", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "CoreEngine", 16 | targets: ["CoreEngine"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "CoreEngine", 27 | dependencies: []), 28 | .testTarget( 29 | name: "CoreEngineTests", 30 | dependencies: [ 31 | "CoreEngine" 32 | ], 33 | path: "Tests/CoreEngineTests" 34 | ) 35 | 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Classes/PublisherCore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(Combine) 3 | import Combine 4 | 5 | public protocol PublisherCore: Core, ObservableObject { 6 | typealias Error = Swift.Error 7 | 8 | var subscription: Set { get set } 9 | func dispatch(effect: any Publisher) 10 | func dispatch(effect: any Publisher) 11 | 12 | func handleError(error: Error) 13 | } 14 | 15 | extension PublisherCore { 16 | public func dispatch(effect: any Publisher) { 17 | effect 18 | .sink { [weak self] completion in 19 | if case let .failure(error) = completion { 20 | self?.handleError(error: error) 21 | } 22 | } receiveValue: { [weak self] value in 23 | if let self { 24 | self.state = self.reduce(state: self.state, action: value) 25 | } 26 | } 27 | .store(in: &self.subscription) 28 | } 29 | 30 | public func dispatch(effect: any Publisher) { 31 | effect 32 | .sink(receiveValue: { [weak self] value in 33 | if let self { 34 | self.state = self.reduce(state: self.state, action: value) 35 | } 36 | }) 37 | .store(in: &self.subscription) 38 | } 39 | 40 | public func handleError(error: Error) { } 41 | } 42 | 43 | @available(*, deprecated, renamed: "PublisherCore", message: "Use PublisherCore instead") 44 | public typealias AnyCore = PublisherCore 45 | #endif 46 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Classes/AsyncCore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol AsyncCore: Actor { 4 | associatedtype Action 5 | associatedtype State 6 | 7 | var action: ((Action) async -> ()) { get } 8 | nonisolated var currentState: State { get set } 9 | var states: AsyncCoreSequence { get } 10 | var continuation: AsyncStream.Continuation { get } 11 | 12 | func reduce(state: State, action: Action) async throws -> State 13 | func handleError(error: Error) async 14 | } 15 | 16 | public extension AsyncCore { 17 | var action: ((Action) async -> ()) { 18 | let newActionClosure: ((Action) async -> ()) = { [weak self] _action in 19 | guard let self = self else { return } 20 | do { 21 | let reducedState = try await self.reduce(state: self.currentState, action: _action) 22 | await self.update(state: reducedState) 23 | } catch { 24 | await self.handleError(error: error) 25 | } 26 | } 27 | return newActionClosure 28 | } 29 | 30 | func dynamicallyCall(withArguments actions: [Action]) async { 31 | for action in actions { 32 | await self.action(action) 33 | } 34 | } 35 | 36 | subscript(dynamicMember keyPath: KeyPath) -> T { 37 | return currentState[keyPath: keyPath] 38 | } 39 | 40 | nonisolated func send(_ action: Action) { 41 | Task { 42 | await self.action(action) 43 | } 44 | } 45 | 46 | func handleError(error: Error) async { } 47 | 48 | func select(_ selector: @escaping @Sendable (State) -> Selected) -> AsyncMapSequence, Selected> { 49 | states.map(selector) 50 | } 51 | } 52 | 53 | private extension AsyncCore { 54 | private func update(state: State) async { 55 | self.currentState = state 56 | continuation.yield(self.currentState) 57 | self.states.send(state) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/CoreEngineTests/DidPublishedTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | #if canImport(Combine) 3 | import Combine 4 | 5 | //@testable import YourModuleName // Replace with the name of your module 6 | @testable import CoreEngine 7 | 8 | class DidPublishedTests: XCTestCase { 9 | var cancellables: Set = [] 10 | 11 | override func tearDown() { 12 | cancellables.removeAll() 13 | super.tearDown() 14 | } 15 | 16 | // Test to verify that the wrapped value changes correctly 17 | func testWrappedValueAssignment() { 18 | class TestObject { 19 | @DidPublished var value: Int = 0 20 | } 21 | 22 | let testObject = TestObject() 23 | XCTAssertEqual(testObject.value, 0) 24 | 25 | testObject.value = 10 26 | XCTAssertEqual(testObject.value, 10) 27 | } 28 | 29 | // Test to verify that the publisher emits values when the wrapped value changes 30 | func testPublisherEmitsValues() { 31 | class TestObject { 32 | @DidPublished var value: String = "Initial" 33 | } 34 | 35 | let testObject = TestObject() 36 | var receivedValues: [String] = [] 37 | 38 | testObject.$value 39 | .sink { value in 40 | receivedValues.append(value) 41 | } 42 | .store(in: &cancellables) 43 | 44 | testObject.value = "First Update" 45 | testObject.value = "Second Update" 46 | 47 | // Allow some time for the publisher to emit values 48 | XCTAssertEqual(receivedValues, ["First Update", "Second Update"]) 49 | } 50 | 51 | // Test to verify that ObservableObject integration works 52 | func testObservableObjectIntegration() async { 53 | class TestObject: ObservableObject { 54 | @DidPublished var value: Double = 0.0 55 | } 56 | 57 | let objectWillChangedExpectation = XCTestExpectation(description: "DidCalled object will changed") 58 | let valuePublisherExpectation = XCTestExpectation(description: "value was changed") 59 | 60 | let testObject = TestObject() 61 | 62 | 63 | testObject.$value 64 | .sink { _ in 65 | valuePublisherExpectation.fulfill() 66 | } 67 | .store(in: &cancellables) 68 | 69 | testObject.value = 3.14 70 | 71 | await fulfillment(of: [ valuePublisherExpectation], timeout: 5) 72 | 73 | 74 | XCTAssertEqual(testObject.value, 3.14) 75 | } 76 | } 77 | #endif -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/CoreEngine.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /CoreEngine.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint CoreEngine.podspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'CoreEngine' 11 | s.version = '1.1.0' 12 | s.summary = '🌪️ Simple and light-weighted unidirectional Data Flow in Swift' 13 | 14 | # This description is used to generate tags and improve search results. 15 | # * Think: What does it do? Why did you write it? What is the focus? 16 | # * Try to keep it short, snappy and to the point. 17 | # * Write the description between the DESC delimiters below. 18 | # * Finally, don't worry about the indent, CocoaPods strips it! 19 | 20 | s.description = <<-DESC 21 | 22 | # CoreKit 23 | 24 | Core is a framework for making more reactive applications inspired by [ReactorKit](https://github.com/ReactorKit/ReactorKit), [Redux](http://redux.js.org/docs/basics/index.html) with Combine. It's a very light weigthed and simple architecture, so you can either use CocoaPods or SPM to stay up to date, or just drag and drop into your project and go. Or you can look through it and roll your own. 25 | 26 | ## Example 27 | 28 | See details on Example 29 | 30 | ```swift 31 | // on ViewController 32 | let label = UILabel() 33 | let increaseButton = UIButton() 34 | let decreaseButton = UIButton() 35 | var core: MainCore = .init() 36 | 37 | func increaseButtonTapped() { 38 | self.core.action(.increase) 39 | } 40 | 41 | func decreaseButtonTapped() { 42 | self.core.action(.decrease) 43 | } 44 | 45 | func bind() { 46 | core.$state.map(\.count) 47 | .sink { [weak self] count in 48 | self?.label.text = "\(count)" 49 | } 50 | .store(in: &subscription) 51 | } 52 | ... 53 | ``` 54 | 55 | ```swift 56 | class MainCore: Core { 57 | var action: Action? = nil 58 | 59 | var subscription: Set = .init() 60 | 61 | enum Action: Equatable, Hashable { 62 | case increase 63 | case decrease 64 | } 65 | 66 | struct State: Equatable { 67 | var count = 0 68 | } 69 | 70 | @Published var state: State = .init() 71 | 72 | func reduce(state: State, action: Action) -> State { 73 | var newState = state 74 | switch action { 75 | case .decrease: 76 | newState.count -= 1 77 | case .increase: 78 | newState.count += 1 79 | } 80 | return newState 81 | } 82 | } 83 | 84 | ``` 85 | DESC 86 | 87 | s.homepage = 'https://github.com/stareta1202/CoreEngine' 88 | s.license = { :type => 'MIT', :file => 'LICENSE' } 89 | s.author = { 'stareta1202' => 'stareta1202@gmail.com' } 90 | s.source = { :git => 'https://github.com/stareta1202/CoreEngine.git', :tag => s.version.to_s } 91 | s.social_media_url = 'https://www.sobabear.com' 92 | 93 | s.ios.deployment_target = '13.0' 94 | 95 | s.source_files = 'Sources/CoreEngine/Classes/**/*' 96 | 97 | # s.resource_bundles = { 98 | # 'CoreEngine' => ['CoreEngine/Assets/*.png'] 99 | # } 100 | 101 | # s.public_header_files = 'Pod/Classes/**/*.h' 102 | # s.frameworks = 'UIKit', 'MapKit' 103 | # s.dependency 'AFNetworking', '~> 2.3' 104 | end 105 | -------------------------------------------------------------------------------- /Tests/CoreEngineTests/AsyncCoreTest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import CoreEngine 3 | import XCTest 4 | 5 | actor MyAsyncCore: @preconcurrency AsyncCore { 6 | 7 | var states: AsyncCoreSequence 8 | var continuation: AsyncStream.Continuation 9 | 10 | init(initialState: State) { 11 | self.currentState = initialState 12 | let (stream, continuation) = AsyncStream.makeStream() 13 | 14 | self.states = .init(stream) 15 | self.continuation = continuation 16 | } 17 | 18 | 19 | enum Action { 20 | case increment 21 | case decrement 22 | case sleepAndIncreaseTen 23 | } 24 | 25 | struct State: Equatable { 26 | var count: Int 27 | } 28 | 29 | nonisolated(unsafe) var currentState: State 30 | 31 | func reduce(state: State, action: Action) async throws -> State { 32 | var newState = state 33 | switch action { 34 | case .increment: 35 | newState.count += 1 36 | case .decrement: 37 | newState.count -= 1 38 | case .sleepAndIncreaseTen: 39 | try! await Task.sleep(nanoseconds: 1_000_000_000) 40 | newState.count += 10 41 | } 42 | 43 | return newState 44 | } 45 | 46 | func handleError(error: Error) async { 47 | print("Error: \(error.localizedDescription)") 48 | } 49 | } 50 | 51 | final class AsyncCoreTests: XCTestCase { 52 | 53 | 54 | 55 | // Test multiple actions in sequence 56 | func testMultipleActions() async { 57 | let core = MyAsyncCore(initialState: .init(count: 0)) 58 | 59 | await core.action(.increment) 60 | await core.action(.increment) 61 | await core.action(.increment) 62 | 63 | var actionCount = 0 64 | 65 | for await count in await core.states.map(\.count) { 66 | actionCount += 1 67 | print("wow count is\(count)") 68 | if actionCount == 3 { 69 | break 70 | } 71 | } 72 | } 73 | 74 | func testMultipleSemd() { 75 | let core = MyAsyncCore(initialState: .init(count: 0)) 76 | 77 | core.send(.increment) 78 | core.send(.increment) 79 | core.send(.increment) 80 | 81 | Task { 82 | var actionCount = 0 83 | for await count in await core.states.map(\.count) { 84 | actionCount += 1 85 | print("wow count is\(count)") 86 | if actionCount == 3 { 87 | break 88 | } 89 | } 90 | } 91 | 92 | } 93 | 94 | func testCurrentValues() async { 95 | let core = MyAsyncCore(initialState: .init(count: 0)) 96 | 97 | 98 | await core.action(.increment) 99 | let count1 = core.currentState.count 100 | XCTAssertEqual(count1, 1) 101 | 102 | 103 | 104 | await core.action(.increment) 105 | let count2 = core.currentState.count 106 | XCTAssertEqual(count2, 2) 107 | 108 | 109 | await core.action(.increment) 110 | let count3 = core.currentState.count 111 | XCTAssertEqual(count3, 3) 112 | 113 | await core.action(.decrement) 114 | let count4 = core.currentState.count 115 | XCTAssertEqual(count4, 2) 116 | } 117 | 118 | func testEstimateTime() async { 119 | let core = MyAsyncCore(initialState: .init(count: 0)) 120 | let startTime = Date().timeIntervalSince1970 121 | 122 | await core.action(.sleepAndIncreaseTen) 123 | await core.action(.sleepAndIncreaseTen) 124 | await core.action(.sleepAndIncreaseTen) 125 | await core.action(.decrement) 126 | 127 | await core.action(.sleepAndIncreaseTen) 128 | 129 | 130 | let endTime = Date().timeIntervalSince1970 131 | 132 | XCTAssertLessThanOrEqual(endTime - startTime, 5) 133 | XCTAssertEqual(core.currentState.count, 39) 134 | 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/CoreEngine/Classes/Async/AsyncCoreSequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @dynamicMemberLookup 4 | public final class AsyncCoreSequence: AsyncSequence { 5 | public typealias Element = State 6 | public struct Iterator: AsyncIteratorProtocol { 7 | public typealias Element = State 8 | 9 | @usableFromInline 10 | var iterator: AsyncStream.Iterator 11 | 12 | @usableFromInline 13 | init(_ iterator: AsyncStream.Iterator) { 14 | self.iterator = iterator 15 | } 16 | 17 | @inlinable 18 | public mutating func next() async -> Element? { 19 | await iterator.next() 20 | } 21 | } 22 | 23 | private let stream: AsyncStream 24 | private var continuations: [AsyncStream.Continuation] = [] 25 | private var last: State? 26 | 27 | public init(_ stream: AsyncStream) { 28 | self.stream = stream 29 | } 30 | 31 | deinit { 32 | continuations.forEach { $0.finish() } 33 | } 34 | 35 | public nonisolated func makeAsyncIterator() -> Iterator { 36 | Iterator(stream.makeAsyncIterator()) 37 | } 38 | 39 | public func send(_ state: State) { 40 | last = state 41 | for continuation in continuations { 42 | continuation.yield(state) 43 | } 44 | } 45 | 46 | public subscript( 47 | dynamicMember keyPath: KeyPath 48 | ) -> AsyncMapSequence, Property> { 49 | let (stream, continuation) = AsyncStream.createStream() 50 | continuations.append(continuation) 51 | if let last { 52 | continuation.yield(last) 53 | } 54 | return stream.map { $0[keyPath: keyPath] } 55 | } 56 | } 57 | 58 | public extension AsyncCoreSequence { 59 | /// Custom `map` function to map over state properties using key paths. 60 | func map( 61 | _ keyPath: KeyPath 62 | ) -> AsyncMapSequence, Property> { 63 | let (stream, continuation) = AsyncStream.createStream() 64 | continuations.append(continuation) 65 | 66 | if let lastState = last { 67 | continuation.yield(lastState) 68 | } 69 | 70 | return stream.map { $0[keyPath: keyPath] } 71 | } 72 | 73 | func map( 74 | _ transform: @Sendable @escaping (State) async -> Transformed 75 | ) -> AsyncMapSequence, Transformed> { 76 | let (stream, continuation) = AsyncStream.createStream() 77 | continuations.append(continuation) 78 | if let last = last { 79 | continuation.yield(last) 80 | } 81 | return stream.map(transform) 82 | } 83 | 84 | func map( 85 | _ transform: @escaping (State) async throws -> Transformed 86 | ) -> AsyncThrowingMapSequence, Transformed> { 87 | let (stream, continuation) = AsyncStream.createStream() 88 | continuations.append(continuation) 89 | if let last = last { 90 | continuation.yield(last) 91 | } 92 | return stream.map(transform) 93 | } 94 | 95 | func filter( 96 | _ isIncluded: @escaping (State) async -> Bool 97 | ) -> AsyncFilterSequence> { 98 | let (stream, continuation) = AsyncStream.createStream() 99 | continuations.append(continuation) 100 | if let last = last { 101 | continuation.yield(last) 102 | } 103 | return stream.filter(isIncluded) 104 | } 105 | 106 | func dropFirst(_ count: Int = 1) -> AsyncDropFirstSequence> { 107 | let (stream, continuation) = AsyncStream.createStream() 108 | continuations.append(continuation) 109 | if let last = last { 110 | continuation.yield(last) 111 | } 112 | return stream.dropFirst(count) 113 | } 114 | 115 | // func flatMap( 116 | // _ transform: @escaping (State) async -> SegmentOfResult 117 | // ) -> AsyncFlatMapSequence, SegmentOfResult> { 118 | // let (stream, continuation) = AsyncStream.createStream() 119 | // continuations.append(continuation) 120 | // if let last = last { 121 | // continuation.yield(last) 122 | // } 123 | // return stream.flatMap(transform) 124 | // } 125 | 126 | func flatMap( 127 | _ transform: @escaping (State) async throws -> SegmentOfResult 128 | ) -> AsyncThrowingFlatMapSequence, SegmentOfResult> { 129 | let (stream, continuation) = AsyncStream.createStream() 130 | continuations.append(continuation) 131 | if let last = last { 132 | continuation.yield(last) 133 | } 134 | return stream.flatMap(transform) 135 | } 136 | 137 | func drop( 138 | while predicate: @escaping (State) async -> Bool 139 | ) -> AsyncDropWhileSequence> { 140 | let (stream, continuation) = AsyncStream.createStream() 141 | continuations.append(continuation) 142 | if let last = last { 143 | continuation.yield(last) 144 | } 145 | return stream.drop(while: predicate) 146 | } 147 | } 148 | 149 | 150 | extension AsyncSequence { 151 | /// makeStream is not supported on Linux 152 | static func createStream( 153 | of elementType: Element.Type = Element.self, 154 | bufferingPolicy limit: AsyncStream.Continuation.BufferingPolicy = .unbounded 155 | ) -> (stream: AsyncStream, continuation: AsyncStream.Continuation) { 156 | var continuation: AsyncStream.Continuation! 157 | let stream = AsyncStream(bufferingPolicy: limit) { cont in 158 | continuation = cont 159 | } 160 | return (stream, continuation) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoreEngine 2 | [![CI](https://github.com/sobabear/CoreEngine/actions/workflows/ci.yml/badge.svg)](https://github.com/sobabear/CoreEngine/actions/workflows/ci.yml) 3 | [![Version](https://img.shields.io/github/v/tag/sobabear/CoreEngine?label=version&sort=semver)](https://github.com/sobabear/CoreEngine/releases) 4 | [![License](https://img.shields.io/cocoapods/l/CoreEngine.svg?style=flat)](https://cocoapods.org/pods/CoreEngine) 5 | 6 | ### Simple and light 7 | ![image](https://user-images.githubusercontent.com/47838132/224374882-38cd9b39-9317-4efb-8b16-d320c434d23e.png) 8 | Core is a framework for making more reactive applications inspired by [ReactorKit](https://github.com/ReactorKit/ReactorKit), [Redux](http://redux.js.org/docs/basics/index.html). 9 | ### Scalability 10 | Core is Reactive independent Framework which means you can expand whatever you want to import such as [Combine](https://developer.apple.com/documentation/combine), [RxSwift](https://github.com/ReactiveX/RxSwift). 11 | 12 | It's a very light weigthed and simple architecture, so you can either use CocoaPods or SPM to stay up to date, or just drag and drop into your project and go. Or you can look through it and roll your own. 13 | 14 | ## Installation 15 | CoreEngine is available through [CocoaPods](https://cocoapods.org). To install 16 | it, simply add the following line to your Podfile: 17 | 18 | ### CocoaPods 19 | 20 | ```ruby 21 | pod 'CoreEngine' 22 | ``` 23 | 24 | ### Swift Package Manager 25 | 26 | [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. 27 | 28 | 29 | To integrate SnapKit into your Xcode project using Swift Package Manager, add it to the dependencies value of your `Package.swift`: 30 | 31 | ```swift 32 | dependencies: [ 33 | .package(url: "https://github.com/sobabear/CoreEngine.git", .upToNextMajor(from: "1.3.1")) 34 | ] 35 | ``` 36 | 37 | ## Performance 38 | Screenshot 2023-04-28 at 1 39 26 PM 39 | 40 | 41 | Core Engine is insanely fast and light-weight compared to similar frameworks 42 | you can check details here [CoreEngineBenchMark](https://github.com/sobabear/CoreEngineBenchMark) 43 | 44 | 45 | ## Highly Recommended: Using AsyncCore 46 | 47 | While CoreEngine provides both traditional reactive approaches and state management patterns, we highly recommend using AsyncCore for modern, asynchronous, and more efficient state handling. 48 | 49 | AsyncCore leverages Swift's structured concurrency with async/await, providing a clean and intuitive way to manage state updates, handle side effects, and ensure thread safety with Swift's Actor model. 50 | 51 | ### Why Use AsyncCore? 52 | - Concurrency-First: Built for Swift's native concurrency model, using async/await to handle asynchronous actions. 53 | - Simplified State Management: AsyncCore simplifies reactive architecture by providing built-in async state streams and an efficient way to dispatch actions. 54 | - Error Handling and Side Effects: AsyncCore includes streamlined error handling and support for asynchronous side effects. 55 | - Thread Safety: Since AsyncCore is built on top of Swift’s Actor system, it ensures thread-safe access to state and actions. 56 | 57 | ## Example 58 | 59 | See details on Example 60 | 61 | ```swift 62 | /// on ViewController with Core 63 | let label = UILabel() 64 | let increaseButton = UIButton() 65 | let decreaseButton = UIButton() 66 | var core: MainCore = .init() 67 | 68 | func increaseButtonTapped() { 69 | self.core.action(.increase) 70 | } 71 | 72 | func decreaseButtonTapped() { 73 | self.core.action(.decrease) 74 | } 75 | 76 | func multipleActions() { 77 | self.core.action(.increase, .decrease) 78 | } 79 | 80 | 81 | func bind() { 82 | core.$state.map(\.count) 83 | .sink { [weak self] count in 84 | self?.label.text = "\(count)" 85 | } 86 | .store(in: &subscription) 87 | } 88 | ... 89 | 90 | /// on ViewController with AsyncCore 91 | class ViewController: UIViewController { 92 | private var core: AsyncMainCore? 93 | 94 | override func viewDidLoad() { 95 | super.viewDidLoad() 96 | 97 | Task { 98 | let core = await AsyncMainCore(initialState: .init()) 99 | self.core = core 100 | self.bind(core: core) 101 | } 102 | } 103 | 104 | private func bind(core: AsyncMainCore) { 105 | Task { 106 | for await count in core.states.compactMap(\.count) { 107 | print("Count: \(count)") 108 | } 109 | } 110 | Task { 111 | for await count in core.states.count { 112 | print("Count: \(count)") 113 | } 114 | } 115 | } 116 | 117 | private func bind() { 118 | Task { 119 | if let counts = self.core?.states.count { 120 | for await count in counts { 121 | print("Count: \(count)") 122 | } 123 | } 124 | } 125 | } 126 | 127 | 128 | @IBAction func increaseTapped() { 129 | core?.send(.increase) 130 | } 131 | 132 | @IBAction func decreaseTapped() { 133 | core?.send(.decrease) 134 | } 135 | } 136 | 137 | ``` 138 | 139 | ```swift 140 | actor AsyncMainCore: AsyncCore { 141 | var currentState: State 142 | 143 | enum Action: Equatable, Hashable { 144 | case increase 145 | case decrease 146 | } 147 | 148 | struct State: Equatable, Sendable { 149 | var count = 0 150 | } 151 | nonisolated(unsafe) var currentState: State = .init() 152 | var states: AsyncCoreSequence 153 | var continuation: AsyncStream.Continuation 154 | 155 | init(initialState: State) async { 156 | self.currentState = initialState 157 | let (states, continuation) = AsyncStream.makeStream() 158 | self.states = await .init(states) 159 | self.continuation = continuation 160 | } 161 | 162 | func reduce(state: State, action: Action) async -> State { 163 | var newState = state 164 | switch action { 165 | case .increase: 166 | newState.count += 1 167 | case .decrease: 168 | newState.count -= 1 169 | } 170 | return newState 171 | } 172 | } 173 | 174 | class MainCore: Core { 175 | var subscription: Set = .init() 176 | 177 | enum Action: Equatable, Hashable { 178 | case increase 179 | case decrease 180 | } 181 | 182 | struct State: Equatable { 183 | var count = 0 184 | } 185 | 186 | @Published var state: State = .init() 187 | 188 | func reduce(state: State, action: Action) -> State { 189 | var newState = state 190 | switch action { 191 | case .decrease: 192 | newState.count -= 1 193 | case .increase: 194 | newState.count += 1 195 | } 196 | return newState 197 | } 198 | } 199 | 200 | ``` 201 | 202 | 203 | ## Side Effect & Error Handling 204 | 205 | Not just simple core, but complex core is also supported. For example, Side Effect and Error handling. When it comes to those, you use ```AsyncCore or PublisherCore```. 206 | 207 | It is not very different from Core, since AnyCore also conforms. 208 | 209 | ### func dispatch(effect: any Publisher) & func handleError(error: Error) 210 | This method is defined in AnyCore and when you deal with side-effect generated publisher send into the function. Also you can handle every errors on the ```handleError(error: Error)``` function 211 | 212 | Here is an example of the ``` PublisherCore``` 213 | 214 | 215 | ```swift 216 | class MainCore: PublisherCore { 217 | var subscription: Set = .init() 218 | 219 | enum Action { 220 | case increase 221 | case decrease 222 | case jump(Int) 223 | case setNumber(Int) 224 | } 225 | 226 | struct State { 227 | var count = 0 228 | } 229 | 230 | @Published var state: State = .init() 231 | @Published var tenGap: Int = 10 232 | 233 | private let sessionService = SessionService() 234 | 235 | init { 236 | dispatch(effect: sessionService.randomUInt$().map(Action.setNumber)) 237 | } 238 | 239 | func reduce(state: State, action: Action) -> State { 240 | var newState = state 241 | 242 | switch action { 243 | case .increase: 244 | newState.count += 1 245 | case .decrease: 246 | newState.count -= 1 247 | case let .jump(value): 248 | newState.count += value 249 | case let .setNumber(value): 250 | newState.count = value 251 | } 252 | return newState 253 | } 254 | 255 | func handleError(error: Error) { 256 | if let errpr = error as? MyError { 257 | //handle 258 | } 259 | } 260 | 261 | func tenJumpAction() { 262 | self.dispatch(effect: $tenGap.map(Action.jump)) 263 | } 264 | } 265 | 266 | 267 | class SessionService { 268 | func randomUInt$() -> AnyPublisher { 269 | // blahblah 270 | } 271 | } 272 | 273 | 274 | ``` 275 | ## Examples + RxSwift 276 | 277 | copy those code for RxSwift 278 | ```swift 279 | import Foundation 280 | import CoreEngine 281 | import RxSwift 282 | 283 | protocol RxCore: Core { 284 | var disposeBag: DisposeBag { get set } 285 | 286 | func mutate(effect: Observable) 287 | func handleError(error: Error) 288 | } 289 | 290 | extension RxCore { 291 | public func mutate(effect: Observable) { 292 | effect 293 | .subscribe(onNext: { [weak self] in 294 | if let self { 295 | self.state = self.reduce(state: self.state, action: $0) 296 | } 297 | 298 | }, onError: { [weak self] in 299 | self?.handleError(error: $0) 300 | }) 301 | .disposed(by: disposeBag) 302 | } 303 | 304 | public func handleError(error: Error) { } 305 | } 306 | 307 | 308 | @propertyWrapper 309 | class ObservableProperty: ObservableType { 310 | var wrappedValue: Element { 311 | didSet { 312 | subject.onNext(wrappedValue) 313 | } 314 | } 315 | 316 | private let subject: BehaviorSubject 317 | 318 | init(wrappedValue: Element) { 319 | self.wrappedValue = wrappedValue 320 | self.subject = BehaviorSubject(value: wrappedValue) 321 | } 322 | 323 | var projectedValue: Observable { 324 | return subject.asObservable() 325 | } 326 | 327 | func subscribe(_ observer: Observer) -> Disposable where Observer : ObserverType, Element == Observer.Element { 328 | return subject.subscribe(observer) 329 | } 330 | } 331 | 332 | ``` 333 | 334 | ## Author 335 | 336 | stareta1202, stareta1202@gmail.com 337 | 338 | ## License 339 | 340 | CoreEngine is available under the MIT license. See the LICENSE file for more info. 341 | 342 | 343 | 344 | --------------------------------------------------------------------------------