Skip to content

EpoxyBars

Eric Horacek edited this page Feb 1, 2021 · 5 revisions

Overview

EpoxyBars is a declarative API to add fixed top and bottom bars to your UIViewController. Adding these bars is simple:

final class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    topBarInstaller.install()
    bottomBarInstaller.install()
  }

  private lazy var topBarInstaller = TopBarInstaller(viewController: self, bars: topBars)
  private lazy var bottomBarInstaller = BottomBarInstaller(viewController: self, bars: bottomBars)

  private var topBars: [BarModeling] {
    [
      // Instantiate BarModels for the top bars here
    ]
  }

  private var bottomBars: [BarModeling] {
    [
      // Instantiate BarModels for the bottom bars here
    ]
  }
}

Updating the bars

You can update the content or switch the bar views of a bar installer by calling setBars(_:animated:) on a bar installer with an array of bar models representing the bar views you'd like to be visible. Bars are identified between model updates by their view's type. Bar installers use the following heuristics to update each bar view when the bar models are updated:

  • If the model is added in the update, its corresponding view is inserted into the stack.
  • If the view style and the content are the same between model updates, no updates occur to the view.
  • If the view's content is different between model updates, the existing view is reused and is updated with new content via setContent(_:animated:).
  • If the view's style is different between model updates (as determined by the model's styleID), the previous view is removed and replaced with a new view.
  • If the model is removed in the update, the corresponding view is removed from the stack.

BarModel

BarModel is the model type that's used to add a view to a bar installer. It's a lightweight model that should be recreated on every state change so that you can write your bar logic in your features declaratively.

BarModel is initialized with the desired bar content, style, and an optional override data ID used to identify the model between updates (the data ID defaults to the type of the bar view). It supports a chaining syntax to get callbacks on various lifecycle events and customize model properties:

let model = ButtonRow.barModel(
  content: .init(text: "Tap me"),
  behaviors: .init(didTap: { _ in
    // Handle the button being tapped
  })
  style: .system)
  // You can call optional "chaining" methods to further customize your bar model:
  .willDisplay { context in
    // Called when the bar view is about to be added to the view hierarchy.
  }
  .didDisplay { context in
    // Called once the bar view has been added to the view hierarchy.
  }

Animations

Updates to the bar stack are animated when you pass true for the animated parameter to setBars(_:animated:). If the bar views are different between model updates, a crossfade animation is used to transition between the views. Any inserted bars slide in, and any removed bars slide out.

If a previously visible bar receives new content in the animated bar update, setContent(_:animated:) is called on the bar view with the updated content and true for the animated parameter. In this case, it is the responsibility of the bar view to animate the updates.

Keyboard avoidance

To have a BottomBarInstaller's bar stack avoid the keyboard as it is shown and hidden, pass true for the avoidsKeyboard parameter to the BottomBarInstaller initializer or set the equivalently-named property to true after it is created.

Safe area insets

Bar installers adjusts their view controller's additionalSafeAreaInsets by the height of the bar stack view, if it is visible. This ensures that any scroll view content is automatically inset by the height of the bar stack so that the scroll view takes the height of the bar stacks into account when it is at the top or bottom of its content.

Bar views have the view controller's original safe area insets applied to their layout margins. This ensures that bar content does not overlap with the the status bar or home indicator but their background is able to flow underneath it.

It's important to note that since bar views are covered by the safe area, any bar view subviews that constrain their subviews to the layout margins must ensure that insetsLayoutMarginsFromSafeArea is set to false, otherwise you may encounter an infinite layout loop.

Differences from UINavigationBar and UIToolbar

At Airbnb, we use bar installers rather than UINavigationItem/UIBarButtonItem to add top and bottom bars to our screens. Unlike vanilla UIKit, bar installers do not require that your UIViewController is nested within a UINavigationController for the navigation bar and toolbar to be drawn. Instead, the bars are added to the view controller's view hierarchy by the bar installers.

We find that this pattern makes it much simpler to navigate between screens that have bars, as it's no longer a hard requirement to wrap all screens in a UINavigationController just to have the bars drawn.

