Protocols

The following protocols are available globally.

  • 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 subviews

    func 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 the View.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 of UIView, meaning that can be triggered using the usual setNeedsLayout() and layoutIfNeeded() 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 the update() 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() and style() methods inside the init.

    class 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() {}
    }
    
    See more

    Declaration

    Swift

    public protocol View: class
  • 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
       }
    
       struct CounterViewModel: ViewModelWithState {
         var countDescription: String
    
         init(state: CounterState) {
           self.countDescription = "the counter is at \(state.counter)"
         }
       }
    
    See more

    Declaration

    Swift

    public protocol ViewModelWithState: ViewModel
  • Mixin protocol for UIView subclasses based on the same SSUL lifecycle of View. Conforming to ModellableView, a UIView will get a model: ViewModel property and the update(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 the View 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 of View that is using a ViewModel to represent its state. All the Setup, Style and Layout phases described in View are still in use, the only difference is that the Update method of the ModellableView is getting an oldModel 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
       public 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?)
       }
    
    See more

    Declaration

    Swift

    public protocol ModellableView: View
  • A special case of ViewModel used to select part of the Katana app state and ViewControllerWithLocalState‘s LocalState that is of interest for the View.

       struct CounterState: State {
         var counter: Int = 0
       }
    
       struct ScreenLocalState: LocalState {
         var isCounting: Bool = false
       }
    
       struct 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"
           }
         }
       }
    
    See more

    Declaration

    Swift

    public protocol ViewModelWithLocalState: ViewModelWithState
  • 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 a ViewControllerWithLocalState.

    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
  • 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 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 the RoutableWithConfiguration is not enough.

    For instance, if we want a screen A to present B, 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 (see Show or Hide).

       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
         }
       }
    
       extension 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
         }
       }
    
    See more

    Declaration

    Swift

    public protocol Routable: class
  • The object responsible to handle the installation of the first screen of the navigation.

    The Navigator will call the RooInstaller.installRoot(identifier:context:completion:) method that will handle the setup of the screen to be shown.

       class 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
         }
       }
    
    See more

    Declaration

    Swift

    public protocol RootInstaller
  • A RoutableWithConfiguration is a ViewController that takes active part to the execution of a navigation action.

    If a screen listScreen needs to present addItemScreen, the ViewController that is handling listScreen must conform to the RoutableWithConfiguration protocol.

    When a Show("addItemScreen") action is dispatched, the Navigator will capture the action and will start finding a RoutableWithConfiguration in the active hierarchy that can handle the action. If the navigationConfiguration of listScreen will match the NavigationRequest of .show(addItemScreen) than the Navigator will execute the relative NavigationInstruction where you can configure the ViewController to present.

    There are others NavigationRequests and NavigationInstructions 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, a RoutableWithConfiguration and its navigationConfiguration are used behind the scenes to implement the Routable protocol for you.

       extension 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
             })
           ]
       }
    
    See more

    Declaration

    Swift

    public protocol RoutableWithConfiguration: Routable
  • Protocol that the SideEffectDependencyContainer of the app must conform in order to use Navigator and the navigation mechanism provided by Tempura.

    See more

    Declaration

    Swift

    public protocol NavigationProvider: SideEffectDependencyContainer