Skip to content

Commit deccedd

Browse files
committed
[APP-2869] Add Combine and SwiftUI bridges (#125)
1 parent 300e800 commit deccedd

12 files changed

+726
-30
lines changed

.circleci/config.yml

+45-30
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
version: 2.1
55

66
anchors:
7-
- &test_device "iPhone Xs"
7+
- &test_device "iPhone 14"
8+
- &test_device_os "16.2"
89
- &clean_before_build true
9-
- &test_output_folder test_output
1010
- &default_executor
1111
macos:
12-
xcode: "14.0.0"
12+
xcode: "14.2.0"
1313

1414
env:
1515
global:
@@ -36,11 +36,18 @@ commands:
3636
pod install --verbose
3737
3838
test_main_project:
39+
parameters:
40+
simulator:
41+
type: string
42+
default: *test_device
43+
os_version:
44+
type: string
45+
default: *test_device_os
3946
steps:
4047
- checkout
4148
- test_project_and_store_results:
42-
project: "Flow.xcodeproj"
43-
scheme: "Flow"
49+
simulator: <<parameters.simulator>>
50+
os_version: <<parameters.os_version>>
4451

4552
test_example_project:
4653
parameters:
@@ -54,30 +61,32 @@ commands:
5461
workspace: "Example.xcworkspace"
5562
scheme: "Example"
5663
path: <<parameters.path>>
57-
test_output_folder: *test_output_folder
5864

5965
# We introduced two separate commands for projects and workspaces because we didn't find a generic and non-confusing way to introduce
60-
# a condition to only pass either the project or the workspace environment argument to the fastlane scan
66+
# a condition to only pass either the project or the workspace environment argument to the test output
6167
test_project_and_store_results:
6268
description: "Builds and tests a project and then stores the results of the tests as artifacts and test results report"
6369
parameters:
64-
project:
70+
simulator:
6571
type: string
66-
scheme:
72+
default: *test_device
73+
os_version:
6774
type: string
75+
default: *test_device_os
6876
steps:
6977
- run:
70-
command: fastlane scan
71-
environment:
72-
SCAN_PROJECT: <<parameters.project>>
73-
SCAN_SCHEME: <<parameters.scheme>>
74-
SCAN_DEVICE: *test_device
75-
SCAN_CLEAN: *clean_before_build
78+
name: Run tests on iOS <<parameters.os_version>>
79+
command: |
80+
xcodebuild -scheme Flow \
81+
-project Flow.xcodeproj \
82+
-destination "platform=iOS Simulator,OS=<<parameters.os_version>>,name=<<parameters.simulator>>" \
83+
build test \
84+
| xcpretty --report junit --output 'test_output/report.junit'
7685
- store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report)
77-
path: *test_output_folder # test_output is the default temporary folder for fastlane scan output
78-
destination: *test_output_folder # This will create a sub structure in the artifacts section in CircleCI
86+
path: test_output # test_output is the default temporary folder for test output
87+
destination: test_output # This will create a sub structure in the artifacts section in CircleCI
7988
- store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report
80-
path: *test_output_folder
89+
path: test_output
8190

8291
test_workspace_and_store_results:
8392
description: "Builds and tests a workspace and then stores the results of the tests as artifacts and test results report"
@@ -88,23 +97,27 @@ commands:
8897
type: string
8998
path:
9099
type: string
91-
test_output_folder:
100+
simulator:
92101
type: string
102+
default: *test_device
103+
os_version:
104+
type: string
105+
default: *test_device_os
93106
steps:
94107
- run:
95-
command: |
108+
name: Run examples
109+
command: |
96110
cd <<parameters.path>>
97-
fastlane scan
98-
environment:
99-
SCAN_WORKSPACE: <<parameters.workspace>>
100-
SCAN_SCHEME: <<parameters.scheme>>
101-
SCAN_DEVICE: *test_device
102-
SCAN_CLEAN: *clean_before_build
111+
xcodebuild -workspace <<parameters.workspace>> \
112+
-scheme <<parameters.scheme>> \
113+
-destination "platform=iOS Simulator,OS=<<parameters.os_version>>,name=<<parameters.simulator>>" \
114+
build test \
115+
| xcpretty --report junit --output 'test_output/report.junit'
103116
- store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report)
104-
path: <<parameters.path>>/<<parameters.test_output_folder>> # test_output is the default temporary folder for fastlane scan output
105-
destination: <<parameters.test_output_folder>> # This will create a sub structure in the artifacts section in CircleCI
117+
path: <<parameters.path>>/test_output # test_output is the default temporary folder for test output
118+
destination: test_output # This will create a sub structure in the artifacts section in CircleCI
106119
- store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report
107-
path: <<parameters.path>>/<<parameters.test_output_folder>>
120+
path: <<parameters.path>>/test_output
108121