Furthermore, we find that bar installers are inherently more flexible than UIKit UINavigationItem/UIBarButtonItem, as they support stacks of an arbitrary number of bars, instead of just a single bar.

Advanced use cases

Animated bar height changes

Sometimes a bar view needs to change its height in an animation. As an example, a custom bar view could expand to show more content when the user taps a button. This behavior can be enabled by conforming your bar view to the HeightInvalidatingBarView protocol:

final class MyCustomBarView: HeightInvalidatingBarView {
  func changeHeight() {
    // Can be called prior to height invalidation to ensure that other changes are not batched
    // within the animation transaction. Triggers the bar view to be laid out.
    prepareHeightBarHeightInvalidation()

    UIView.animate(, animations: {
      // Perform constraint updates for this bar view so that the intrinsic height will change.

      // Triggers another animated layout pass that will animatedly update the bar height.
      self.invalidateBarHeight()
    })
  }
}

To animatedly change the height, a bar view should first call prepareHeightBarHeightInvalidation() to perform any pending layout changes, then invalidateBarHeight() within an animation transaction after updating the constraints to trigger the bar to have a new intrinsic height. This will result in an animation where the bar changes height and the bar stack animatedly adjusts other views to accommodate for the height change all in the same animation.

Bar Coordinators

You can optionally specify a "coordinator" for a BarModel by calling the .makeCoordinator method, which takes a closure that is used to produce a coordinator object when the bar view is added to the view hierarchy. A coordinator is an object that exists for as long as the bar view, and is able to receive updates "out of band" from the BarModel updates that it can apply directly to its bar view. For example, bar coordinators can be used for the following types of behavior:

  • To provide navigation actions to navigation bars so that they trigger the correct action, e.g. dismissing the visible view controller or popping the top view controller from a navigation stack without every consumer needing to configure this behavior manually.
  • To allow scroll view offsets to be communicated to the navigation bars so that they can show or hide divider views without needing to recreate the BarModel on every frame draw.
  • To further customize the bar model that will be displayed to the user with additional behavior that the consumer doesn't have knowledge of, e.g. the contextual leading navigation bar button style of an "x" or "<" icon based on the presentation context.

A bar coordinator receives updates from the installer via a BarCoordinatorProperty. For example, we can define a "scroll percentage" property as follows so that bars can be updated whenever the scroll percentage changes:

public protocol BarScrollPercentageCoordinating: AnyObject {
  var scrollPercentage: CGFloat { get set }
}

private extension BarCoordinatorProperty {
  static var scrollPercentage: BarCoordinatorProperty<CGFloat> {
    .init(keyPath: \BarScrollPercentageCoordinating.scrollPercentage, default: 0)
  }
}

extension BottomBarInstaller: BarScrollPercentageConfigurable {
  public var scrollPercentage: CGFloat {
    get { self[.scrollPercentage] }
    set { self[.scrollPercentage] = newValue }
  }
}

With this in place, consumers of the bar installer can now set the scrollPercentage on their BottomBarInstaller every time the scroll offset changes:

bottomBarInstaller.scrollPercentage = ...

These scroll percentage updates are now communicated to any visible bar's coordinator that implements BarScrollPercentageCoordinating. A coordinator can implement BarCoordinating and then update the constructed bar view on every scroll event:

final class ScrollPercentageBarCoordinator: BarCoordinating, BarScrollPercentageCoordinating {
  public init(updateBarModel: @escaping (_ animated: Bool) -> Void) {}

  public func barModel(for model: BarModel<MyCustomBarView>) -> BarModeling {
    model.willDisplay { [weak self] view in
      self?.view = view
    }
  }

  public var scrollPercentage: CGFloat = 0 {
    didSet { updateScrollPercentage() }
  }

  private weak var view: ViewType? {
    didSet { updateScrollPercentage() }
  }

  private func updateScrollPercentage() {
    view?.scrollPercentage = scrollPercentage
  }
}

Finally, to specify that you want to use this coordinator with your MyCustomBarView, just call:

MyCustomBarView.barModel(...)
  .makeCoordinator(ScrollPercentageBarCoordinator.init)

That's it! Now every time the scroll percentage changes, you can update your MyCustomBarView instance with the frequently changing scroll percentage in a performant way.