├── .github └── workflows │ └── Deploy_DocC.yml ├── .gitignore ├── Package.swift ├── README.md ├── Sources └── CombineFlow │ ├── Flow │ ├── Flow.swift │ └── Flows.swift │ ├── FlowContributor │ ├── FlowContributor.swift │ └── FlowContributors.swift │ ├── FlowCoordinator.swift │ ├── Presentable.swift │ └── Step │ ├── Step.swift │ └── Stepper.swift └── Tests └── CombineFlowTests └── CombineFlowTests.swift /.github/workflows/Deploy_DocC.yml: -------------------------------------------------------------------------------- 1 | name: Deploy DocC to Github Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | hosting: 10 | runs-on: macos-12 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - uses: maxim-lobanov/setup-xcode@v1 16 | with: 17 | xcode-version: '14.0' 18 | - name: config git 19 | run: | 20 | git config --global user.name 'baekteun' 21 | git config --global user.email 'baegteun@gmail.com' 22 | git config pull.rebase false 23 | git checkout -t origin/Deploy_DocC 24 | git pull origin Deploy_DocC 25 | git merge master 26 | - name: Archive Docc 27 | run: | 28 | xcodebuild clean docbuild -scheme CombineFlow \ 29 | -destination generic/platform=iOS \ 30 | OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path CombineFlow --output-path docs" 31 | 32 | - name: git commit & push 33 | run: | 34 | git add -A 35 | git commit -m "📝 :: deploy docc" 36 | git push 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /build 3 | /.build 4 | /.swiftpm 5 | /*.xcodeproj -------------------------------------------------------------------------------- /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: "CombineFlow", 8 | platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], 9 | products: [ 10 | .library( 11 | name: "CombineFlow", 12 | targets: ["CombineFlow"]) 13 | ], 14 | dependencies: [], 15 | targets: [ 16 | .target( 17 | name: "CombineFlow", 18 | dependencies: [] 19 | ), 20 | .testTarget( 21 | name: "CombineFlowTests", 22 | dependencies: ["CombineFlow"] 23 | ) 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineFlow 2 | 3 | Navigation framework for iOS applications based on a Coordinator pattern. 4 | 5 | [Document](https://baekteun.github.io/CombineFlow/documentation/combineflow/) 6 | 7 |
8 | 9 | ## Constents 10 | - [CombineFlow](#combineflow) 11 | - [Constents](#constents) 12 | - [Requirements](#requirements) 13 | - [Overview](#overview) 14 | - [Communication](#communication) 15 | - [Installation](#installation) 16 | - [Swift Package Manager](#swift-package-manager) 17 | - [Manually](#manually) 18 | - [Usage](#usage) 19 | - [Quick Start](#quick-start) 20 | 21 | 22 | ## Requirements 23 | - iOS 13.0+ 24 | - Swift 5+ 25 | 26 |
27 | 28 | ## Overview 29 | Navigation framework for iOS applications based on a Coordinator pattern 30 | 31 | CombineFlow is inspired [RxFlow](https://github.com/RxSwiftCommunity/RxFlow) 32 | 33 |
34 | 35 | ## Communication 36 | - If you found a bug, open an issue. 37 | - If you have a feature request, open an issue. 38 | - If you want to contribute, submit a pull request. 39 | 40 |
41 | 42 | ## Installation 43 | 44 | ### Swift Package Manager 45 | [Swift Package Manager](https://www.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. 46 | 47 | To integrate `CombineFlow` into your Xcode project using Swift Package Manager, add it to the dependencies value of your Package.swift: 48 | 49 | ```swift 50 | dependencies: [ 51 | .package(url: "https://github.com/baekteun/CombineFlow.git", .upToNextMajor(from: "1.0.0")) 52 | ] 53 | ``` 54 | 55 | ### Manually 56 | If you prefer not to use either of the aforementioned dependency managers, you can integrate CombineFlow into your project manually. 57 | 58 |
59 | 60 | ## Usage 61 | 62 | ### Quick Start 63 | 64 | ```swift 65 | // create a path 66 | import CombineFlow 67 | 68 | enum ExStep: Step { 69 | case main 70 | } 71 | ``` 72 | 73 | ```swift 74 | // create a flow 75 | import CombineFlow 76 | import Combine 77 | import UIKit 78 | 79 | final class MainFlow: Flow { 80 | private let rootVC = UINavigationController() 81 | 82 | var root: Presentable { 83 | rootVC 84 | } 85 | 86 | // navigation 87 | func navigate(to step: any Step) -> FlowContributors { 88 | guard let step = step as? ExStep else { return .none } 89 | switch step { 90 | case .main: 91 | let vc = StepperViewController() 92 | rootVC.setViewControllers([vc], animated: true) 93 | return .one(.contribute(withNextPresentable: vc, withNextStepper: vc)) 94 | } 95 | return .none 96 | } 97 | } 98 | ``` 99 | 100 | -------------------------------------------------------------------------------- /Sources/CombineFlow/Flow/Flow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol Flow: Presentable { 4 | var root: Presentable { get } 5 | 6 | func navigate(to step: any Step) -> FlowContributors 7 | } 8 | -------------------------------------------------------------------------------- /Sources/CombineFlow/Flow/Flows.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum Flows { 4 | public static func use( 5 | _ flow: any Flow, 6 | block: @escaping (_ root: Root) -> Void 7 | ) where Root: UIViewController { 8 | guard let root = flow.root as? Root else { 9 | fatalError("The Root type does not match Flow's root") 10 | } 11 | block(root) 12 | } 13 | 14 | public static func use( 15 | _ Flow1: any Flow, 16 | _ Flow2: any Flow, 17 | block: @escaping (_ root1: Root1, _ root2: Root2) -> Void 18 | ) where Root1: UIViewController, Root2: UIViewController { 19 | guard 20 | let root1 = Flow1.root as? Root1, 21 | let root2 = Flow2.root as? Root2 22 | else { 23 | fatalError("The Root type does not match Flow's root") 24 | } 25 | block(root1, root2) 26 | } 27 | 28 | public static func use( 29 | _ Flow1: any Flow, 30 | _ Flow2: any Flow, 31 | _ Flow3: any Flow, 32 | block: @escaping (_ root1: Root1, _ root2: Root2, _ root3: Root3) -> Void 33 | ) where Root1: UIViewController, Root2: UIViewController, Root3: UIViewController { 34 | guard 35 | let root1 = Flow1.root as? Root1, 36 | let root2 = Flow2.root as? Root2, 37 | let root3 = Flow3.root as? Root3 38 | else { 39 | fatalError("The Root type does not match Flow's root") 40 | } 41 | block(root1, root2, root3) 42 | } 43 | 44 | public static func use( 45 | _ Flow1: any Flow, 46 | _ Flow2: any Flow, 47 | _ Flow3: any Flow, 48 | _ Flow4: any Flow, 49 | block: @escaping (_ root1: Root1, _ root2: Root2, _ root3: Root3, _ root4: Root4) -> Void 50 | ) 51 | where 52 | Root1: UIViewController, 53 | Root2: UIViewController, 54 | Root3: UIViewController, 55 | Root4: UIViewController { 56 | guard 57 | let root1 = Flow1.root as? Root1, 58 | let root2 = Flow2.root as? Root2, 59 | let root3 = Flow3.root as? Root3, 60 | let root4 = Flow4.root as? Root4 61 | else { 62 | fatalError("The Root type does not match Flow's root") 63 | } 64 | block(root1, root2, root3, root4) 65 | } 66 | 67 | public static func use( 68 | _ Flow1: any Flow, 69 | _ Flow2: any Flow, 70 | _ Flow3: any Flow, 71 | _ Flow4: any Flow, 72 | _ Flow5: any Flow, 73 | block: @escaping (_ root1: Root1, _ root2: Root2, _ root3: Root3, _ root4: Root4, _ root5: Root5) -> Void 74 | ) where Root1: UIViewController, 75 | Root2: UIViewController, 76 | Root3: UIViewController, 77 | Root4: UIViewController, 78 | Root5: UIViewController { 79 | guard 80 | let root1 = Flow1.root as? Root1, 81 | let root2 = Flow2.root as? Root2, 82 | let root3 = Flow3.root as? Root3, 83 | let root4 = Flow4.root as? Root4, 84 | let root5 = Flow5.root as? Root5 85 | else { 86 | fatalError("The Root type does not match Flow's root") 87 | } 88 | block(root1, root2, root3, root4, root5) 89 | } 90 | 91 | public static func use( 92 | _ Flows: [some Flow], 93 | block: @escaping (_ roots: [UIViewController]) -> Void 94 | ) { 95 | let roots = Flows 96 | .compactMap { $0.root as? UIViewController } 97 | block(roots) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/CombineFlow/FlowContributor/FlowContributor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FlowContributor { 4 | case contribute( 5 | withNextPresentable: any Presentable, 6 | withNextRouter: any Stepper = DefaultStepper() 7 | ) 8 | case forwardToCurrentFlow(withStep: any Step) 9 | case forwardToParentFlow(withStep: any Step) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/CombineFlow/FlowContributor/FlowContributors.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum FlowContributors { 4 | case one(flowContributor: FlowContributor) 5 | case multiple(flowContributors: [FlowContributor]) 6 | case end(forwardToParentFlowWithStep: any Step) 7 | case none 8 | } 9 | -------------------------------------------------------------------------------- /Sources/CombineFlow/FlowCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public final class FlowCoordinator { 5 | private let id = UUID().uuidString 6 | private var bag = Set() 7 | private weak var parentFlowCoordinator: FlowCoordinator? 8 | private var childFlowCoordinators: [String: FlowCoordinator] = [:] 9 | private let stepsSubject = PassthroughSubject() 10 | 11 | public func coordinate( 12 | flow: any Flow, 13 | with stepper: any Stepper = DefaultStepper() 14 | ) { 15 | stepsSubject 16 | .append(Publishers.Merge(stepper.steps, stepsSubject)) 17 | .receive(on: RunLoop.main) 18 | .map { flow.navigate(to: $0) } 19 | .handleEvents(receiveOutput: { [weak self] contributors in 20 | switch contributors { 21 | case .none: 22 | return 23 | 24 | case let .one(contributor): 25 | self?.performSideEffect(contributor: contributor) 26 | 27 | case let .multiple(contributors): 28 | contributors.forEach { self?.performSideEffect(contributor: $0) } 29 | 30 | case let .end(path): 31 | self?.parentFlowCoordinator?.stepsSubject.send(path) 32 | self?.childFlowCoordinators.removeAll() 33 | self?.parentFlowCoordinator? 34 | .childFlowCoordinators 35 | .removeValue(forKey: self?.id ?? "") 36 | } 37 | }) 38 | .map { [weak self] in self?.nextSteppers(from: $0) ?? [] } 39 | .flatMap { $0.publisher.eraseToAnyPublisher() } 40 | .flatMap { [weak self] in self?.toSteps(from: $0) ?? Empty().eraseToAnyPublisher() } 41 | .sink { [weak self] step in 42 | self?.stepsSubject.send(step) 43 | } 44 | .store(in: &bag) 45 | 46 | stepper.steps 47 | .sink { [weak self] step in 48 | self?.stepsSubject.send(step) 49 | } 50 | .store(in: &bag) 51 | 52 | Just(stepper.initialStep) 53 | .sink { [weak self] step in 54 | self?.stepsSubject.send(step) 55 | } 56 | .store(in: &bag) 57 | } 58 | } 59 | 60 | private extension FlowCoordinator { 61 | private func performSideEffect(contributor: FlowContributor) { 62 | switch contributor { 63 | case let .contribute(presentable, router): 64 | guard let childMoordinator = presentable as? Flow else { return } 65 | let flowCoordinator = FlowCoordinator() 66 | flowCoordinator.parentFlowCoordinator = self 67 | self.childFlowCoordinators[flowCoordinator.id] = flowCoordinator 68 | flowCoordinator.coordinate(flow: childMoordinator, with: router) 69 | 70 | case let .forwardToCurrentFlow(step): 71 | self.stepsSubject.send(step) 72 | 73 | case let .forwardToParentFlow(step): 74 | self.parentFlowCoordinator?.stepsSubject.send(step) 75 | } 76 | } 77 | 78 | private func nextSteppers(from contributors: FlowContributors) -> [any Stepper] { 79 | switch contributors { 80 | case let .one(.contribute(_, stepper)): 81 | return [stepper] 82 | 83 | case let .multiple(flowContributors): 84 | return flowContributors.compactMap { 85 | if case let .contribute(_, stepper) = $0 { 86 | return stepper 87 | } 88 | return nil 89 | } 90 | 91 | default: 92 | return [] 93 | } 94 | } 95 | 96 | private func toSteps(from stepper: any Stepper) -> AnyPublisher { 97 | stepper.steps 98 | .filter { !($0 is NoneStep) } 99 | .eraseToAnyPublisher() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/CombineFlow/Presentable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol Presentable {} 4 | 5 | extension UIViewController: Presentable {} 6 | extension UIWindow: Presentable {} 7 | 8 | -------------------------------------------------------------------------------- /Sources/CombineFlow/Step/Step.swift: -------------------------------------------------------------------------------- 1 | public protocol Step {} 2 | 3 | public struct NoneStep: Step {} 4 | -------------------------------------------------------------------------------- /Sources/CombineFlow/Step/Stepper.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public protocol Stepper { 5 | var steps: PassthroughSubject { get } 6 | var initialStep: any Step { get } 7 | } 8 | 9 | public extension Stepper { 10 | var initialStep: any Step { 11 | NoneStep() 12 | } 13 | } 14 | 15 | public final class OneStepper: Stepper { 16 | public let steps: PassthroughSubject = .init() 17 | private let singleStep: any Step 18 | 19 | init(singleStep: any Step) { 20 | self.singleStep = singleStep 21 | } 22 | 23 | public var initialStep: Step { 24 | singleStep 25 | } 26 | } 27 | 28 | public final class DefaultStepper: Stepper { 29 | public let steps: PassthroughSubject = .init() 30 | 31 | public init() {} 32 | } 33 | -------------------------------------------------------------------------------- /Tests/CombineFlowTests/CombineFlowTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CombineFlow 3 | 4 | final class CombineFlowTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | } 11 | --------------------------------------------------------------------------------