Skip to content

Commit 2ee87e1

Browse files
authored
Merge pull request #2191 from woocommerce/issue/927-observables
Add PublishSubject observable
2 parents 97f0582 + 478ff3e commit 2ee87e1

File tree

6 files changed

+475
-1
lines changed

6 files changed

+475
-1
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
import Foundation
3+
4+
/// Signature of a block called for every emitted Observable value.
5+
///
6+
typealias OnNext<Element> = (Element) -> ()
7+
8+
/// Emits values over time.
9+
///
10+
/// Acts like `Publisher` in Combine and `Observable` in ReactiveX.
11+
///
12+
/// This class is a pseudo-abstract class. It does not do anything on its own. Use the
13+
/// subclasses like `PublishSubject` instead.
14+
///
15+
/// See here for more info about Observables:
16+
///
17+
/// - https://developer.apple.com/documentation/combine/publisher
18+
/// - http://reactivex.io/documentation/observable.html
19+
///
20+
class Observable<Element> {
21+
/// Subscribe to values emitted by this `Observable`.
22+
///
23+
/// The given `onNext` is called a "Observer" or "Subscriber".
24+
///
25+
/// Example:
26+
///
27+
/// ```
28+
/// class ViewModel {
29+
/// let onDataLoaded: Observable<[Item]>
30+
/// }
31+
///
32+
/// func viewDidLoad() {
33+
/// viewModel.onDataLoaded.subscribe { items in
34+
/// // do something with `items`
35+
/// tableView.reloadData()
36+
/// }
37+
/// }
38+
/// ```
39+
///
40+
func subscribe(_ onNext: @escaping OnNext<Element>) -> ObservationToken {
41+
fatalError("Abstract method. This must be implemented by subclasses.")
42+
}
43+
}

