Classes

The following classes are available globally.

  • Manages a screen of your app, it keeps the UI updated and listens for user interactions.

    Overview

    In Tempura, a Screen is composed by three different elements that interoperate in order to get the actual pixels on the screen and to keep them updated when the state changes. These are ViewController, ViewModelWithState and ViewControllerModellableView. The ViewController is a subclass of UIViewController that is responsible to manage the set of views that are shown in each screen of your UI.

       struct CounterState: State {
         var counter: Int = 0
       }
    
       struct IncrementCounter: Action {
         func updatedState(inout currentState: CounterState) {
           currentState.counter += 1
         }
       }
    
       struct DecrementCounter: Action {
         func updatedState(inout currentState: CounterState) {
           currentState.counter -= 1
         }
       }
    
       struct CounterViewModel: ViewModelWithState {
         var countDescription: String
    
         init(state: CounterState) {
           self.countDescription = "the counter is at \(state.counter)"
         }
       }
    
       class CounterView: UIView, ViewControllerModellableView {
    
         // subviews
         var counterLabel = UILabel()
         var addButton = UIButton(type: .custom)
         var subButton = UIButton(type: .custom)
    
         // interactions
         var didTapAdd: Interaction?
         var didTapSub: Interaction?
    
         // setup
         func setup() {
           self.addButton.on(.touchUpInside) { [unowned self] button in
             self.didTapAdd?()
           }
           self.subButton.on(.touchUpInside) { [unowned self] button in
             self.didTapSub?()
           }
           self.addSubview(self.counterLabel)
           self.addSubview(self.subButton)
           self.addSubview(self.addButton)
         }
    
         // style
         func style() {
           self.backgroundColor = .white
           self.addButton.setTitle("Add", for: .normal)
           self.subButton.setTitle("Sub", for: .normal)
         }
    
         // update
         func update(oldModel: CounterViewModel?) {
           self.counterLabel.text = self.model?.countDescription
           self.setNeedsLayout()
         }
    
         // layout
         override func layoutSubviews() {
           self.counterLabel.sizeToFit()
           self.addButton.frame = CGRect(x: 0, y: 100, width: 100, height: 44)
           self.subButton.frame = CGRect(x: 100, y: 100, width: 100, height: 44)
         }
       }
    
       class CounterViewController: ViewController<CounterView> {
    
       override func setupInteraction() {
         self.rootView.didTapAdd = { [unowned self] in
           self.dispatch(IncrementCounter())
         }
         self.rootView.didTapSub = { [unowned self] in
           self.dispatch(DecrementCounter())
         }
       }
    

    Lifecycle of a ViewController

    In order to instantiate a ViewController’s subclass you need to provide a Katana Store instance. This instance will be used by the ViewController to listen for state updates.

       let vc = CounterViewController(store: appStore)
    

    When a ViewController is created it will start receiving state updates as soon as the connected property will become true.

    When the ViewController becomes visible, the UIKit UIViewController.viewWillAppear() will be called and Tempura will set connected to true and the ViewController will start receiving the updates from the state. If you don’t want this to happen automatically every time the ViewController will become visible, set shouldConnectWhenVisible to false.

    As soon a new state is available from the Katana store, the ViewController will instantiate a new ViewModel out of that state and feed the rootView with that, calling ModellableView.update(oldModel:)

    When something happens inside the ViewControllerModellableView (or its subviews) the ViewController is responsible to listen for these Interaction callbacks and react accordingly dispatching actions in order to change the state.

    When a ViewController is removed from the hierarchy or hidden by some other ViewController, UIKit will call UIViewController.viewWillDisappear() and Tempura will set connected to false, detaching the ViewController from the state updates. If you don’t want this to happen automatically every time the ViewControllet will become invisible, set sholdDisconnectWhenInvisible to false

    See more

    Declaration

    Swift

    open class ViewController<V: ViewControllerModellableView & UIView>: UIViewController, AnyViewController
  • Special case of a ViewController that contains a LocalState.

    Overview

    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. The ViewControllerWithLocalState will be listening for changes of both global and local states, updating the UI using the appropriate ViewModelWithLocalState.

    You can change the global state dispatching Katana actions like in every ViewController. You can change the LocalState manipulating directly the localState variable.

    The lifecycle of a ViewControllerWithLocalState is the same as a normal ViewController, please refer to that for more details.

       struct GlobalState: State {
         var todos: [String] = [
           "buy milk",
           "find a unicorn",
           "visit Rome"
         ]
       }
    
       struct RemoveTodo: AppAction {
         var index: Int
    
         func updatedState(inout currentState: GlobalState) {
           currentState.todos.remove(at: index)
         }
       }
    
       struct ListLocalState: LocalState {
         var selectedIndex: Int
       }
    
       struct TodolistViewModel: ViewModelWithLocalState {
         var todos: [String]
         var selectedIndex: Int
    
         init?(state: GlobalState?, localState: ListLocalState) {
           guard let state = state else { return nil }
           self.todos = state.todos
           self.selectedIndex = localState.selectedIndex
         }
       }
    
       class TodoListView: UIView, ViewControllerModellableView {
    
         // subviews
         var todoListView = ListView()
    
         // interactions
         var didTapToRemoveItem: ((Int) -> ())?
         var didSelectItem: ((Int) -> ())?
    
         // setup
         func setup() {
           self.todoListView.on(.selection) { [unowned self] indexPath in
             self.didSelectItem?(indexPath.item)
           }
           self.todoListView.on(.deleteItem) { [unowned self] indexPath in
             seld.didTapRemoveItem?(indexPath.item)
           }
           self.addSubview(self.todoListView)
         }
    
         // style
         func style() {
           self.backgroundColor = .white
           self.todoListView.backgroundColor = .white
         }
    
         // update
         func update(oldModel: CounterViewModel?) {
           self.todoListView.source = model?.todos ?? []
           self.todoListView.selectedIndex = model?.todos
         }
    
         // layout
         override func layoutSubviews() {
           self.todoListView.frame = self.bounds
         }
       }
    
       class TodoListViewController: ViewControllerWithLocalState<TodoListView> {
    
       override func setupInteraction() {
         self.rootView.didTapRemoveItem = { [unowned self] index in
           self.dispatch(RemoveTodo(index: index))
         }
         self.rootView.didSelectItem = { [unowned self] index in
           self.localState.selectedIndex = index
         }
       }
    
    See more

    Declaration

    Swift

    open class ViewControllerWithLocalState<V: ViewControllerModellableView & UIView>: ViewController<V> where V.VM: ViewModelWithLocalState
  • A View used to do ViewController containment This is the View that will contain the View of the managed ViewController

    See more

    Declaration

    Swift

    public class ContainerView: UIView
  • Main class that is handling the navigation in a Tempura app.

    Overview

    When it comes to creating a complex app, the way you handle the navigation between different screens is an important factor on the final result.

    We believe that relying on the native iOS navigation system, even in a Redux-like environment like Katana, is the right choice for our stack, because:

    • there is no navigation code to write and maintain just to mimic the way native navigation works

    • native navigation gestures will come for free and will stay up to date with new iOS releases

    • the app will feel more native

    For these reasons we found a way to reconcile the redux-like world of Katana with the imperative world of the iOS navigation.

    The Routable protocol

    If a Screen (read ViewController) takes an active part on the navigation (i.e. needs to present another screen) it must conform to the RoutableWithConfiguration protocol.

       protocol RoutableWithConfiguration: Routable {
    
         var routeIdentifier: RouteElementIdentifier { get }
    
         var navigationConfiguration: [NavigationRequest: NavigationInstruction] { get }
       }
    
       typealias RouteElementIdentifier = String
    

    Each RoutableWithConfiguration can be asked by the Navigator navigation to perform a specific navigation task (like present another ViewController) based on the navigation action (Show, Hide) you dispatch.

    The route

    A Route is an array that represents a navigation path to a specific screen.

       typealias Route = [RouteElementIdentifier]
    

    How the navigation works

    Suppose we have a current Route like screenA, screenB the topmost ViewController/RoutableWithConfiguration in the visible hierarchy.

    Tempura will expose two main navigation actions:

    Show

       Show("screenC", animated: true)
    

    When this action is dispatched, the Navigator will ask screenB (the topmost RoutableWithConfiguration to handle that action, looking at its navigationConfiguration).

    In order to allow screenB to present screenC, we need to add a NavigationRequest inside the navigationConfiguration of screenB that will match the Show("screenC") action with a .show("screenC") NavigationRequest.

       extension ScreenB: RoutableWithConfiguration {
    
         // needed by the `Routable` protocol
         // to identify this ViewController in the hierarchy
         var routeIdentifier: RouteElementIdentifier {
           return "screenB"
         }
    
         // the `NavigationRequest`s that this ViewController is handling
         // with the `NavigationInstruction` to execute
         var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
           return [
             .show("screenC"): .presentModally({ [unowned self] _ in
               let vc = ScreenC(store: self.store)
               return vc
             })
           ]
       }
    

    Inside the .presentModally NavigationInstruction, we create and return the ViewController that is implementing the screenC. This will be used by the Navigator to call the appropriate UIKit navigation action ( a UIViewController.present(:animated:completion:) in this case).

    If the Navigator will not find a matching NavigationRequest in the navigationConfiguration of screenB, it will ask to the next RoutableWithConfiguration in the visible hierarchy, in this case screenA. If nobody is matching that NavigationRequest, a fatalError is thrown.

    Hide

    Same will happen when a Hide function is dispatched.

       Hide("screenC", animated: true)
    

    Looking at the current Route [screenA, screenB, screenC], the Navigator will ask the topmost RoutableWithConfiguration (screenC in this case) to match for a .hide("screenC") NavigationRequest.

    Again, if we want screenC to be responsible for dismissing itself, we just need to implement the matching NavigationRequest in the navigationConfiguration:

       extension ScreenC: RoutableWithConfiguration {
    
         // needed by the `Routable` protocol
         // to identify this ViewController in the hierarchy
         var routeIdentifier: RouteElementIdentifier {
           return "screenC"
         }
    
         // the `NavigationRequest`s that this ViewController is handling
         // with the `NavigationInstruction` to execute
         var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
           return [
             .hide("screenC"): .dismissModally(behaviour: .hard)
           ]
       }
    

    If the Navigator will not find a matching NavigationRequest in the navigationConfiguration of screenC, it will ask to the next RoutableWithConfiguration in the visible hierarchy, in this case screenB. If nobody is matching that NavigationRequest, a fatalError is thrown.

    Initializing the navigation

    In order to use the navigation system you need to start the Navigator (typically in your AppDelegate) using the start(using:in:at:) method. In this method you need to specify a RootInstaller and a starting screen. The RootInstaller (typically your AppDelegate) will be responsible to handle the installation of the screen you specified before.

    Doing so, 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 class Navigator