Skip to content
This repository has been archived by the owner on Apr 18, 2024. It is now read-only.

Artisan Mediator AKA View Model

Nayanda Haberty edited this page Jul 9, 2021 · 18 revisions

Artisan Mediator

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:


View Mediator

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.

Mediator Bonding

ViewMediator can be bond with View by using bond(with:) and apply(to:) methods:

  • bond(with:) will remove old View bonding and call bonding(with:) method
  • apply(to:) will call bond(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 after init()
  • func willBonded(with view: View) which will be called before bonding(with:) is called
  • func didBonded(with view: View) which will be called after bonding(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 another ViewMediator since View could only bond with one ViewMediator
  • 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()

UIViewController Mediator

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

UIView Mediator

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

ObservableView

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


Cell Mediator

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.

TableCell Mediator

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.

CollectionCell Mediator

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.