WooCommerce/Classes/Tools/ObservationToken.swift renamed to WooCommerce/Classes/Tools/Observables/ObservationToken.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
/// An observation token that contains a closure when the observation is cancelled.
2+
///
3+
/// This acts like `AnyCancellable` in Combine and `IDisposable` in ReactiveX.
4+
///
25
/// Example usage can be found in `ProductImageActionHandler`.
36
///
7+
/// See:
8+
///
9+
/// - https://developer.apple.com/documentation/combine/anycancellable
10+
///
411
final class ObservationToken {
512
private let onCancel: () -> Void
613

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
2+
import Foundation
3+
4+
/// A subscription to an `Observable`.
5+
///
6+
/// Acts like `Subscriber` in Combine and `IObserver` in ReactiveX.
7+
///
8+
/// Observers are simply the callbacks when you subscribe to an `Observable`. Consider this:
9+
///
10+
/// ```
11+
/// viewModel.onDataLoaded.subscribe { items in
12+
/// /// do something
13+
/// }
14+
/// ```
15+
///
16+
/// The block passed to the `subscribe` method with the "do something" comment is the `Observer`.
17+
///
18+
/// Currently, this `struct` is simply a container to clarify these concepts. In other frameworks,
19+
/// an `Observer` can have more callbacks like `onCompleted`.
20+
///
21+
/// See these for more info about Observers:
22+
///
23+
/// - https://developer.apple.com/documentation/combine/subscriber
24+
/// - http://introtorx.com/Content/v1.0.10621.0/02_KeyTypes.html#IObserver
25+
///
26+
struct Observer<Element> {
27+
private let onNext: (Element) -> ()
28+
29+
init(onNext: @escaping OnNext<Element>) {
30+
self.onNext = onNext
31+
}
32+
33+
/// Send the given value to the observer.
34+
///
35+
func send(_ element: Element) {
36+
onNext(element)
37+
}
38+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
2+
import Foundation
3+
4+
/// Emits values to observers as soon as the values arrive. Only the values emitted after the
5+
/// subscription will be emitted.
6+
///
7+
/// Acts like `PassthroughSubject` in Combine and `PublishSubject` in ReactiveX.
8+
///
9+
/// This observable is a bridge between the imperative and reactive programming paradigms. It
10+
/// allows consumers to _manually_ emit values using the `send()` method.
11+
///
12+
/// Multiple observers are allowed which makes this a possible replacement for
13+
/// `NSNotificationCenter` observations.
14+
///
15+
/// ## Example Usage
16+
///
17+
/// In a class that you would like to emit values (or events), add the `PublishSubject` defining
18+
/// the value type:
19+
///
20+
/// ```
21+
/// class PostListViewModel {
22+
/// /// Calls observers/subscribers whenever the list of Post changes.
23+
/// private let postsSubject = PublishSubject<[Post]>()
24+
/// }
25+
/// ```
26+
///
27+
/// Since `PublishSubject` exposes `send()` which makes this a **mutable** Observable, we recommend
28+
/// exposing only the `Observable<[Post]>` interface:
29+
///
30+
/// ```
31+
/// class PostListViewModel {
32+
/// private let postsSubject = PublishSubject<[Post]>()
33+
///
34+
/// /// The public Observable that the ViewController will subscribe to
35+
/// var posts: Observable<[Post]> {
36+
/// postsSubject
37+
/// }
38+
/// }
39+
/// ```
40+
///
41+
/// The `ViewController` can then subscribe to the `posts` Observable:
42+
///
43+
/// ```
44+
/// func viewDidLoad() {
45+
/// viewModel.posts.subscribe { posts in
46+
/// // do something with posts
47+
/// tableView.reloadData()
48+
/// }
49+
/// }
50+
/// ```
51+
///
52+
/// Whenever the list of post changes, like after fetching from the API, the `ViewModel` can
53+
/// _notify_ the `ViewController` by updating `postsSubject`:
54+
///
55+
/// ```
56+
/// fetchFromAPI { fetchedPosts
57+
/// // Notify the observers (e.g. ViewController) that the list of posts have changed
58+
/// postsSubject.send(fetchedPosts)
59+
/// }
60+
/// ```
61+
///
62+
/// ## References
63+
///
64+
/// See here for info about similar observables in other frameworks:
65+
///
66+
/// - https://developer.apple.com/documentation/combine/passthroughsubject
67+
/// - http://reactivex.io/documentation/subject.html
68+
///
69+
final class PublishSubject<Element>: Observable<Element> {
70+
71+
private typealias OnCancel = () -> ()
72+
73+
/// The list of Observers that will be notified when a new value is sent.
74+
///
75+
private var observers = [UUID: Observer<Element>]()
76+
77+
override func subscribe(_ onNext: @escaping OnNext<Element>) -> ObservationToken {
78+
let uuid = UUID()
79+
80+
observers[uuid] = Observer(onNext: onNext)
81+
82+
let onCancel: OnCancel = { [weak self] in
83+
self?.observers.removeValue(forKey: uuid)
84+
}
85+
86+
return ObservationToken(onCancel: onCancel)
87+
}
88+
89+
/// Emit a new value. All observers are immediately called with the given value.
90+
///
91+
func send(_ element: Element) {
92+
observers.values.forEach { observer in
93+
observer.send(element)
94+
}
95+
}
96+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,14 @@
308308
45FBDF3C238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBDF3B238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift */; };
309309
57448D28242E775000A56A74 /* EmptySearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57448D27242E775000A56A74 /* EmptySearchResultsViewController.swift */; };
310310
57448D2A242E777700A56A74 /* EmptySearchResultsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57448D29242E777700A56A74 /* EmptySearchResultsViewController.xib */; };
311+
5754727B2451F14600A94C3C /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5754727A2451F14600A94C3C /* Observable.swift */; };
312+
5754727D2451F1D800A94C3C /* PublishSubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5754727C2451F1D800A94C3C /* PublishSubject.swift */; };
313+
5754727F24520B2A00A94C3C /* Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5754727E24520B2A00A94C3C /* Observer.swift */; };
311314
576F92222423C3C0003E5FEF /* OrdersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 576F92212423C3C0003E5FEF /* OrdersViewModel.swift */; };
312315
5795F22C23E26A8D00F6707C /* OrderSearchStarterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5795F22B23E26A8D00F6707C /* OrderSearchStarterViewController.xib */; };
313316
5795F22E23E26A9E00F6707C /* OrderSearchStarterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5795F22D23E26A9E00F6707C /* OrderSearchStarterViewController.swift */; };
314317
5795F23023E26B5300F6707C /* OrderSearchStarterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5795F22F23E26B5300F6707C /* OrderSearchStarterViewModel.swift */; };
318+
5798191324526FE8000817F8 /* PublishSubjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5798191224526FE8000817F8 /* PublishSubjectTests.swift */; };
315319
57AE0B0123ECD6D400237009 /* OrdersViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57AE0B0023ECD6D400237009 /* OrdersViewController.xib */; };
316320
57C503DC23E8C70C00EC0790 /* OrdersMasterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C503DB23E8C70C00EC0790 /* OrdersMasterViewController.swift */; };
317321
57C503DE23E8CE0D00EC0790 /* OrdersMasterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57C503DD23E8CE0D00EC0790 /* OrdersMasterViewController.xib */; };
@@ -1124,10 +1128,14 @@
11241128
45FBDF3B238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedAddProductImageCollectionViewCellTests.swift; sourceTree = "<group>"; };
11251129
57448D27242E775000A56A74 /* EmptySearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultsViewController.swift; sourceTree = "<group>"; };
11261130
57448D29242E777700A56A74 /* EmptySearchResultsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = EmptySearchResultsViewController.xib; sourceTree = "<group>"; };
1131+
5754727A2451F14600A94C3C /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
1132+
5754727C2451F1D800A94C3C /* PublishSubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishSubject.swift; sourceTree = "<group>"; };
1133+
5754727E24520B2A00A94C3C /* Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observer.swift; sourceTree = "<group>"; };
11271134
576F92212423C3C0003E5FEF /* OrdersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersViewModel.swift; sourceTree = "<group>"; };
11281135
5795F22B23E26A8D00F6707C /* OrderSearchStarterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OrderSearchStarterViewController.xib; sourceTree = "<group>"; };
11291136
5795F22D23E26A9E00F6707C /* OrderSearchStarterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderSearchStarterViewController.swift; sourceTree = "<group>"; };
11301137
5795F22F23E26B5300F6707C /* OrderSearchStarterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderSearchStarterViewModel.swift; sourceTree = "<group>"; };
1138+
5798191224526FE8000817F8 /* PublishSubjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishSubjectTests.swift; sourceTree = "<group>"; };
11311139
57AE0B0023ECD6D400237009 /* OrdersViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OrdersViewController.xib; sourceTree = "<group>"; };
11321140
57C503DB23E8C70C00EC0790 /* OrdersMasterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersMasterViewController.swift; sourceTree = "<group>"; };
11331141
57C503DD23E8CE0D00EC0790 /* OrdersMasterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OrdersMasterViewController.xib; sourceTree = "<group>"; };
@@ -2338,6 +2346,25 @@
23382346
path = EmptySearchResultsViewController;
23392347
sourceTree = "<group>";
23402348
};
2349+
575472792451F0A500A94C3C /* Observables */ = {
2350+
isa = PBXGroup;
2351+
children = (
2352+
027B8BB923FE0D0C0040944E /* ObservationToken.swift */,
2353+
5754727A2451F14600A94C3C /* Observable.swift */,
2354+
5754727C2451F1D800A94C3C /* PublishSubject.swift */,
2355+
5754727E24520B2A00A94C3C /* Observer.swift */,
2356+
);
2357+
path = Observables;
2358+
sourceTree = "<group>";
2359+
};
2360+
5798191124526FC7000817F8 /* Observables */ = {
2361+
isa = PBXGroup;
2362+
children = (
2363+
5798191224526FE8000817F8 /* PublishSubjectTests.swift */,
2364+
);
2365+
path = Observables;
2366+
sourceTree = "<group>";
2367+
};
23412368
57F34A9F2423D44000E38AFB /* Orders */ = {
23422369
isa = PBXGroup;
23432370
children = (
@@ -2504,6 +2531,7 @@
25042531
B53A569521123D27000776C9 /* Tools */ = {
25052532
isa = PBXGroup;
25062533
children = (
2534+
5798191124526FC7000817F8 /* Observables */,
25072535
D8A8C4F22268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift */,
25082536
D83F5938225B424B00626E75 /* AddManualTrackingViewModelTests.swift */,
25092537
CECC759823D6160000486676 /* AggregateDataHelperTests.swift */,
@@ -2600,6 +2628,7 @@
26002628
B55D4C2220B716CE00D7A50F /* Tools */ = {
26012629
isa = PBXGroup;
26022630
children = (
2631+
575472792451F0A500A94C3C /* Observables */,
26032632
CECC759A23D61BCC00486676 /* AggregateData */,
26042633
02C0CD2823B5BAFB00F880B1 /* ImageService */,
26052634
CEEC9B6121E79EBF0055EEF0 /* AppRatings */,
@@ -2620,7 +2649,6 @@
26202649
B55D4C2620B717C000D7A50F /* UserAgent.swift */,
26212650
CE22709E2293052700C0626C /* WebviewHelper.swift */,
26222651
45D685FD23D0FB25005F87D0 /* Throttler.swift */,
2623-
027B8BB923FE0D0C0040944E /* ObservationToken.swift */,
26242652
);
26252653
path = Tools;
26262654
sourceTree = "<group>";
@@ -4531,10 +4559,12 @@
45314559
74F9E9CE214C036400A3F2D2 /* NoPeriodDataTableViewCell.swift in Sources */,
45324560
B50911302049E27A007D25DC /* DashboardViewController.swift in Sources */,
45334561
933A27372222354600C2143A /* Logging.swift in Sources */,
4562+
5754727D2451F1D800A94C3C /* PublishSubject.swift in Sources */,
45344563
0290E26F238E3CE400B5C466 /* ListSelectorViewController.swift in Sources */,
45354564
B5D1AFB820BC510200DB0E8C /* UIImage+Woo.swift in Sources */,
45364565
B5980A6121AC878900EBF596 /* UIDevice+Woo.swift in Sources */,
45374566
02521E11243DC3C400DC7810 /* CancellableMedia.swift in Sources */,
4567+
5754727F24520B2A00A94C3C /* Observer.swift in Sources */,
45384568
02404EDC2314CD3600FF1170 /* StatsV4ToV3BannerActionHandler.swift in Sources */,
45394569
027B8BBA23FE0D0C0040944E /* ObservationToken.swift in Sources */,
45404570
B517EA18218B232700730EC4 /* StringFormatter+Notes.swift in Sources */,
@@ -4644,6 +4674,7 @@
46444674
B541B223218A29A6008FE7C1 /* NSParagraphStyle+Woo.swift in Sources */,
46454675
B50BB4162141828F00AF0F3C /* FooterSpinnerView.swift in Sources */,
46464676
02FE89C9231FB31400E85EF8 /* FeatureFlagService.swift in Sources */,
4677+
5754727B2451F14600A94C3C /* Observable.swift in Sources */,
46474678
B5980A6321AC879F00EBF596 /* Bundle+Woo.swift in Sources */,
46484679
B59D1EE5219080B4009D1978 /* Note+Woo.swift in Sources */,
46494680
02913E9523A774C500707A0C /* UnitInputFormatter.swift in Sources */,
@@ -4754,6 +4785,7 @@
47544785
027B8BBD23FE0DE10040944E /* ProductImageActionHandlerTests.swift in Sources */,
47554786
B5980A6521AC905C00EBF596 /* UIDeviceWooTests.swift in Sources */,
47564787
45EF798624509B4C00B22BA2 /* ArrayIndexPathTests.swift in Sources */,
4788+
5798191324526FE8000817F8 /* PublishSubjectTests.swift in Sources */,
47574789
746791632108D7C0007CF1DC /* WooAnalyticsTests.swift in Sources */,
47584790
021FAFCD2355621E00B99241 /* UIView+SubviewsAxisTests.swift in Sources */,
47594791
74F3015A2200EC0800931B9E /* NSDecimalNumberWooTests.swift in Sources */,

0 commit comments

Comments
 (0)