Pharos is an Observer pattern framework for Swift that utilizes propertyWrapper. It could help a lot when designing Apps using reactive programming. Under the hood, it utilizes Chary as DispatchQueue utilities
Example
To run the example project, clone the repo, and run pod install from the Example directory first.
Requirements
Swift 5.0 or higher (or 5.5 when using Swift Package Manager)
iOS 12.0 or higher
Only Swift Package Manager
macOS 12.0 or higher
tvOS 12.0 or higher
Installation
Cocoapods
Pharos is available through CocoaPods. To install
it, simply add the following line to your Podfile:
pod 'Pharos'
Swift Package Manager from XCode
Add it using XCode menu File > Swift Package > Add Package Dependency
Pharos is available under the MIT license. See the LICENSE file for more info.
Basic Usage
All you need is a property that you want to observe and add @Subject propertyWrapper at it:
class MyClass {
@Subject var text: String?
}
to observe any changes that happen in the text, use its projectedValue to get its Observable`. and pass the closure subscriber:
class MyClass {
@Subject var text: String?
func observeText() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
}
}
every time any set happens in text, it will call the closure with its changes which include old value and new value.
You could ignore any set that does not change the value as long the value is Equatable
class MyClass {
@Subject var text: String?
func observeText() {
$text.distinct()
.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
}
}
if you want the observer to run using the current value, just fire it:
class MyClass {
@Subject var text: String?
func observeText() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
.fire()
}
}
if you want to ignore observing the old value, use observe instead:
class MyClass {
@Subject var text: String?
func observeText() {
$text.observe { newValue in
print(newValue)
}.retain()
}
}
you can always check the current value by accessing the observable property:
class MyClass {
@Subject var text: String = "my text"
func printCurrentText() {
print(text)
}
}
Control Subscriber Retaining
By default, if you observe Observable and end it with retain(). The closure will be retained by the Observable itself. It will automatically be removed by ARC if the Observable is removed by ARC.
If you want to retain the closure with a custom object, you could always do something like this:
class MyClass {
@Subject var text: String?
func observeText() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retained(by: self)
}
}
In the example above, the closure will be retained by the MyClass instance and will be removed if the instance is removed by ARC.
If you want to handle the retaining manually, you could always use Retainer to retain the observer:
class MyClass {
@Subject var text: String?
var retainer: Retainer = .init()
func observeText() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retained(by: retainer)
}
func discardManually() {
retainer.discardAllRetained()
}
func discardByCreateNewRetainer() {
retainer = .init()
}
}
There are many ways to discard the subscriber managed by Retainer:
call discardAllRetained() from subscriber’s retainer
replace the retainer with a new one, which will trigger ARC to remove the retainer from memory and thus will discard all of its managed subscribers by default.
doing nothing, which if the object that has a retainer is discarded by ARC, it will automatically discard the Retainer and thus will discard all of its managed subscribers by default.
You can always control how long you want to retain by using various retain methods:
class MyClass {
@Subject var text: String?
var retainer: Retainer = .init()
func observeTextOnce() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retainUntilNextState()
}
func observeTextTenTimes() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retainUntil(nextEventCount: 10)
}
func observeTextForOneMinutes() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retain(for: 60)
}
func observeTextUntilFoundMatches() {
$text.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retainUntil {
$0.new == "found!"
}
}
}
Use this retain capability wisely, since if you’re not aware of how the ARC work it can introduce retain cycle.
UIControl
You can observe an event in UIControl as long as in iOS by calling observeEventChange, or by using whenDidTriggered(by:) if you want to observe a specific event or for more specific whenDidTapped for touchUpInside event:
myButton.observeEventChange { changes in
print("new event: \(changes.new) form old event: \(changes.old)")
}.retain()
myButton.whenDidTriggered(by: .touchDown) { _ in
print("someone touch down on this button")
}.retain()
myButton.whenDidTapped { _ in
print("someone touch up on this button")
}.retain()
Bindable
You can observe changes in the supported UIView property by accessing its observables in bindables:
class MyClass {
var textField: UITextField = .init()
func observeText() {
textField.bindables.text.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
}
}
you can always bind two Observables to notify each other:
class MyClass {
var textField: UITextField = .init()
@Subject var text: String?
func observeText() {
$text.bind(with: textField.bindables.text)
.retain()
}
}
In the example above, every time text is set, it will automatically set the textField.text, and when textField.text is set it will automatically set the text.
Filtering Subscription
You can filter value by passing a closure that returns the Bool value which indicated that value should be ignored:
class MyClass {
@Subject var text: String
func observeText() {
$text.ignore { $0.isEmpty }
.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
}
}
In the example above, observeChange closure will not run when the new value is empty
The opposite of ignore is filter
class MyClass {
@Subject var text: String
func observeText() {
$text.filter { $0.count > 5 }
.observeChange { changes in
print(changes.new)
print(changes.old)
}.retain()
}
}
In the example above, observeChange closure will only run when the new value is bigger than 5
Throttling
Sometimes you just want to delay some observing because if the value is coming too fast, it could bottleneck some of your business logic like when you call API or something. It will automatically use the latest value when the closure fire:
class MyClass {
@Subject var text: String?
func observeText() {
$text.throttled(by: 1)
.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retain()
}
func test() {
text = "this will trigger the observable and block observer for next 1 second"
text = "this will be stored in pending but will be replaced by next set"
text = "this will be stored in pending but will be replaced by next set too"
text = "this will be stored in pending and be used at next 1 second"
}
}
Add DispatchQueue
You could add DispatchQueue to make sure your observable is run on the right thread. If DispatchQueue is not provided, it will use the thread from the notifier:
class MyClass {
@Subject var text: String?
func observeText() {
$text.dispatch(on: .main)
.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retain()
}
}
It will make all the subscribers after this dispatch call to be run asynchronously in the given DispatchQueue
You could make it synchronous if it’s already in the same DispatchQueue by using observe(on:):
class MyClass {
@Subject var text: String?
func observeText() {
$text.observe(on: .main)
.observeChange { changes in
print(changes.new)
print(changes.old)
}
.retain()
}
}
Mapping Value
You could map the value from your Subject to another type by using mapping. Mapping will create a new Observable with mapped type:
class MyClass {
@Subject var text: String
func observeText() {
$text.mapped { $0.count }
.observeChange { changes in
print("text character count is \(changes.new)")
}.retain()
}
}
You could always map and ignore errors or nil during mapping. Did set closure will always be called when mapping is successful:
class MyClass {
@Subject var text: String?
func observeText() {
$text.compactMapped { $0?.count }
.observeChange { changes in
// this will not run if text is nil
print("text character count is \(changes.new)")
}.retain()
}
}
Observable Block
You can always create Observable from the code block by using ObservableBlock:
let myObservableFromBlock = ObservableBlock { accept in
runSomethingAsync { result in
accept(result)
}
}
myObservableFromBlock.observeChange { changes in
print(changes)
}.retain()
Publisher
The publisher is the Observable that is only used for Publishing value
let myPublisher = Publisher<Int>()
...
...
// it will then publish 10 to all of its subscribers
myPublisher.publish(10)
Relay value to another Observable
You can relay value from any Observable to another Observable:
class MyClass {
@Subject var text: String?
@Subject var count: Int = 0
@Subject var empty: Bool = true
func observeText() {
$text.compactMap { $0?.count }
.relayChanges(to: $count)
.retain()
}
}
You can always relay value to Any NSObject Bearer Observables by accessing relayables. Its using dynamicMemberLookup, so all of the object writable properties will be available there:
class MyClass {
var label: UILabel = UILabel()
@Subject var text: String?
func observeText() {
$text.relayChanges(to: label.relayables.text)
.retain()
}
}
Merge Observable
You can merge as many observables as long their type subject type is the same:
class MyClass {
@Subject var subject1: String = ""
@Subject var subject2: String = ""
@Subject var subject3: String = ""
@Subject var subject4: String = ""
func observeText() {
$subject1.merged(with: $subject2, $subject3, $subject4)
.observeChange { changes in
// this will run if any of merged observable is set
print(changes.new)
}.retain()
}
}
Combine Observable
You can combine up to 4 observables as one and observe if any of those observables is set:
class MyClass {
@Subject var userName: String = ""
@Subject var fullName: String = ""
@Subject var password: String = ""
@Subject var user: User = User()
func observeText() {
$userName.combine(with: $fullName, $password)
.mapped {
User(
userName: $0.new.0 ?? "",
fullName: $0.new.1 ?? "",
password: $0.new.2 ?? ""
)
}.relayChanges(to: $user)
.retain()
}
}
It will generate an Observable of all combined values but is optional since some values might not be there when one of the observables is triggered. To make sure that it will only be called triggered when all of the combined value is available, you can use compactCombine instead
class MyClass {
@Subject var userName: String = ""
@Subject var fullName: String = ""
@Subject var password: String = ""
@Subject var user: User = User()
func observeText() {
$userName.compactCombine(with: $fullName, $password)
.mapped {
User(
userName: $0.new.0,
fullName: $0.new.1,
password: $0.new.2
)
}.relayChanges(to: $user)
.retain()
}
}
It will not be triggered until all the observable is emitting a value.
Pharos
Pharos is an Observer pattern framework for Swift that utilizes
propertyWrapper
. It could help a lot when designing Apps using reactive programming. Under the hood, it utilizes Chary as DispatchQueue utilitiesExample
To run the example project, clone the repo, and run
pod install
from the Example directory first.Requirements
Only Swift Package Manager
Installation
Cocoapods
Pharos is available through CocoaPods. To install it, simply add the following line to your Podfile:
Swift Package Manager from XCode
Swift Package Manager from Package.swift
Add as your target dependency in Package.swift
Use it in your target as
Pharos
Author
Nayanda Haberty, hainayanda@outlook.com
License
Pharos is available under the MIT license. See the LICENSE file for more info.
Basic Usage
All you need is a property that you want to observe and add
@Subject
propertyWrapper at it:to observe any changes that happen in the text, use its
projectedValue
to get its Observable`. and pass the closure subscriber:every time any set happens in text, it will call the closure with its changes which include old value and new value. You could ignore any set that does not change the value as long the value is
Equatable
if you want the observer to run using the current value, just fire it:
if you want to ignore observing the old value, use
observe
instead:you can always check the current value by accessing the observable property:
Control Subscriber Retaining
By default, if you observe Observable and end it with
retain()
. The closure will be retained by the Observable itself. It will automatically be removed byARC
if the Observable is removed byARC
. If you want to retain the closure with a custom object, you could always do something like this:In the example above, the closure will be retained by the
MyClass
instance and will be removed if the instance is removed by ARC.If you want to handle the retaining manually, you could always use
Retainer
to retain the observer:There are many ways to discard the subscriber managed by
Retainer
:discardAllRetained()
from subscriber’s retainerARC
to remove the retainer from memory and thus will discard all of its managed subscribers by default.ARC
, it will automatically discard theRetainer
and thus will discard all of its managed subscribers by default.You can always control how long you want to retain by using various retain methods:
Use this retain capability wisely, since if you’re not aware of how the ARC work it can introduce retain cycle.
UIControl
You can observe an event in
UIControl
as long as in iOS by callingobserveEventChange
, or by usingwhenDidTriggered(by:)
if you want to observe a specific event or for more specificwhenDidTapped
for touchUpInside event:Bindable
You can observe changes in the supported
UIView
property by accessing its observables inbindables
:you can always bind two Observables to notify each other:
In the example above, every time
text
is set, it will automatically set thetextField.text
, and whentextField.text
is set it will automatically set thetext
.Filtering Subscription
You can filter value by passing a closure that returns the
Bool
value which indicated that value should be ignored:In the example above,
observeChange
closure will not run when the new value is emptyThe opposite of
ignore
isfilter
In the example above, observeChange closure will only run when the new value is bigger than 5
Throttling
Sometimes you just want to delay some observing because if the value is coming too fast, it could bottleneck some of your business logic like when you call API or something. It will automatically use the latest value when the closure fire:
Add DispatchQueue
You could add
DispatchQueue
to make sure your observable is run on the right thread. IfDispatchQueue
is not provided, it will use the thread from the notifier:It will make all the subscribers after this dispatch call to be run asynchronously in the given
DispatchQueue
You could make it synchronous if it’s already in the same
DispatchQueue
by usingobserve(on:)
:Mapping Value
You could map the value from your
Subject
to another type by using mapping. Mapping will create a new Observable with mapped type:You could always map and ignore errors or nil during mapping. Did set closure will always be called when mapping is successful:
Observable Block
You can always create
Observable
from the code block by usingObservableBlock
:Publisher
The publisher is the Observable that is only used for Publishing value
Relay value to another Observable
You can relay value from any Observable to another Observable:
You can always relay value to Any NSObject Bearer Observables by accessing
relayables
. Its usingdynamicMemberLookup
, so all of the object writable properties will be available there:Merge Observable
You can merge as many observables as long their type subject type is the same:
Combine Observable
You can combine up to 4 observables as one and observe if any of those observables is set:
It will generate an Observable of all combined values but is optional since some values might not be there when one of the observables is triggered. To make sure that it will only be called triggered when all of the combined value is available, you can use
compactCombine
insteadIt will not be triggered until all the observable is emitting a value.
Contribute
You know-how. Just clone and do a pull request