Something I’ve been interested in a lot lately is attempting to recreate existing User Interface components or User Interactions that are seemingly unique, complex and interesting enough to pique my interest. I get a little bit of enjoyment out of diving deeper into how they might work internally or the problems they might solve. I deliberately choose not to reverse engineer the source code to find out exactly how they were written, instead I try to think about how the component works from a visual perspective and I work backwards from there. I ask myself, “How would I build this, if I had to build it from scratch?”
Apple recently released Siri Shortcuts, an application that allows people to create fun automation scripts and recipes that integrate deeply into the operating system and with third party apps. I’ve been playing around with it, figuring out how it works and what it’s capable of. I’ve created Shortcuts that tell me where my local iOS Engineers coffee meetup is every week, what time my bus arrives, that perform edits on my photos and keep track of my daily caffeine intake.
While the app boasts a relatively standard looking interface that conforms to design patterns shared across the entire operating system — looks can be deceiving. Shortcuts’ User Interface is actually a complicated system that introduces entirely new paradigms specific to the entirely new automation problem domain it operates within. Where it shines is in the way that it presents these components in a way that feels simple, clear and familiar to a user that has likely seen something just like it before.
It’s obvious that a lot of hard work has been done under the covers to make everything feel clean, simple and familiar. It’s one of the main reasons why I love Shortcuts so much.
The Drawer View
As you begin creating Shortcuts one of the first thing’s you’ll notice is the ‘Drawer View’ component that sits at the bottom of the screen. It can be pulled out, tapped on, extended, scrolled and closed in many different ways. It presents a list of steps and integrations that can be added to a Shortcut. In true Apple fashion, the ‘Drawer View’ is a unique component in that it is there when you need it, but it can be pushed out of the way when it’s not needed, freeing up the screen space to do other things. It’s not a Modal, or a Push, so as a user you’re never taken too far away from the task at hand.
It looks like a simple enough component at first, but as you begin to look closer you’ll start to realize it’s actually doing a lot in order to create a seamless, unobtrusive editing experience.
I decided to spend some time trying to re-create the Siri Shortcuts Drawer View myself so that I could try to understand the complexity behind it, the interaction problems it solves and to learn more about UIKit.
It’s worth noting— My implementation is far from perfect
I didn’t write this in order to achieve a perfect, pixel for pixel recreation of the original, in fact my implementation falls short in many ways that the original doesn’t. Instead this is a hack-y, late-night experimentation fueled by curiosity. It’s a journey through understanding how the real component works and the subtle details that are otherwise invisible to the naked eye.
What becomes most obvious when playing around with Shortcuts is that the drawer view has three core states that it is always switching between.
It’s tucked down at the bottom of the screen, at its most compact size. It displays a Search Bar which is tappable and the view itself can be pulled up.
It’s slightly taller, and displays a Search Bar, a title label and a small list of options or cells that can be tapped, and the view itself can be pulled up but the list cannot be scrolled.
It’s at its largest size, almost the entire height of the containing view, although there’s still a slight gap above. It displays a Search Bar, title label and a long list of options or cells that can be tapped and the list (Scroll View) can be scrolled revealing more option cells. The view itself can be pulled down to return back to its previous smaller states.
What are the functional requirements of the component that my recreation must achieve in order to satisfy the solution?
- Needs to display a view of content at the bottom of the screen
- Needs to present the view above another visible view of content
- Needs to enable interaction with both the view itself and it’s content view simultaneously
- Needs to enable pulling up and down on the view to expand and compress it
- Needs to snap between three distinct heights “compressed”, “expanded” and “full height”
- Needs to enable scrolling through the content view’s scroll view, as well as panning the view itself
- Needs to darken the background of the containing view when it is at its “full height”.
- Needs to have smooth, buttery spring animations as it switches between states
- Needs to transition between scrolling content and panning the view seamlessly, without lifting a finger
- Needs to feel and scroll fast but not have a significant performance impact on the device
- Needs to look like a native iOS component and make use of only standard UIKit interface elements
In a production environment it might also need to support Accessibility (Voice Over, Dynamic Text, Screen Readers etc), Localization, Re-Usability and different forms of internal content.
Architecting the Component
Speculating on potential solutions, diagramming the View Hierarchy and thinking about the APIs that UIKit provides which could be used to build this component, an obvious solution stood out — Child View Controllers.
Child View Controllers allow a Parent View Controller to add a View Controller to its view as a Child View Controller. Each View Controller responsible for handling their contained subviews. It’s a way to decouple a View and the logic around its subviews from a View Controller, making it re-usable in various other places in the User Interfaces. It allows two View Controllers to be displayed within a single UIWindow simultaneously.
Primary View Controller
Applying this to the Drawer View would mean having a parent
PrimaryViewController responsible for presenting a child
DrawerViewController within its view. The parent would be responsible for controlling all of its own content — in this case a
UITableView containing various cells and subviews representing the steps in a Shortcut.
It would also be responsible for coordinating the child, interpreting user input and translating or resizing the child’s frame and position within its own coordinate system.
Drawer View Controller
Our child would be responsible for controlling all of its own content — a
UITableView containing cells, a search bar and title label. Interpreting user inputs on its own view and communicating them with its parent. Handling its content based on the state of its view within its parent.
Translating User Input and Finger Movement into translations and movements of the views in the view hierarchy requires a
UIPanGestureRecognizer added to the child view controller. It will listen for pan gestures and provide information about the touch events such as — the state (Began, Changed, Ended, Cancelled), the translation (position of the touch in relation to its starting point), the velocity (speed of the pan in points per second).
Pan events are handled by the child update itself accordingly, and communicate the events to its parent so that the parent can translate or resize the child within its coordinate system. Put simply, if the user drags the child upwards from the Compressed state, the parent will use the state, translation and velocity to transition the child into its Expanded state. If the user drags the child back down, the parent will shrink it back to its compressed state.
Handling Touch Events
When a Pan Gesture has begun and is changing (as the user’s finger drags) the translation and velocity need to be sent to the parent view controller so that it can move the child view controller to its appropriate position in its view. These values are communicated to the parent using a delegate (
The parent disables
userInteractionEnabled on the child’s view, preventing unwanted touches or scrolls as the user’s finger is dragging up and down between states. Geometric calculations are done to check that the child view has not been moved too far to the top of the parent’s view. The
containerViewTopConstraint (top of child’s view to top of parent’s view) is updating with the y translation of the pan gesture, causing the top of the child’s view to follow the user’s finger as it drags.
When a Pan has ended it needs to perform the core of the geometric calculation to determine which state the Drawer View needs to be transitioned to.
Firstly it calculates the
containerViewTopConstraint for each expansion state of the Drawer View based on static height values. It uses the y Velocity of the pan gesture and a static threshold to determine if the pan is fast or slow. If the pan is slow it determines which state the is
containerViewTopConstraint closest to and animates the constraint to the corresponding state’s constraint value.
If the pan is fast, uses the
previousContainerViewTopConstraint value to determine which state the Drawer View is coming from, and the closest it is to. For example, if the Drawer View is being panned from the Compressed state to the top of the screen, it will snap to its Full Height position. If it was previously in the Expanded state and is moving down, it will snap to its Compressed state.
Animation from one state to another is applied with
Velocity parameters. These values are used to calculate a
springDampening that makes the animation feel smooth, fluid and snappy. Calculation divides the gesture’s velocity by the distance of the old constraint from the new. We apply it to animate the
containerViewTopConstraint from its previous value to the corresponding state’s constant value.
Background Color Overlay View
As the Drawer is transitioning from its Expansion state to the Full Height the parent animates the alpha of a
backgroundColorOverlayView based on the progress of the transition. Progress is calculated as the —
currentDistance / totalDistance
which outputs a value between 0.0 and 1.0 that can be applied directly to the alpha property of the
backgroundColorOverlayView to reveal a dark fade on top of the parent’s content. It’ll feel as though the user is controlling the progress of the animation with their finger.
UIPanGestureRecognizer vs UIScrollView
To handle pan gestures a UIPanGestureRecognizer is added to the child view controller’s view. Nested inside the child’s subviews is a
UITableView displaying cells representing Shortcut options. Internally UITableView’s
UIScrollView uses a
UIPanGestureRecognizer to drive the scrolling interaction. Obviously these nested
UIPanGestureRecognizers are a recipe for complexity.
What determines which recognizer handles a given touch event?
func gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) -> Bool
A gesture recognizer’s delegate can control how the recognizer behaves when it is triggered alongside another
UIGestureRecognizer. It can choose to override the other recognizer, or let the other recognizer take a given touch event by returning
In the case of the Drawer view providing a delegate to the child’s
UIPanGestureRecognizer enables the child to determine if a touch event is handled by the drawer itself as a pan, or the
UITableView as a content scroll. To determine that the Velocity, ExpansionState and y ContentOffset of the table view are used to determine the direction of the pan (Up or Down) and the y contentOffset to determine the position within the
UIScrollView. If the Drawer is in its Compressed or Expanded states the user should not be able to scroll the UITableView’s content — so we return false. If the Drawer is in its Full Height position we want to —
- Allow the user to scroll down through the
- Allow the user to scroll up through the content until they reach the top.
- Stop the user from scrolling down while at the top of the
UITableView’scontent and rubber banding the
UIScrollView. This gesture is used to pan the Drawer down into its Expanded or Compressed state.
Using the Velocity, if the user is panning Down
true is returned. If the user is panning Up, the y contentOffset is checked and
false is returned as needed.
Seamless Transition between UIScrollView and UIPanGestureRecognizer
One of the smallest, and most subtle details that is probably entirely invisible to many users of Shortcuts is the way that the Drawer seamlessly transitions from scrolling through the
UITableView’s content to translating the entire Drawer View Up or Down. It’s a subtle yet delightful interaction that I tried to replicate as much as possible — my solution is likely far more tacky than the original.
Identifying that the Pan gesture is Up in the Full Height state inside
shouldHandleGesture flag is set to
false. Whenever the gesture recognizer’s
panGestureDidMove() is triggered this will ensure incoming touch events are ignored — ensuring the user doesn’t push the Drawer higher than its Full Height state.
scrollViewDidScroll(_:) on the
UITableViewDelegate allows checking if the Drawer is Full Height. If it is we check the y contentOffset is less than 0.0 (top of scroll view), and the velocity is ≥ 0.0 (meaning the user is trying to rubber band the UITableView’s Scroll View). Instead of rubber banding the Scroll View — the
UIPanGestureRecognizer needs to seamlessly take over and continue the motion by translating the Drawer View.
Enabling and Disabling
isScrollEnabled on the ScrollView ends the current scroll interaction from the user’s finger.
shouldHandleGesture is enabled, causing the
UIPanGestureRecognizer to begin handling pan events again. At this point in time the transition is made from the Scroll View to the UIPanGestureRecognizer underneath the user’s dragging finger.
Note: This might admittedly be a little hack-y, I spent a fair amount of time reading through the documentation for UIPanGestureRecognizer and UIScrollView to find a cleaner way to pass the touch events but turned up nothing.
Adding Sample Content Cells
Sample cells to display within the Drawer View are created (
DrawerTableViewCell), the cell is registered for reuse with the
UITableView and static sample data is passed randomly to each cell.
UITableViewDelegate are implemented with a static number of cells. Cells are presented in the Table View making the Drawer View look like the original.
For the most part the core interactions of the Siri Shortcuts Drawer View are implemented. I didn’t have access to the exact measurements of views, the exact variables for spring and dampening in the transition animations — I’m not even sure if the original implementation uses UIView animations over
UIPercentDrivenInteractiveTransition. I eye-balled many details including the positions and heights of the Drawer in its different states. My experimental implementation far from exact, admittedly likely missed a few key details.
Compiling and running the project in the iOS Simulator, I now have a working implementation of the Siri Shortcuts Drawer View. It starts in the Compressed state; I can pull it up and down between Compressed, Expanded and Full Height. It’ll apply a spring animation to make transitions feel fluid and snappy. I can pan up to the Full Height and scroll up through the Scroll View, then scroll back down and seamlessly transition into pulling the Drawer down to its Expanded state.
Breaking down existing UI components and re-creating them without looking at their source code is a great way to gain a deeper insight into problems that other engineers are solving. It helps consider the ways you might solve the same problem if you needed to. It can add some new UIKit techniques to your toolbox that you didn’t already know, or solidify your knowledge in a part of UIKit you thought you knew already. There’s always something more in UIKit to learn.
I’ve open sourced the code for this experiment on Github. Feel free to fork it and make changes. I do not currently support this component as a reusable framework or CocoaPod. If you’d like, use this as a basis for building your own.