109122
jobs:
110123
swiftlint:
@@ -136,7 +149,9 @@ jobs:
136149
macos:
137150
xcode: "13.0.0"
138151
steps:
139-
- test_main_project
152+
- test_main_project:
153+
simulator: "iPhone 13"
154+
os_version: "15.0"
140155

141156
test-xcode14-ios16:
142157
<<: *default_executor

Bridges/CancelBag.swift

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// CancelBag.swift
3+
// Flow
4+
//
5+
// Created by Carl Ekman on 2023-02-09.
6+
// Copyright © 2023 PayPal Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
#if canImport(Combine)
11+
import Combine
12+
13+
/// A type alias for `Set<AnyCancellable>` meant to bridge some of the patterns of `DisposeBag`
14+
/// with modern conventions, like `store(in set: inout Set<AnyCancellable>)`.
15+
@available(iOS 13.0, macOS 10.15, *)
16+
public typealias CancelBag = Set<AnyCancellable>
17+
18+
@available(iOS 13.0, macOS 10.15, *)
19+
extension CancelBag: Cancellable {
20+
/// Cancel all elements in the set.
21+
public func cancel() {
22+
forEach { $0.cancel() }
23+
}
24+
25+
/// Cancel all elements and then empty the set.
26+
public mutating func empty() {
27+
cancel()
28+
removeAll()
29+
}
30+
31+
/// Create a new, empty set, which is itself a part of self.
32+
/// Corresponds to `innerBag()` for `DisposeBag`.
33+
public mutating func subset() -> CancelBag {
34+
let bag = CancelBag()
35+
self.insert(AnyCancellable(bag))
36+
return bag
37+
}
38+
}
39+
40+
@available(iOS 13.0, macOS 10.15, *)
41+
extension CancelBag {
42+
public init(disposable: Disposable) {
43+
self.init([disposable.asAnyCancellable])
44+
}
45+
46+
public var asAnyCancellable: AnyCancellable {
47+
AnyCancellable(self)
48+
}
49+
}
50+
51+
@available(iOS 13.0, macOS 10.15, *)
52+
public func += (cancelBag: inout CancelBag, cancellable: AnyCancellable?) {
53+
if let cancellable = cancellable {
54+
cancelBag.insert(cancellable)
55+
}
56+
}
57+
58+
@available(iOS 13.0, macOS 10.15, *)
59+
public func += (cancelBag: inout CancelBag, cancellation: @escaping () -> Void) {
60+
cancelBag.insert(AnyCancellable(cancellation))
61+
}
62+
63+
#endif

Bridges/Disposable+Cancellable.swift

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Disposable+Cancellable.swift
3+
// Flow
4+
//
5+
// Created by Carl Ekman on 2023-02-09.
6+
// Copyright © 2023 PayPal Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
#if canImport(Combine)
11+
import Combine
12+
13+
@available(iOS 13.0, macOS 10.15, *)
14+
extension Disposable {
15+
public var asAnyCancellable: AnyCancellable {
16+
AnyCancellable { self.dispose() }
17+
}
18+
}
19+
20+
@available(iOS 13.0, macOS 10.15, *)
21+
extension Future {
22+
public var cancellable: AnyCancellable {
23+
AnyCancellable { self.disposable.dispose() }
24+
}
25+
}
26+
27+
#endif

Bridges/Future+Combine.swift

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// Copyright © 2023 PayPal Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
#if canImport(Combine)
7+
import Combine
8+
9+
@available(iOS 13.0, macOS 10.15, *)
10+
extension Flow.Future {
11+
/// Convert a `Flow.Future<Value>` to a `Combine.Future<Value, Error>` intended to be
12+
/// used to bridge between the `Flow` and `Combine` world
13+
public var toCombineFuture: Combine.Future<Value, Error> {
14+
Combine.Future { promise in
15+
self.onResult { promise($0) }
16+
}
17+
}
18+
}
19+
20+
#endif

