Skip to content

Commit

Permalink
[APP-2869] Add Combine and SwiftUI bridges (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
moglistree committed Feb 15, 2023
1 parent 300e800 commit deccedd
Show file tree
Hide file tree
Showing 12 changed files with 726 additions and 30 deletions.
75 changes: 45 additions & 30 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
version: 2.1

anchors:
- &test_device "iPhone Xs"
- &test_device "iPhone 14"
- &test_device_os "16.2"
- &clean_before_build true
- &test_output_folder test_output
- &default_executor
macos:
xcode: "14.0.0"
xcode: "14.2.0"

env:
global:
Expand All @@ -36,11 +36,18 @@ commands:
pod install --verbose
test_main_project:
parameters:
simulator:
type: string
default: *test_device
os_version:
type: string
default: *test_device_os
steps:
- checkout
- test_project_and_store_results:
project: "Flow.xcodeproj"
scheme: "Flow"
simulator: <<parameters.simulator>>
os_version: <<parameters.os_version>>

test_example_project:
parameters:
Expand All @@ -54,30 +61,32 @@ commands:
workspace: "Example.xcworkspace"
scheme: "Example"
path: <<parameters.path>>
test_output_folder: *test_output_folder

# We introduced two separate commands for projects and workspaces because we didn't find a generic and non-confusing way to introduce
# a condition to only pass either the project or the workspace environment argument to the fastlane scan
# a condition to only pass either the project or the workspace environment argument to the test output
test_project_and_store_results:
description: "Builds and tests a project and then stores the results of the tests as artifacts and test results report"
parameters:
project:
simulator:
type: string
scheme:
default: *test_device
os_version:
type: string
default: *test_device_os
steps:
- run:
command: fastlane scan
environment:
SCAN_PROJECT: <<parameters.project>>
SCAN_SCHEME: <<parameters.scheme>>
SCAN_DEVICE: *test_device
SCAN_CLEAN: *clean_before_build
name: Run tests on iOS <<parameters.os_version>>
command: |
xcodebuild -scheme Flow \
-project Flow.xcodeproj \
-destination "platform=iOS Simulator,OS=<<parameters.os_version>>,name=<<parameters.simulator>>" \
build test \
| xcpretty --report junit --output 'test_output/report.junit'
- store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report)
path: *test_output_folder # test_output is the default temporary folder for fastlane scan output
destination: *test_output_folder # This will create a sub structure in the artifacts section in CircleCI
path: test_output # test_output is the default temporary folder for test output
destination: test_output # This will create a sub structure in the artifacts section in CircleCI
- store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report
path: *test_output_folder
path: test_output

test_workspace_and_store_results:
description: "Builds and tests a workspace and then stores the results of the tests as artifacts and test results report"
Expand All @@ -88,23 +97,27 @@ commands:
type: string
path:
type: string
test_output_folder:
simulator:
type: string
default: *test_device
os_version:
type: string
default: *test_device_os
steps:
- run:
command: |
name: Run examples
command: |
cd <<parameters.path>>
fastlane scan
environment:
SCAN_WORKSPACE: <<parameters.workspace>>
SCAN_SCHEME: <<parameters.scheme>>
SCAN_DEVICE: *test_device
SCAN_CLEAN: *clean_before_build
xcodebuild -workspace <<parameters.workspace>> \
-scheme <<parameters.scheme>> \
-destination "platform=iOS Simulator,OS=<<parameters.os_version>>,name=<<parameters.simulator>>" \
build test \
| xcpretty --report junit --output 'test_output/report.junit'
- store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report)
path: <<parameters.path>>/<<parameters.test_output_folder>> # test_output is the default temporary folder for fastlane scan output
destination: <<parameters.test_output_folder>> # This will create a sub structure in the artifacts section in CircleCI
path: <<parameters.path>>/test_output # test_output is the default temporary folder for test output
destination: test_output # This will create a sub structure in the artifacts section in CircleCI
- store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report
path: <<parameters.path>>/<<parameters.test_output_folder>>
path: <<parameters.path>>/test_output

jobs:
swiftlint:
Expand Down Expand Up @@ -136,7 +149,9 @@ jobs:
macos:
xcode: "13.0.0"
steps:
- test_main_project
- test_main_project:
simulator: "iPhone 13"
os_version: "15.0"

test-xcode14-ios16:
<<: *default_executor
Expand Down
63 changes: 63 additions & 0 deletions Bridges/CancelBag.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// CancelBag.swift
// Flow
//
// Created by Carl Ekman on 2023-02-09.
// Copyright © 2023 PayPal Inc. All rights reserved.
//

import Foundation
#if canImport(Combine)
import Combine

/// A type alias for `Set<AnyCancellable>` meant to bridge some of the patterns of `DisposeBag`
/// with modern conventions, like `store(in set: inout Set<AnyCancellable>)`.
@available(iOS 13.0, macOS 10.15, *)
public typealias CancelBag = Set<AnyCancellable>

@available(iOS 13.0, macOS 10.15, *)
extension CancelBag: Cancellable {
/// Cancel all elements in the set.
public func cancel() {
forEach { $0.cancel() }
}

/// Cancel all elements and then empty the set.
public mutating func empty() {
cancel()
removeAll()
}

/// Create a new, empty set, which is itself a part of self.
/// Corresponds to `innerBag()` for `DisposeBag`.
public mutating func subset() -> CancelBag {
let bag = CancelBag()
self.insert(AnyCancellable(bag))
return bag
}
}

