Protocols
The following protocols are available globally.
-
The LocalState of a
ViewControllerWithLocalState
.A
ViewController
is managing the UI of a screen, listening for Katana global state changes, keeping the UI updated and dispatching actions in response to user interactions in order to change the global state.There are times when you have some kind of state information that is only specific to the screen managed by a ViewController, like for instance the item selected in a list. In this case, in order to avoid polluting the global state, you can represent that information inside a
LocalState
and promote that ViewController to be aViewControllerWithLocalState
.A ViewControllerWithLocalState contains a
localState
variable that you can change directly in order to represent local state changes.struct GlobalState: State { var todos: [String] = [ "buy milk", "find a unicorn", "visit Rome" ] }
struct ListLocalState: LocalState { var selectedIndex: Int }
Declaration
Swift
public protocol LocalState
-
Mixin protocol for UIView subclasses based on the same SSUL lifecycle of
View
. Conforming toModellableView
, a UIView will get amodel: ViewModel
property and theupdate(oldModel: ViewModel?)
will be automatically called each time the model property will change. If your UIView is simple and you don’t want to use a ViewModel, refer to theView
protocol instead.Overview
The
View
protocol is good enough for small reusable UI elements that can be manipulated through properties. There are a couple of drawbacks to this approach:- it’s not easy to test UI elements
- in the
View.update()
phase we don’t know the actual property that is changed, meaning that we cannot reason in terms of differences from the old values - changing two or more properties at the same time will trigger two or more updates.
To solve all of these issues we introduce the concept of
ViewModel
. A ViewModel is a struct that contains all the properties that define the state of the View.struct ContactViewModel: ViewModel { var name: String = "John" var lastName: String = "Doe" }
A
ModellableView
then is a special case ofView
that is using a ViewModel to represent its state. All the Setup, Style and Layout phases described inView
are still in use, the only difference is that the Update method of the ModellableView is getting anoldModel
parameter.struct ContactView: UIView, ModellableView { // subviews to create the UI private var title = UILabel() private var subtitle = UILabel() // interactions var nameDidChange: ((String) -> ())? var lastNameDidChange: ((String) -> ())? override init(frame: CGRect = .zero) { super.init(frame: frame) self.setup() self.style() } func setup() { // define the subviews that will make up the UI self.addSubview(self.title) self.addSubview(self.subtitle) self.title.on(.didEndEditing) { [weak self] label in self?.nameDidChange?(label.text) } self.subtitle.on(.didEndEditing) { [weak self] label in self?.lastNameDidChange?(label.text) } } func style() { // define the default look and feel of the UI elements } func update(oldModel: ContactViewModel?) { // update the UI based on the value of `self.model` // you can use `oldModel` to reason about diffs self.title.text = self.model.name self.subtitle.text = self.model.lastname } override func layoutSubviews() { // layout the subviews } }
Conforming to
ModellableView
will:- create the
model: ContactViewModel
variable automatically for you. - automatically call the
ModellableView.update(oldModel:)
method every time the model changes - allow to test the ViewModel instead of testing the ModellableView
- include the oldModel inside the
ModellableView.update(oldModel:)
so that you can reason about diffs - allow to change more than one property and trigger only one update
See morepublic protocol ModellableView: View { associatedtype VM: ViewModel // the ViewModel of the View. // `update(oldModel: VM?)` will be called each time model will change var model: VM? { get set } // the model is changed, update the View func update(oldModel: VM?) }
Declaration
Swift
public protocol ModellableView : View
-
Basic protocol representing the 4 main phases of the lifecycle of a UIView in Tempura.
Ideally all the reusable simple Views of the app should conform to this protocol. Please note that this protocol is just used in order to enforce the same lifecycle for all the views.
For more complex Views please refer to the
ModellableView
protocol.Overview
A view is a piece of UI that is visible on screen. It contains no business logic, it can contain UI logic.
class Switch: UIView, View { // subviews private var thumb = UIView() // properties var isOn: Bool = false { didSet { guard self.isOn != oldValue else { return } self.update() } } override init(frame: CGRect = .zero) { super.init(frame: frame) self.setup() self.style() } // interactions var valueDidChange: ((Bool) -> ())? func setup() { // define the subviews that will make up the UI } func style() { // define the default look and feel of the UI elements } func update() { // update the UI based on the value of the properties } override func layoutSubviews() { // layout the subviews, optionally considering the properties } }
The interface that the View is exposing is composed by properties and interactions. The
properties
are the internal state of the element that can be manipulated from the outside,interactions
are callbacks used to listen from outside of the view to changes occurred inside the element itself (like user interacting with the element changing its value). The lifecycle of a View contains four different phases:public protocol View: class { func setup() func style() func update() func layoutSubviews() }
This protocol is not doing anything for us, it’s just a way to enforce the SSUL phases.
Setup
The setup phase should execute only once when the
View
is created, here you tipically want to create and add all the children views as subviewsfunc setup() { self.addSubview(self.headerView) self.addSubview(self.contentView) self.addSubview(self.footerView) }
Style
The style phase should execute only once when the
View
is created, right after the setup phase. Here you configure all the style related properties that will not change over time. For all the style attributes that change depending on the “state” of the view, look at theView.update()
phase.func style() { self.headerView.backgroundColor = .white self.contentView.layer.cornerRadius = 20 }
Update
The update phase should execute every time the “state” of the View is changed. Here you update the View in order to reflect its new state.
var headerImage: UIImage? { didSet { self.update() } }
func update() { self.headerView.image = self.headerImage }
Layout
The layout phase is where you define the layout of your view. It’s using the same
layoutSubviews()
method ofUIView
, meaning that can be triggered using the usualsetNeedsLayout()
andlayoutIfNeeded()
methods of UIKit.override func layoutSubviews() { self.headerView.frame = CGRect(x: 0, y:0, width: self.bounds.width, height: 100) self.contentView.frame = CGRect(x: 0, y: 100, width: self.bounds.width, height: 300) }
Note on layout updates
When the layout of your view changes over time, you are responsible to call
setNeedsLayout()
inside theupdate()
phase in order to trigger a layout update.func update() { self.headerView.image = self.headerImage self.setNeedsLayout() }
override func layoutSubviews() { let containsImage: Bool = self.headerImage != nil let headerHeight: CGFloat = containsImage ? 100 : 0 self.headerView.frame = CGRect(x: 0, y:0, width: self.bounds.width, height: headerHeight) }
Note on calling setup and style
When your UIView subclass conforms to the View protocol, you are responsible to call the
setup()
andstyle()
methods inside the init.
See moreclass TestView: UIView, View { override init(frame: CGRect = .zero) { super.init(frame: frame) self.setup() self.style() } func setup() {} func style() {} func update() {} override func layoutSubviews() {} }
Declaration
Swift
public protocol View : AnyObject
-
Partial Type Erasure for the ViewController Each
See moreViewController
is anAnyViewController
Declaration
Swift
public protocol AnyViewController
-
Extends the
ModellableView
protocol to add some convenience variables that refers to the ViewController that owns the View. A special case ofModellableView
representing the UIView that theViewController
is managing. It’s intended to be used only as the main View of aViewController
.Overview
A ViewControllerModellableView is a
See moreModellableView
that aViewController
is managing directly. It differs from a ModellableView only for a couple of computed variables used as syntactic sugar to access navigation items on the navigation bar (if present). A ViewControllerModellableView has also access to theuniversalSafeAreaInsets
. TheViewController
that is managing this View is responsible to callModellableView.setup()
andModellableView.style()
during the setup phase of the ViewController so you don’t need to do that.Declaration
Swift
public protocol ViewControllerModellableView : AnyViewControllerModellableView, ModellableView where Self.VM : ViewModelWithState
-
type erasure for ViewControllerModellableView in order to access to the viewController property without specifying the VM
See moreDeclaration
Swift
public protocol AnyViewControllerModellableView
-
A lightweight object that represents the set of properties needed by a
ModellableView
to render itself.struct ContactViewModel: ViewModel { var name: String = "John" var lastName: String = "Doe" }
Declaration
Swift
public protocol ViewModel
-
A special case of
ViewModel
used to select part of the Katana app state andViewControllerWithLocalState
‘sLocalState
that is of interest for the View.struct CounterState: State { var counter: Int = 0 }
struct ScreenLocalState: LocalState { var isCounting: Bool = false }
See morestruct CounterViewModel: ViewModelWithState { var countDescription: String init(state: CounterState?, localState: ScreenLocalState) { if let state = state, localState.isCounting { self.countDescription = "the counter is at \(state.counter)" } else { self.countDescription = "we are not counting yet" } } }
Declaration
Swift
public protocol ViewModelWithLocalState : ViewModelWithState
-
A special case of
ViewModel
used to select part of the Katana app state that is of interest for the View.struct CounterState: State { var counter: Int = 0 }
See morestruct CounterViewModel: ViewModelWithState { var countDescription: String init(state: CounterState) { self.countDescription = "the counter is at \(state.counter)" } }
Declaration
Swift
public protocol ViewModelWithState : ViewModel
-
Protocol for all the navigation-related SideEffect exposed by Tempura
Declaration
Swift
public protocol NavigationSideEffect : AnySideEffect
-
A RoutableWithConfiguration is a
ViewController
that takes active part to the execution of a navigation action.If a screen
listScreen
needs to presentaddItemScreen
, the ViewController that is handlinglistScreen
must conform to theRoutableWithConfiguration
protocol.When a
Show("addItemScreen")
action is dispatched, theNavigator
will capture the action and will start finding a RoutableWithConfiguration in the active hierarchy that can handle the action. If thenavigationConfiguration
oflistScreen
will match theNavigationRequest
of.show(addItemScreen)
than the Navigator will execute the relativeNavigationInstruction
where you can configure the ViewController to present.There are others
NavigationRequest
s andNavigationInstruction
s that can be used to define the navigation structure of the app.In case you need more control, you can always implement the
Routable
protocol yourself and have fine grained control of the implementation of the navigation. In fact, aRoutableWithConfiguration
and itsnavigationConfiguration
are used behind the scenes to implement theRoutable
protocol for you.
See moreextension ListViewController: RoutableWithConfiguration { // needed by the `Routable` protocol // to identify this ViewController in the hierarchy var routeIdentifier: RouteElementIdentifier { return "listScreen" } // the `NavigationRequest`s that this ViewController is handling // with the `NavigationInstruction` to execute var navigationConfiguration: [NavigationRequest: NavigationInstruction] { return [ .show("addItemScreen"): .presentModally({ [unowned self] _ in let vc = AddItemViewController(store: self.store) return vc }) ] }
Declaration
Swift
public protocol RoutableWithConfiguration : Routable
-
Defines a way to inspect a UIViewController asking for the next visible UIViewController in the visible stack.
See moreDeclaration
Swift
public protocol CustomRouteInspectables : AnyObject
-
Undocumented
See moreDeclaration
Swift
public protocol RouteInspectable : AnyObject
-
The object responsible to handle the installation of the first screen of the navigation.
The
Navigator
will call theRooInstaller.installRoot(identifier:context:completion:)
method that will handle the setup of the screen to be shown.
See moreclass AppDelegate: UIResponder, UIApplicationDelegate, RootInstaller { func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -> Bool { ... // setup the root of the navigation // this is done by invoking this method (and not in the init of the navigator) // because the navigator is instantiated by the Store. // this in turn will invoke the `installRootMethod` of the rootInstaller (self) navigator.start(using: self, in self.window, at: "screenA") return true } // install the root of the app // this method is called by the navigator when needed // you must call the `completion` callback when the navigation has been completed func installRoot(identifier: RouteElementIdentifier, context: Any?, completion: () -> ()) -> Bool { let vc = ScreenAViewController(store: self.store) self.window.rootViewController = vc completion() return true } }
Declaration
Swift
public protocol RootInstaller
-
A Routable is a
ViewController
that takes active part to the execution of a navigation action. This is intended to be used in the few cases theRoutableWithConfiguration
is not enough.For instance, if we want a screen
A
to presentB
, the ViewController that is handling A, must conform to the Routable protocol.Each Routable can be asked by the
Navigator
to perform a specific navigation task (like present another ViewController) based on the NavigationAction you dispatch (seeShow
orHide
).extension TodoListViewController: Routable { var routeIdentifier: RouteElementIdentifier: { return "todoList" } func show(indentifier: RouteElementIdentifier, from: RouteElementIdentifier, animated: Bool, context: Any?, completion: @escaping RoutingCompletion) -> Bool { let vc = NextViewController(store: self.store) self.present(vc, animated: animated, completion: completion) return true } }
See moreextension TodoListViewController: Routable { func hide(indentifier: RouteElementIdentifier, from: RouteElementIdentifier, animated: Bool, context: Any?, completion: @escaping RoutingCompletion) -> Bool { if identifier == self.routeIdentifier { self.dismiss(animated: animated, completion: completion) return true } return false } }
Declaration
Swift
public protocol Routable : AnyObject