Bottom Sheet, shall we drop the formalities?
At first glance, Bottom Sheet appeared all too complicated and inaccessible to me. It was a challenge! I had no idea where to begin. A bunch of questions appeared in my mind. To name a few. Should I use a view or a view controller? Auto or manual layout? How do I animate it? How do I hide it interactively?
However, everything’s changed after working on the bottom sheet in the Joom app, where it‘s used everywhere. Even in such critical scenarios as payment flow, so we’re truly confident in this component that I shared our experience on Podlodka iOS crew #7. As part of the workshop, I showed how to make a bottom sheet adapt to the content size, dismiss interactively, and support UINavigationController.
Wait, Apple has already presented a native way to present the bottom sheet. Why write your own? Though it’s true, this component is only supported from iOS 15, which implies that it will be available only in 2–3 years. Besides, designer requirements often change and go beyond native iOS elements, so you end up with your own implementation anyway.
In this article, I want to clear the air over Bottom Sheet, answer the questions I’ve faced myself, and suggest one of the possible implementations. So after you’re through with this article, you could deservedly add the “proficient with Bottom Sheets” line to your CV.
If you’re interested, let’s get started! We’ll create a simple bottom sheet and upgrade it step by step. Along the way, you’ll:
- Learn how to adapt the bottom sheet to the content size and how to dismiss it.
- Add interactive dismissal, taking scrollable content into account.
- Support UINavigationController with navigation inside the bottom sheet.
Part 1. Adapting to content size. Bottom Sheet dismissing. Basic design
First of all, let’s make sure we’re on the same page with the term “Bottom Sheet”. Bottom Sheet is a component that sits at the bottom and adapts to the size of the content. Here are some examples of its use in some system applications: Apple Maps (search), Stocks (news), Voice Memos (voice recording), etc.
The starter project
Download the starter project via the Github link. Once downloaded open BottomSheetDemo.xcodeproj and look around. You’ll see two targets in the project: BottomSheetDemo and BottomSheet — an application and a library with the bottom sheet.
RootViewController is the first screen in the application. It has only one “Show Bottom Sheet” button which presents ResizeViewController on tap.
ResizeViewController takes the height of the content in the initialiser. There are also four buttons that change the height of the content: by +100 and -100, by 2 and 0.5 times.
Let’s see it in action.
Theory. How do I present a Bottom Sheet?
We need an entity to control the presentation, which will add a bottom sheet to the UI hierarchy, position it on the screen, account for the size of the content, respond to changes in it, take care of animation, and enable the interactive closing.
This sounds like a UIPresentationController’s responsibility:
From the time a view controller is presented until the time it is dismissed, UIKit uses a presentation controller to manage the presentation process for that view controller.
Armed with this knowledge, let’s start making the bottom sheet!
Let’s create a presentation controller
To show a bottom sheet, let’s override modalPresentationStyle and transitioningDelegate. Don’t forget that transitioningDelegate is a weak reference, and we need a strong one for not losing the object.
Create BottomSheetTransitioningDelegate, an implementation of transitioningDelegate.
And presentation controller.
Finally, go back to RootViewController and resolve TODO.
Let’s run the application.
Looks like it just got worse. The view controller’s opened up in full screen and hidden behind the status bar. We’ve overridden the system presentation controller that showed the view controller pretty well and positioned it according to Safe Area. There is nothing like this in our presentation controller — we haven’t specified the view controller’s position in any way, not to mention Safe Area, so let’s fix that.
Taking content size into account
Let’s go back to ResizeViewController. The currentHeight property is self explanatory and accounts for the current height. To avoid creating excessive protocols, we’ll use preferredContentSize to represent the Bottom Sheet’s desired size.
Now let’s override frameOfPresentedViewInContainerView, which accounts for the position of presentedView in the presentation controller. In our case, presentedView is a ResizeViewController’s view and containerView holds it.
Additionally, we set shouldPresentInFullscreen to false, because Bottom Sheet does not cover the whole screen.
Now, let’s see the result
The original size is now considered, but there is no reaction to its change.
Reacting to content change
Let’s see UIPresentationController. It implements UIContentContainer, and thus preferredContentSizeDidChange(forChildContentContainer:), which is invoked after preferredContentSize is changed in one of the child view controllers.
Here we check the current frame and the one we believe to be correct. If they are different, we refresh the presentedView frame. Let’s run the application.
The size changes abruptly and without animation. Why? Because we didn’t include this animation in any way. Let’s add an animation on preferredContentSize changes in ResizeViewController.
Now, check it again.
It works! But here’s another problem — we can’t close it.
Bottom Sheet dismissal
To dismiss it, we’ll need a shadow which’ll close the bottom sheet on tap. Also, we’ll need a dismissal handler by which the presentation controller will report that it’s ready to be closed.
Let’s inject it into the presentation controller initialiser.
For convenience, let’s add a presentation controller factory.
It will be used within BottomSheetTransitioningDelegate,
We’ll implement the factory with the dismiss handler in RootViewController.
Now let’s configure a shadow with dismiss handler in the presentation controller. We should add the shadow before the transition starts and remove it when it’s over.
To begin, we need to track the presentation controller’s state. Let’s introduce a state property, which will be in charge of the Bottom Sheet’s current state. Let’s override the transition lifecycle methods and change state accordingly.
Then the question arises as to when we should add and remove the shadow. We’ll add the shadow before presenting the bottom sheet, and remove it right after it’s hidden
Finally, we can implement addSubviews() and removeSubviews()
Now, let’s see how it works.
Great! Now we got the shadow, and the bottom sheet’s dismissed by tap on it! But the shadow appears and disappears without any animation.
What should we do? The shadow relates to the transition, so it should be animated along. Therefore, we need to inline it into the transitioning delegate.
Let’s implement the UIViewControllerAnimatedTransitioning protocol the same way iOS already does it, but we’ll also include a fade-animation for the shadow.
Also, don‘t forget to implement the relevant methods in the BottomSheetTransitioningDelegate.
Let’s make sure that the animation works.
And it does! To finish our bottom sheet, we need to round the top corners.
Rounding the corners
We can make it via presentedViewController’s cornerRadius in the presentation controller. It needs to be done before the transition starts in presentationTransitionWillBegin().
Finally, let’s have a look at the corners.
What we’ve accomplished so far:
- We overridden the system transitioning delegate.
- We created a presentation controller.
- We added a shadow to hide the bottom sheet via the dismiss handler.
- We implemented an animated transition via the transitioning delegate.
- We made the basic design.
Part 2. Interactive Bottom Sheet dismissal
Like in the first part, let’s begin with the starter project. A pull bar has been added to indicate that the bottom sheet can be closed by the swipe-down gesture. Also, a scrollView has appeared full screen in ResizeViewController. We will need it for list-based screens. The rest is from part one.
Let’s see the application.
Theory. Specifics of Interactive dismissal
UISwipeGestureRecognizer is an obvious choice for detecting swipe gestures. It will initiate the bottom sheet dismissal.
But what if the presented controller already has this gesture? It may cause a conflict of gestures since it’s not clear which one to process first.
Is it common for the presented controller to have this gesture? In fact, all the time. In modern applications, 99 % of screens are list-based, which means that each one has a UIScrollView or its descendants, UITableView or UICollectionView, which have exactly the same gesture. What should we do?
Let’s break it down into two cases, without and with UIScrollView.
- If there’s no UIScrollView, we simply add a swipe gesture.
- If it’s available, the content may fit:
- Completely. Then the bottom sheet’s size will be less than the screen, so it can be closed with a swipe right away.
- Partially. Then a swipe may also mean content scrolling. Let’s assume that the user scrolls to close the bottom sheet when contentOffset is zero. Otherwise, their intention is content-scrolling.
If UIScrollView exists, we subscribe to contentOffset changes and judge by them when interactive dismissing can be started.
Now that we have the plan, let’s implement it!
If there is no UIScrollView
Then we add the pan gesture to presentedView. At what point should we do this? The gesture initiates interactive dismissing and the bottom sheet can be closed only if it’s fully appeared. So it makes sense to add the gesture at the end of the presentation.
And write the function that adds the pan gesture to the given view.
Let’s break down each state of the gesture.
began — the user has just started sliding the finger, and the gesture has been recognised as the pan gesture. We initiate the bottom sheet dismissing.
changed — the user continuously slides the finger across the screen. We hide the bottom sheet proportionally to the distance the user’s finger has travelled across the screen.
ended — the user hass taken fingers off the screen. We decide if we dismiss the bottom sheet or return it to its original position.
cancelled — the gesture is cancelled. We return the bottom sheet to its original position.
Additionally, we will use UIPercentDrivenInteractiveTransition to pass the transition state to the transitioning delegate.
Let’s start with the began state. This is the right moment to initialize interactive dismissing because this state occurs only once. We also call dismiss from presentingViewController to notify UIKit of the intention to close the bottom sheet.
Now let’s proceed with the changed state. Change the position of presentedView proportionally to the distance travelled by the user’s fingertip across the screen. We measure the distance from the starting point, where the gesture began, to the current position. Next, we calculate transition progress relative to the height of the content view controller, i.e., presentedView.
Once the user takes their finger off the screen, the gesture switches to the ended state. We need to figure out what the user wants — to scroll the content or to dismiss the bottom sheet. If the user moved their finger abruptly, travelling a very short distance, they most likely wanted to dismiss the bottom Sheet. Another matter is if the user’s finger travelled a long way across the screen and, at the very last moment, sharply took their finger off the screen, aiming upwards. In this case, the bottom sheet should return to its original position. This suggests the idea of calculating some kind of movement momentum that would account for both acceleration and direction.
A bit of physics. Imagine that there is a body that moves with a constant velocity v₀. Then, at a distance x₀, it comes under the influence of a deceleration a. The question is, where will the body stop?
The velocity formula is v(t)=v₀﹢a﹡t
With negative acceleration the velocity becomes zero, let’s denote this moment in time as t₁ and put it in the velocity formula:
Next let’s put t₁ in the distance formula x(t) = x + v₀ ﹡ t + a ﹡ t²/2
Then, we finally get the formula for the distance x₀−0.5 ﹡ v₀²/a, where x₀ is the body’s initial position, v₀ is its initial velocity and a is the deceleration.
Let’s assume that Bottom Sheet is the body from the problem above when the gesture is over. Then, the current speed and the travelled distance can be found through pan gesture. Let’s assume the deceleration to be a constant of 800. As we know the distance formula is x₀−0.5 ﹡ v₀²/a, so we can calculate where the bottom sheet will stop affected by deceleration. If the stopping point is closer to the starting point, we cancel the transition, otherwise we carry it out to the end.
If the gesture is cancelled, we return to the starting point.
Finally, we should return interactiveTransitioning to the transitioning delegate.
Now let’s run the application and see what happens.
Woohoo, we’ve taught our bottom sheet to close on a swipe down! However, if the content is larger than the screen, scrollView will intercept the gestures.
The list-based screens break down
Even though ResizeViewController was already a list-based type, it didn’t prevent us from adding a pan gesture. It’s because scrollView has no scroll when contentSize is equal to its size.
Therefore, let’s consider the opposite case when contentSize is larger than the bottom sheet, and scrolling works. First of all, we should subscribe to contentOffset. Then, if contentOffset is zero and the user is scrolling down, then we initiate dismissing. When the user releases their finger from the screen, we’ll either cancel or finalise the transition, just like we did before. If contentOffset is changed and the user does not touch the screen, then scrolling goes on by inertia and we shouldn’t do anything.
Firs of all, we need an indicator which would tell us whether a view controller has a scrollView. Let’s introduce a protocol for this.
To track contentOffset changes we subscribe to UIScrollViewDelegate. But what if someone has already subscribed to UIScrollView delegate? Then we’ll overwrite the previous delegate.
So we will use MulticastDelegate to forward UIScrollView’s delegate invocations to all interested parties and not just one. In a classic iOS delegate pattern, only one object at a time can subscribe, and it’s not really convenient. That’s why MulticastDelegate is introduced. By using it, firstly, we ensure that the delegate property is not overwritten. Secondly, we proxy all invocations to subscribers, and such an approach doesn’t require any changes in the existing codebase. It’s implemented in Objective-C because of its runtime capabilities and ability to use message dispatching. Though we could use Swift, it would be much more verbose and less universal — for each type with delegate new instance should be implemented with all the methods.
We subscribe to delegate after the end of the transition, just like we did with the pan gesture.
Then we should implement UIScrollViewDelegate. In the first place, let’s make some helpers
Next, let’s consider the moment when the user is about to scroll the content. At this moment, the user has just started a swipe gesture. Remember this state with the isDragging flag.
Next, let’s make the helper function, which will help us to determine whether transition progress needs to be started or carried on.
We check that the user is now sliding the finger across the screen. If so, we check if the transition should be carried on.
If the transition hasn’t started or has been just initiated, it should be continued if the user scrolls down and the content is at the top (check it via isContentOriginInBounds). If we’re in the middle of the dismissal, we just continue the transition.
Then let’s see when contentOffset is changed in scrollViewDidScroll(:_).
startInteractiveTransitionIfNeeded() will initiate the interactive transition if we haven’t already done so.
In scrollViewDidScroll(_:) we’re checking whether we can continue/start the interactive transition. If we can start or continue, we initiate the transition. If we can’t, we should remember the last content offset before the transition was activated. We’ll need it later.
💡 Remember how you were asked during interviews when a View’s bounds origin is not zero? And you probably replied: When the content offset of scrollView is non-zero. But it is not clear how this could be used in practice, is it? Below you will see how having this knowledge may be handy!
Now let’s make sure that content is tied to the top and equate contentInset to contentOffset. We change contentOffset via bounds.origin to avoid scrollViewDidScroll(_:) invocation. In the end, we update the transition progress.
Once the user takes the finger off the screen after scrolling, we should finalise or cancel the transition, which is the same as we did when the pan gesture is in the ended state.
Via didStartDragging, we check if the bottom sheet interactive dismissal was active before the end of scrolling.
If yes, then, just like with pan gesture, we use the impulse to decide if we cancel or finalise the transition.
If not, we cancel the transition. It’s also possible that the user started dismissing the bottom sheet and then returned to content scrolling. In this case, the transition’s progress is zero, and we definitely want to cancel it.
Now let’s see what we’ve got.
So, we’ve learned to work with all Bottom Sheet sizes.
What we’ve achieved in the second part
- We’ve made truly interactive transition using pan gesture and scrollView.
- We’ve implemented multi-subscriptions pattern to any delegate via MulticastDelegate.
Part 3. Maintaining UINavigationController
F inal starter project is already waiting for you. Two new buttons have been added to ResizeViewController, which are visible if a navigation controller is present. The first one pushes ResizeViewController with the current content height, and the second one pops to rootViewController. The rest is from part two.
In part three, our goal is to support the navigation controller inside the bottom sheet with standard push and pop operations, preserving interactive pop.
Theory. Will it work out of the box?
Can I use the system UINavigationController directly? Unfortunately, no.
The navigation controller does not fully support preferredContentSize. The original content size and its increase work as expected. But the navigation controller does not react to its downsizing. When tapping -100, the size doesn’t change.
So we will definitely need a subclass of UINavigationController, capable of tracking changes in the navigation stack and updating its own preferredContentSize according to topViewController.
When tracking scrollView in the presentation controller, we need to take into account that presentedViewController can be UINavigationController. Additionally, when changing the navigation stack, we need to extract scrollView from the current topViewController, if there is one.
And the final note. UINavigationController is packed within an iOS SDK version with its own features. As we will see later, these features will come to light and become a nudge. We will discuss what we can do about it later.
Adapting to content size
We’ve already made content size adaptation in the first part, but it doesn’t work with a navigation controller. The system navigation controller doesn’t fully respect preferredContentSize changes. So let’s create a descendant of UINavigationController and use the UIContentContainer feature.
In updatePreferredContentSize, we account for topViewController and additionalSafeAreaInsets.
Similar to the presentation controller, we react to content size changes via preferredContentSizeDidChange(forChildContentContainer:). Remember that we should take care of the animation ourselves when changing preferredContentSize. So let’s add animation to the navigation controller and remove it from ResizeViewController.
In the presentation controller, we should bear in mind that the presentedViewController can be a UINavigationController. In this case, we need to track scrollView inside the current topViewController. Let’s update setupScrollTrackingIfNeeded() accordingly.
To track changes in the navigation stack we subscribe to delegate via the same MulticastDelegate pattern we did before. We’ll keep an eye on scrollView when presenting the view controller.
Let’s check the result.
The navigation controller is now responsive to changes in content size, but when switching back, the size is not involved in the animation.
Animating the transition push and pop
Why does it happen? Because the system implementation of the navigation controller fails to account for preferredContentSize when changing the navigation stack. Therefore, we need to update content size every time changes are made to the navigation stack.
With this in mind, let’s introduce a helper function for updating the stack and preferredContentSize together. Where possible, we resize content with an animation via transitionCoordinator. It is vital to update the stack first and only then the content size. Otherwise, topViewController will not be up to date.
Finally, let’s implement UINavigationController’s methods that change the navigation stack via updateNavigationStack(animated:applyChanges:).
Now let’s run the application and see what we’ve got.
It got better and the content size is now considered, though with some artifacts. Let’s have a look at iOS 12.
Transition quality has worsened, and the content size is the same as the previous one after the pop.
It turns out that we can’t fully count on the system transition of UINavigationController, and we also have to implement it by ourselves. Let’s implement UINavigationControllerDelegate, in which we redefine the push and pop transitions.
Next, let’s implement UINavigationControllerDelegate and define the transition’s animation.
Let’s run the application and pay attention to the animations.
Everything works smoothly, as we expected!
When we overrode push and pop transitions, we lost the system interactive pop transition, which had previously functioned out of the box. So we’ll have to implement it by ourselves.
But how do we replicate the system interactive pop transition? Let’s see what we have in UIKit… UIScreenEdgePanGestureRecognizer! It’s used by iOS for exactly this kind of gesture.
We’ll add this gesture for the view controller, which has been pushed into the navigation controller’s hierarchy. Below, I’m giving only critical segments of the code.
In the handler, we do the same as for the pan gesture. Then we initiate the interactive transition, updating progress proportionally to finger movement, and use the same criterion for cancelling and finalising the transition.
What remains is to account for the interactive transition in UINavigationControllerDelegate. We configure an interactive pop gesture on push operation.
Let’s check it now.
And yay, we’ve made it through our last challenge!
Let’s summarise what we’ve done in the last part:
- We’ve adapted UINavigationController to content size.
- We’ve achieved the right push and pop transitions in iOS 12+.
- We’ve kept a unique interactive pop.
Thank you for reading to the end! Now you can update your resume. We started from scratch and ended up with a multifunctional bottom sheet. We answered the questions we asked at the beginning of the story, and even recalled school physics!
I hope you found this helpful and now the bottom sheet in your app will play out in fresh colours!