Bridges/Publisher+Utilities.swift

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// Callbacker+Combine.swift
3+
// Flow
4+
//
5+
// Created by Carl Ekman on 2023-02-09.
6+
// Copyright © 2023 PayPal Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
#if canImport(Combine)
11+
import Combine
12+
13+
@available(iOS 13.0, macOS 10.15, *)
14+
public extension Publisher {
15+
/// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value
16+
/// will be automatically cancelled once a new value is published. Completion will cancel the last cancellable as well.
17+
///
18+
/// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`.
19+
func autosink(
20+
receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void),
21+
receiveValue: @escaping ((Self.Output) -> AnyCancellable)
22+
) -> AnyCancellable {
23+
var bag = CancelBag()
24+
var subBag = bag.subset()
25+
26+
bag += sink(receiveCompletion: { completion in
27+
subBag.cancel()
28+
receiveCompletion(completion)
29+
}, receiveValue: { value in
30+
subBag.cancel()
31+
subBag += receiveValue(value)
32+
})
33+
34+
return bag.asAnyCancellable
35+
}
36+
}
37+
38+
@available(iOS 13.0, macOS 10.15, *)
39+
public extension Publisher where Self.Failure == Never {
40+
/// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value
41+
/// will be automatically cancelled once a new value is published, for publishers that never fail.
42+
///
43+
/// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`.
44+
func autosink(
45+
receiveValue: @escaping ((Self.Output) -> AnyCancellable)
46+
) -> AnyCancellable {
47+
var bag = CancelBag()
48+
var subBag = bag.subset()
49+
50+
bag += sink { value in
51+
subBag.cancel()
52+
subBag += receiveValue(value)
53+
}
54+
55+
return bag.asAnyCancellable
56+
}
57+
}
58+
59+
#endif

Bridges/Signal+Combine.swift

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//
2+
// Copyright © 2023 PayPal Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
#if canImport(Combine)
7+
import Combine
8+
9+
extension CoreSignal {
10+
@available(iOS 13.0, macOS 10.15, *)
11+
final class SignalPublisher: Publisher, Cancellable {
12+
typealias Output = Value
13+
typealias Failure = Error
14+
15+
internal var signal: CoreSignal<Kind, Value>
16+
internal var bag: CancelBag
17+
18+
init(signal: CoreSignal<Kind, Value>) {
19+
self.signal = signal
20+
self.bag = []
21+
}
22+
23+
func receive<S>(
24+
subscriber: S
25+
) where S : Subscriber, Failure == S.Failure, Value == S.Input {
26+
// Creating our custom subscription instance:
27+
let subscription = EventSubscription<S>()
28+
subscription.target = subscriber
29+
30+
// Attaching our subscription to the subscriber:
31+
subscriber.receive(subscription: subscription)
32+
33+
// Collect cancellables when attaching to signal
34+
bag += signal
35+
.onValue { subscription.trigger(for: $0) }
36+
.asAnyCancellable
37+
38+
if let finiteVersion = signal as? FiniteSignal<Value> {
39+
bag += finiteVersion.onEvent { event in
40+
if case let .end(error) = event {
41+
if let error = error {
42+
subscription.end(with: error)
43+
} else {
44+
subscription.end()
45+
}
46+
}
47+
}.asAnyCancellable
48+
}
49+
}
50+
51+
func cancel() {
52+
bag.cancel()
53+
}
54+
55+
deinit {
56+
cancel()
57+
}
58+
}
59+
60+
@available(iOS 13.0, macOS 10.15, *)
61+
final class EventSubscription<Target: Subscriber>: Subscription
62+
where Target.Input == Value {
63+
64+
var target: Target?
65+
66+
func request(_ demand: Subscribers.Demand) {}
67+
68+
func cancel() {
69+
target = nil
70+
}
71+
72+
func end(with error: Target.Failure? = nil) {
73+
if let error = error {
74+
_ = target?.receive(completion: .failure(error))
75+
} else {
76+
_ = target?.receive(completion: .finished)
77+
}
78+
}
79+
80+
func trigger(for value: Value) {
81+
_ = target?.receive(value)
82+
}
83+
}
84+
85+
@available(iOS 13.0, macOS 10.15, *)
86+
public var asAnyPublisher: AnyPublisher<Value, Error> {
87+
SignalPublisher(signal: self).eraseToAnyPublisher()
88+
}
89+
}
90+
91+
#endif

0 commit comments

Comments
 (0)