How to implement expandable menu on iOS (like in Airbnb)
A few months ago I had a chance to implement an expandable menu that behaves same as the one in a popular iOS application (like Airbnb). Then I thought it would be great to have it available in form of a library. Now I want to share with you some solutions that I have created in order to implement beautiful scroll driven animations.
The library supports 3 states. The main goal is to achieve smooth transitions between states while scrolling UIScrollView
.
UIScrollView
UIScrollView
is an iOS SDK view that supports scrolling and zooming. It is used as a superclass of UITableView
and UICollectionView
, so you can use them wherever UIScrollView
is supported.
UIScrollView
uses UIPanGestureRecognizer
internally to detect scrolling gestures. Scrolling state of UIScrollView
is defined as contentOffset: CGPoint
property. Scrollable area is union of contentInsets
and contentSize
. So the starting contentOffset
is CGPoint(x: -contentInsets.left, y: -contentInsets.right)
and ending is CGPoint(x: contentSize.width — frame.width+contentInsets.right, y: contentSize.height — frame.height+contentInsets.bottom)
.
UIScrollView
has a bounces: Bool
property. In case bounces
is on contentOffset
can change to value above/below limits. We need to keep that in mind.
We are interested in contentOffset: CGPoint
property for changing our menu state. The main way of observing scroll view contentOffset
is setting an object to delegate property and implementing scrollViewDidScroll(UIScrollView)
method. There is no way to use delegate without affecting other client code in Swift (because NSProxy
is not available) so I have decided to use Key-Value Observing.
Observable
I have createdObservable
class that can wrap any type of observing.
And two Observable
subclasses:
KVObservable
— for wrapping Key-Value Observing.
GestureStateObservable
— for wrapping target-action observing of UIGestureRecognizer state.
Scrollable
To make library testable I have implemented Scrollable
protocol. I also needed a way to make UIScrollView
provide Observable
s for contentOffset
, contentSize
and panGestureRecognizer.state
. Protocol conformance is a good way to do this. Apart from observables it contains all properties that library needs to use. It also contains updateContentOffset(CGPoint, animated: Bool)
method to set contentOffset
with animation.
I have not used a native setContentOffset(...)
method of UIScrollView
for updating contentOffset
cause UIKit
animations API is more flexible IMO. The problem here is that setting contentOffset
directly to property doesn’t stop UIScrollView
deceleration, so updateContentOffset(…)
method stops it via setting current contentOffset
without animation.
State
I wanted to have predictable menu state. That is why I have isolated all mutable state in State
struct that contains offset
, isExpandedStateAvailable
and configuration
properties.
offset
is just an inverted height of menu. I decided to use offset
instead of height
, because scrolling down decreases height
when scrolling up increases. offset
is being calculated like offset = previousOffset + (contentOffset.y — previousContentOffset.y)
;
isExpandedStateAvailable
property determines should offset go below-normalStateHeight
to-expandedStateHeight
or not;configuration
is a struct that contains menu height constants.
BarController
BarController
is the main object that makes all state calculation magic and provides state changes to users.
It takes stateReducer
, configuration
and stateObserver
as initializer arguments.
stateObserver
closure is called on adidSet
observer ofstate
property. It notifies library user about state changes.stateReducer
is a function that takes previous state, some scrolling context params and returns a new state. Injecting it through initializer provides decoupling between state calculation logic andBarController
object itself.
Default state reducer calculates difference between contentOffset.y
and previousContentOffset.y
and applies provided transformers one-by-one. After that it returns new state with offset = previousState.offset + deltaY
.
The library uses 3 transformers for reducing state:
ignoreTopDeltaYTransformer
— makes sure that scrolling above top ofUIScrollView
is being ignored and does not affectBarController
state;
ignoreBottomDeltaYTransformer
— same asignoreTopDeltaYTransformer
, but for scrolling below bottom;
cutOutStateRangeDeltaYTransformer
— cuts out extra delta Y, that goes out of minimum/maximum limits of BarController supported states.
BarController
calls a stateReducer
and sets result as a state
every time contentOffset
changes.
For now the library is able to transform contentOffset
changes into internal state changes, but isExpandedStateAvailable
state property is never being mutated as well as state transitions are not being finished.
That is where panGestureRecognizer.state
observing comes in:
- Pan gesture sets
isExpandedStateAvailable
state property to true in case panning began in the top of scrolling or in case we already have an expanded state;
- Pan gesture change sets
isExpandedStateAvailable
if state offset reached normal state;
- Pan gesture end finds offset that is most near current state, adds a difference to current content offset and calls
updateContentOffset(CGPoint, animated: Bool)
with result content offset to end state transition animation.
So expanded state becomes available only when the user starts scrolling at the top of available scrollable area. If expanded state was available and user scrolls below normal state, expanded state turns off. And if the user ends the panning gesture during state transition BarController
updates content offset with animation to finish it.
Binding UIScrollView to BarController
BarController
contains 2 public methods that the user can use to assign UIScrollView
. In most cases the user should use set(scrollView: UIScrollView)
method. There is also preconfigure(scrollView: UIScrollView)
method, it configures the scroll view’s visual state to be consistent with the current BarController
state. It should be used when the scroll view is about to be swapped. For example the user can replace current scroll view with animation and want second scroll view to be visually configured in the beginning of animation. After animation completion the user should call set(scrollView: UIScrollView)
. preconfigure(scrollView: UIScrollView)
method is not needed to be called if UIScrollView
is set once, cause set(scrollView: UIScrollView)
calls it internally.
preconfigure
method finds difference between contentSize
height and frame height and puts it as a bottom content inset so that the menu remains expandable, configures contentInsets.top
and scrollIndicatorInsets.top
and sets initial contentOffset
to make the new scroll view visually consistent with the state offset.
API
To inform users about state changes BarController
calls injected stateObserver
function with changed State
model object.
State
struct has several public methods for getting useful information from internal state:
height()
— returns reversed offset, actually height of menu;
transitionProgress()
— returns transition progress from 0 to 2, where 0 — compact state, 1 — normal state, 2 — expanded state;
value(compactNormalRange: ValueRangeType, normalExpandedRange: ValueRangeType)
— returns transition progress mapped to one of 2 range types according to the currentStateRange
.
Here is an example from AirBarExampleApp
with a use of State
public methods. airBar.frame.height
is animated with height()
and backgroundView.alpha
is animated using value(...)
. Background view alpha here is interpolated from transition progress to (0, 1)
range in compact-normal
transition and constantly 1
in normal-expanded
transition.
Conclusions
As a result, I got a beautiful scroll driven menu with predictable state and a lot of experience working with UIScrollView
.
The library, example application and installation guide can be found here:
Feel free to use it for your own purposes. Let me know if you have any difficulties with it.
And what is your experience working with UIScrollView
? And with scroll driven animations? Feel free to share / ask questions in the comments, I would be glad to help.
Thank you for reading!
We did the investigation of the topic for the Freebird Rides app we’ve built here at UPTech.
If you find this helpful, click the 👏 below so other can enjoy it too. Follow us for more articles on how to build great products.