@available(iOS 13.0, macOS 10.15, *)
extension CancelBag {
public init(disposable: Disposable) {
self.init([disposable.asAnyCancellable])
}

public var asAnyCancellable: AnyCancellable {
AnyCancellable(self)
}
}

@available(iOS 13.0, macOS 10.15, *)
public func += (cancelBag: inout CancelBag, cancellable: AnyCancellable?) {
if let cancellable = cancellable {
cancelBag.insert(cancellable)
}
}

@available(iOS 13.0, macOS 10.15, *)
public func += (cancelBag: inout CancelBag, cancellation: @escaping () -> Void) {
cancelBag.insert(AnyCancellable(cancellation))
}

#endif
27 changes: 27 additions & 0 deletions Bridges/Disposable+Cancellable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// Disposable+Cancellable.swift
// Flow
//
// Created by Carl Ekman on 2023-02-09.
// Copyright © 2023 PayPal Inc. All rights reserved.
//

import Foundation
#if canImport(Combine)
import Combine

@available(iOS 13.0, macOS 10.15, *)
extension Disposable {
public var asAnyCancellable: AnyCancellable {
AnyCancellable { self.dispose() }
}
}

@available(iOS 13.0, macOS 10.15, *)
extension Future {
public var cancellable: AnyCancellable {
AnyCancellable { self.disposable.dispose() }
}
}

#endif
20 changes: 20 additions & 0 deletions Bridges/Future+Combine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// Copyright © 2023 PayPal Inc. All rights reserved.
//

import Foundation
#if canImport(Combine)
import Combine

@available(iOS 13.0, macOS 10.15, *)
extension Flow.Future {
/// Convert a `Flow.Future<Value>` to a `Combine.Future<Value, Error>` intended to be
/// used to bridge between the `Flow` and `Combine` world
public var toCombineFuture: Combine.Future<Value, Error> {
Combine.Future { promise in
self.onResult { promise($0) }
}
}
}

#endif
59 changes: 59 additions & 0 deletions Bridges/Publisher+Utilities.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// Callbacker+Combine.swift
// Flow
//
// Created by Carl Ekman on 2023-02-09.
// Copyright © 2023 PayPal Inc. All rights reserved.
//

import Foundation
#if canImport(Combine)
import Combine

@available(iOS 13.0, macOS 10.15, *)
public extension Publisher {
/// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value
/// will be automatically cancelled once a new value is published. Completion will cancel the last cancellable as well.
///
/// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`.
func autosink(
receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void),
receiveValue: @escaping ((Self.Output) -> AnyCancellable)
) -> AnyCancellable {
var bag = CancelBag()
var subBag = bag.subset()

bag += sink(receiveCompletion: { completion in
subBag.cancel()
receiveCompletion(completion)
}, receiveValue: { value in
subBag.cancel()
subBag += receiveValue(value)
})

return bag.asAnyCancellable
}
}

@available(iOS 13.0, macOS 10.15, *)
public extension Publisher where Self.Failure == Never {
/// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value
/// will be automatically cancelled once a new value is published, for publishers that never fail.
///
/// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`.
func autosink(
receiveValue: @escaping ((Self.Output) -> AnyCancellable)
) -> AnyCancellable {
var bag = CancelBag()
var subBag = bag.subset()

bag += sink { value in
subBag.cancel()
subBag += receiveValue(value)
}

return bag.asAnyCancellable
}
}

#endif
91 changes: 91 additions & 0 deletions Bridges/Signal+Combine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// Copyright © 2023 PayPal Inc. All rights reserved.
//

import Foundation
#if canImport(Combine)
import Combine

extension CoreSignal {
@available(iOS 13.0, macOS 10.15, *)
final class SignalPublisher: Publisher, Cancellable {
typealias Output = Value
typealias Failure = Error

internal var signal: CoreSignal<Kind, Value>
internal var bag: CancelBag

init(signal: CoreSignal<Kind, Value>) {
self.signal = signal
self.bag = []
}

func receive<S>(
subscriber: S
) where S : Subscriber, Failure == S.Failure, Value == S.Input {
// Creating our custom subscription instance:
let subscription = EventSubscription<S>()
subscription.target = subscriber

// Attaching our subscription to the subscriber:
subscriber.receive(subscription: subscription)

// Collect cancellables when attaching to signal
bag += signal
.onValue { subscription.trigger(for: $0) }
.asAnyCancellable

if let finiteVersion = signal as? FiniteSignal<Value> {
bag += finiteVersion.onEvent { event in
if case let .end(error) = event {
if let error = error {
subscription.end(with: error)
} else {
subscription.end()
}
}
}.asAnyCancellable
}
}

func cancel() {
bag.cancel()
}

deinit {
cancel()
}
}

@available(iOS 13.0, macOS 10.15, *)
final class EventSubscription<Target: Subscriber>: Subscription
where Target.Input == Value {

var target: Target?

func request(_ demand: Subscribers.Demand) {}

func cancel() {
target = nil
}

func end(with error: Target.Failure? = nil) {
if let error = error {
_ = target?.receive(completion: .failure(error))
} else {
_ = target?.receive(completion: .finished)
}
}

func trigger(for value: Value) {
_ = target?.receive(value)
}
}

@available(iOS 13.0, macOS 10.15, *)
public var asAnyPublisher: AnyPublisher<Value, Error> {
SignalPublisher(signal: self).eraseToAnyPublisher()
}
}

#endif
Loading

0 comments on commit deccedd

Please sign in to comment.