Table of contents
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 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.
I have created Observable 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.
To make library testable I have implemented Scrollable protocol. I also needed a way to make UIScrollView provide Observables 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.
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 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 a didSet observer of state 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 and BarController 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 of UIScrollView is being ignored and does not affect BarController state;
- ignoreBottomDeltaYTransformer — same as ignoreTopDeltaYTransformer, 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.
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 current StateRange.
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.
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.
Thank you for reading!