-
Notifications
You must be signed in to change notification settings - Fork 1
Artisan Mediator AKA View Model
Artisan Mediator is the ViewModel in MVVM Architecture in Artisan. You should read about Artisan Fragment and `Artisan Bonding before you read this further.
This page is divided into 2 section which all have subsections:
ViewMediator
is the base class for MVVM ViewModel
in Artisan. its declared like this:
open class ViewMediator<View: NSObject>: NSObject, BondableMediator
the View generic parameter could be assigned with any NSObject
but it better be assigned with Fragment
or UIViewController
to get most of its feature. It is designed to be where the two-way binding and all business logic happen.
ViewMediator
can be bond with View
by using bond(with:)
and apply(to:)
methods:
-
bond(with:)
will remove oldView
bonding and callbonding(with:)
method -
apply(to:)
will callbond(with:)
, and then will invoke all Observable properties with current value
As you notice, both of those methods will eventually call this method:
func bonding(with view: View)
which were the Observable properties and View UI Data bonded.
class TitledImageVM: ViewMediator<TitledImage> {
@Observable var image: UIImage?
@Observable var title: String?
override func bonding(with view: TitledImage) {
$image.relayValue(to: view.imageView.bearerRelays.image)
$title.relayValue(to: view.titleLabel.bearerRelays.text)
}
}
You could also override this method:
-
func didInit()
which will be called afterinit()
-
func willBonded(with view: View)
which will be called beforebonding(with:)
is called -
func didBonded(with view: View)
which will be called afterbonding(with:)
is called -
func willApplying(_ view: View)
which will be called before Observable properties is invoked with current value -
func didApplying(_ view: View)
which will be called after Observable properties is invoked with current value
Once the View
is bond with ViewMediator
you could get the View
from ViewMediator
by calling bondedView
or get mediator from View
by calling mediator
:
class TitledImage: UIView, Fragment {
lazy var imageView: UIImageView = .init()
lazy var titleLabel: UILabel = .init()
@LayoutPlan
var viewPlan: ViewPlan {
imageView.plan
.at(.fullTop, .equalTo(8), to: .parent)
titleLabel.plan
.at(.bottomOf(banner), .equalTo(8))
.at(.fullBottom, .equalTo(8), to: .parent)
}
func set(url: URL) {
(getMediator() as? TitledImageVM)?.set(url: url)
}
}
class TitledImageVM: ViewMediator<TitledImage> {
@Observable var image: UIImage?
@Observable var title: String?
var imageService: ImageService = .default
override func bonding(with view: TitledImage) {
$image.relayValue(to: view.imageView.bearerRelays.image)
$title.relayValue(to: view.titleLabel.bearerRelays.text)
}
func set(url: URL) {
imageService(get: url) { [weak self] image in
self?.bondedView?.imageView.image = image
}
}
}
At that example, TitledImageVM
can access TitledImage
by calling view
and TitledImage
can access TitledImageVM
by call getMediator()
. both would return nil if both is not bonded
Bonding could be obsolete on this occasion:
-
View
is bond with anotherViewMediator
sinceView
could only bond with oneViewMediator
-
removeBond()
is called from mediator - mediator is deinitialized by ARC
In those cases, you could override this method to do something when that happened:
func bondDidRemoved()
You could use basic ViewMediator
to create ViewModel
for any UIViewController
:
class SearchScreen: UIViewController {
...
...
...
}
class SearchScreenVM: ViewMediator<SearchScreen> {
...
...
...
@Observable var searchPhrase: String?
override func bonding(with view: EventSearchScreen) {
$searchPhrase.bonding(with: view.searchBar.bondableRelays.text)
.whenDidSet(invoke: self, method: EventSearchScreenVM.search(for:))
}
...
...
...
}
Just do apply (or bond) when you want to show the UIViewController
func routeToSearch(from origin: UIViewController) {
let screen = SearchScreen()
let mediator = SearchScreenVM()
mediator.apply(to: screen)
origin.present(screen, animated: true, completion: nil)
}
The mediator will only invoke observables with current value only when the view already loaded
You could use basic ViewMediator
to create ViewModel
for any UIView
:
class TitledImage: UIView, Fragment {
...
...
...
}
class TitledImageVM: ViewMediator<TitledImage> {
@Observable var image: UIImage?
@Observable var title: String?
override func bonding(with view: TitledImage) {
$image.relayValue(to: view.imageView.bearerRelays.image)
$title.relayValue(to: view.titleLabel.bearerRelays.text)
}
...
...
...
}
Just do apply (or map) when you want to bind the UIView
let view = TitledImage()
let mediator = TitledImageVM()
mediator.apply(to: screen)
The mediator will only invoke observables with current value only when the view already inside superview
There is one protocol that give View
the ability to auto cast ViewMediator
into Observer
protocol:
public protocol ObservableView {
associatedtype Observer
var observer: Observer? { get }
}
Example:
protocol SearchScreenObserver {
func didTap(_ tableView: UITableView, cell: UITableViewCell, at indexPath: IndexPath)
}
class SearchScreen: UIViewController, ObservableView {
typealias Observer = SearchScreenObserver
...
...
...
}
extension SearchScreen: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let cell = tableView.cellForRow(at: indexPath) else {
return
}
observer?.didTap(tableView, cell: cell, at: indexPath)
}
}
// the mediator
class SearchScreenVM: ViewMediator<EventSearchScreen> {
...
...
...
}
extension SearchScreenVM: SearchScreenObserver {
func didTap(_ tableView: UITableView, cell: UITableViewCell, at indexPath: IndexPath) {
// do something
...
...
...
}
}
On the example above, as long as the bonded mediator of SearchScreen
is implementing SearchScreenObserver
, it will be implicitly cast as SearchScreenObserver
every time SearchScreen
calls observer
Same like ViewMediator
, CellMediator
gives extra features which utilized Mediator as UI Data for Cell reused. It has an extra useful property which is var cellIdentifier: AnyHashable which could be used when doing reload to determine that the Cell needs to be inserted, removed, or refreshed. the default value of cellIdentifier
is a random string. CellMediator
will always use apply instead of the map if automatically bonded with cell.
TableCellMediator
is the base ViewMediator for UITableViewCell:
class KeywordCellVM: TableCellMediator<KeywordCell> {
@Observable var keyword: String?
override func bonding(with view: KeywordCell) {
$keyword.relayValue(to: view.keywordLabel.bearerRelays.text)
.whenDidSet { [weak self] changes in
self?.distinctIdentifier = changes.new ?? UUID().uuidString
}
}
}
Since the TableCellMediator
have generic UITableViewCell
parameter, it could be auto registered for UITableView
and could be reused automatically for the same UITableViewCell
. All you need to do is assign the mediators into UITableView
:
var keywords: [String] = ["some", "other", "keyword"]
tableView.cells = keywords.compactMap {
let cellVM = KeywordCellVM()
cellVM.keyword = $0
return cellVM
}
In the above example, the tableView
will reload the data with KeywordCell
and will apply KeywordCellVM
for each cell. You don't even need to provide dataSource or call tableView.reloadData()
. You could read more about this mechanism here.
TableViewCellMediator
is the base ViewMediator for UITableViewCell:
class ImageCellVM: CollectionCellMediator<ImageCell> {
@Observable var image: UIImage?
override func bonding(with view: ImageCell) {
$image.relayValue(to: view.imageView.bearerRelay.image)
}
}
Since the CollectionCellMediator
have generic UICollectionViewCell
parameter, it could be auto registered for UICollectionView
and could be reused automatically for the same UICollectionViewCell
. All you need to do is assign the mediators into UICollectionView
:
var images: [UIImage] = getSomeImages()
collectionView.cells = images.compactMap {
let cellVM = ImageCellVM()
cellVM.cellIdentifier = $0
cellVM.image = $0
return cellVM
}
In the above example, the collectionView
will reload the data with ImageCell
and will apply ImageCellVM
for each cell. You don't even need to provide dataSource or call collectionView.reloadData()
. You could read more about this mechanism here.