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
andViewControllerModellableView
. The ViewController is a subclass ofUIViewController
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 becometrue
.When the ViewController becomes visible, the UIKit
UIViewController.viewWillAppear()
will be called and Tempura will setconnected
totrue
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, setshouldConnectWhenVisible
tofalse
.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, callingModellableView.update(oldModel:)
When something happens inside the
ViewControllerModellableView
(or its subviews) the ViewController is responsible to listen for theseInteraction
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
See moreUIViewController.viewWillDisappear()
and Tempura will setconnected
tofalse
, detaching the ViewController from the state updates. If you don’t want this to happen automatically every time the ViewControllet will become invisible, setsholdDisconnectWhenInvisible
tofalse
Declaration
Swift
open class ViewController<V> : UIViewController, AnyViewController where V : UIView, V : ViewControllerModellableView
-
A View used to do ViewController containment This is the View that will contain the View of the managed ViewController
See moreDeclaration
Swift
public class ContainerView : UIView
-
Special case of a
ViewController
that contains aLocalState
.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 aViewControllerWithLocalState
.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 appropriateViewModelWithLocalState
.You can change the global state dispatching Katana actions like in every
ViewController
. You can change theLocalState
manipulating directly thelocalState
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 } }
See moreclass 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 } }
Declaration
Swift
open class ViewControllerWithLocalState<V: ViewControllerModellableView & UIView>: ViewController<V> where V.VM: ViewModelWithLocalState
-
Wraps an array of NavigationRequest into a reference-type object
See moreDeclaration
Swift
public class NavigationRequests : Equatable, ExpressibleByArrayLiteral, CustomDebugStringConvertible
-
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 topmostRoutableWithConfiguration
to handle that action, looking at itsnavigationConfiguration
).In order to allow “screenB” to present “screenC”, we need to add a
NavigationRequest
inside thenavigationConfiguration
of “screenB” that will match theShow("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 theNavigator
to call the appropriate UIKit navigation action ( aUIViewController.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 thenavigationConfiguration
: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 aRootInstaller
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.
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 class Navigator