-
Notifications
You must be signed in to change notification settings - Fork 75
EpoxyBars
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
]
}
}
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
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.
}
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.
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.
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.
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.
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.
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.
- Overview
ItemModel
andItemModeling
- Using
EpoxyableView
CollectionViewController
CollectionView
- Handling selection
- Setting view delegates and closures
- Highlight and selection states
- Responding to view appear / disappear events
- Using
UICollectionViewFlowLayout
- Overview
GroupItem
andGroupItemModeling
- Composing groups
- Spacing
StaticGroupItem
GroupItem
withoutEpoxyableView
- Creating components inline
- Alignment
- Accessibility layouts
- Constrainable and ConstrainableContainer
- Accessing properties of underlying Constrainables