Interface #1: Calculator Button

This is a button that mimics the behavior of buttons in the iOS calculator app.

Key Features

Highlights instantly on touch. Can be tapped rapidly even when mid-animation. User can touch down and drag outside of the button to cancel the tap. User can touch down, drag outside, drag back in, and confirm the tap.

Design Theory

We want buttons that feel responsive, acknowledging to the user that they are functional. In addition, we want the action to be cancellable if the user decides against their action after they touched down. This allows users to make quicker decisions since they can perform actions in parallel with thought.

Slides from the WWDC presentation showing how gestures in parallel with thought make actions faster.

Critical Code

The first step to create this button is to use a UIControl subclass, not a UIButton subclass. A UIButton would work fine, but since we are customizing the interaction, we won’t need any of its features.

CalculatorButton: UIControl {

public var value: Int = 0 {

didSet { label.text = “\(value)” }

}

private lazy var label: UILabel = { ... }()

}

Next, we will use UIControlEvents to assign functions to the various touch interactions.

addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter]) addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel])

We group the touchDown and touchDragEnter events into a single “event” called touchDown , and we can group the touchUpInside , touchDragExit , and touchCancel events into a single event called touchUp .

(For a description of all available UIControlEvents , check out the documentation.)

This gives us two functions to handle the animations.

private var animator = UIViewPropertyAnimator() @objc private func touchDown() {

animator.stopAnimation(true)

backgroundColor = highlightedColor

} @objc private func touchUp() {

animator = UIViewPropertyAnimator(duration: 0.5, curve: .easeOut, animations: {

self.backgroundColor = self.normalColor

})

animator.startAnimation()

}

On touchDown , we cancel the existing animation if needed, and instantly set the color to the highlighted color (in this case a light gray).

On touchUp , we create a new animator and start the animation. Using a UIViewPropertyAnimator makes it easy to cancel the highlight animation.

(Side note: This is not the exact behavior of the buttons in the iOS calculator app, which allow a touch that began in a different button to activate it if the touch was dragged inside the button. In most cases, a button like the one I created here is the intended behavior for iOS buttons.)

Interface #2: Spring Animations

This interface shows how a spring animation can be created by specifying a “damping” (bounciness) and “response” (speed).

Key Features

Uses “design-friendly” parameters. No concept of animation duration. Easily interruptible.

Design Theory

Springs make great animation models because of their speed and natural appearance. A spring animation starts incredibly quickly, spending most of its time gradually approaching its final state. This is perfect for creating interfaces that feel responsive—they spring to life!

A few additional reminders when designing spring animations:

Springs don’t have to be springy. Using a damping value of 1 will create an animation that slowly comes to rest without any bounciness. Most animations should use a damping value of 1. Try to avoid thinking about duration. In theory, a spring never fully comes to rest, and forcing a duration on the spring can cause it to feel unnatural. Instead, play with the damping and response values until it feels right. Interruption is critical. Because springs spend so much of their time close to their final value, users may think the animation has completed and will try to interact with it again.

Critical Code

In UIKit, we can create a spring animation with a UIViewPropertyAnimator and a UISpringTimingParameters object. Unfortunately, there is no initializer that just takes a damping and response . The closest we can get is the UISpringTimingParameters initializer that takes a mass, stiffness, damping, and initial velocity.

UISpringTimingParameters(mass: CGFloat, stiffness: CGFloat, damping: CGFloat, initialVelocity: CGVector)

We would like to create a convenience initializer that takes a damping and response, and maps it to the required mass, stiffness, and damping.

With a little bit of physics, we can derive the equations we need:

Solving for the spring constant and damping coefficient.

With this result, we can create our own UISpringTimingParameters with exactly the parameters we desire.

extension UISpringTimingParameters {

convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) {

let stiffness = pow(2 * .pi / response, 2)

let damp = 4 * .pi * damping / response

self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity)

}

}

This is how we will specify spring animations for all other interfaces.

The Physics Behind Spring Animations

Want to go deeper on spring animations? Check out this incredible post by Christian Schnorr: Demystifying UIKit Spring Animations.

After reading his post, spring animations finally clicked for me. Huge shout-out to Christian for helping me understand the math behind these animations and for teaching me how to solve second-order differential equations.

Interface #3: Flashlight Button

Another button, but with much different behavior. This mimics the behavior of the flashlight button on the lock screen of iPhone X.

Key Features

Requires an intentional gesture with 3D touch. Bounciness hints at the required gesture. Haptic feedback confirms activation.

Design Theory

Apple wanted to create a button that was easily and quickly accessible, but couldn’t be triggered accidentally. Requiring force pressure to activate the flashlight is a great choice, but lacks affordance and feedback.

In order to solve those problems, the button is springy and grows as the user applies force, hinting at the required gesture. In addition, there are two separate vibrations of haptic feedback: one when the required amount of force is applied, and another when the button activates as the force is reduced. These haptics mimic the behavior of a physical button.

Critical Code

To measure the amount of force being applied to the button, we can use the UITouch object provided in touch events.

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {

super.touchesMoved(touches, with: event)

guard let touch = touches.first else { return }

let force = touch.force / touch.maximumPossibleForce

let scale = 1 + (maxWidth / minWidth - 1) * force

transform = CGAffineTransform(scaleX: scale, y: scale)

}

We calculate a scale transform based on the current force, so that the button grows with increasing pressure.

Since the button could be pressed but not yet activated, we need to keep track of the button’s current state.

enum ForceState {

case reset, activated, confirmed

} private let resetForce: CGFloat = 0.4

private let activationForce: CGFloat = 0.5

private let confirmationForce: CGFloat = 0.49

Having the confirmation force be slightly lower than the activation force prevents the user from rapidly activating and de-activating the button by quickly crossing the force threshold.

For haptic feedback, we can use UIKit ’s feedback generators.

private let activationFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) private let confirmationFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)

Finally, for the bouncy animations, we can use a UIViewPropertyAnimator with the custom UISpringTimingParameters initializers we created before.

let params = UISpringTimingParameters(damping: 0.4, response: 0.2)

let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)

animator.addAnimations {

self.transform = CGAffineTransform(scaleX: 1, y: 1)

self.backgroundColor = self.isOn ? self.onColor : self.offColor

}

animator.startAnimation()

Interface #4: Rubberbanding

Rubberbanding occurs when a view resists movement. An example is when a scrolling view reaches the end of its content.

Key Features

Interface is always responsive, even when an action is invalid. De-synced touch tracking indicates a boundary. Amount of motion lessens further from the boundary.

Design Theory

Rubberbanding is a great way to communicate invalid actions while still giving the user a sense of control. It softly indicates a boundary, pulling them back into a valid state.

Critical Code

Luckily, rubberbanding is straightforward to implement.

offset = pow(offset, 0.7)

By using an exponent between 0 and 1, the view’s offset is moved less the further it is away from its resting position. Use a larger exponent for less movement and a smaller exponent for more movement.

For a little more context, this code is usually implemented in a UIPanGestureRecognizer callback whenever the touch moves. The offset can be calculated with the delta between the current and original touch locations, and the offset can be applied with a translation transform.

var offset = touchPoint.y - originalTouchPoint.y

offset = offset > 0 ? pow(offset, 0.7) : -pow(-offset, 0.7)

view.transform = CGAffineTransform(translationX: 0, y: offset)

Note: This is not how Apple performs rubberbanding with elements like scroll views. I like this method because of its simplicity, but there are more complex functions for different behaviors.

Interface #5: Acceleration Pausing

To view the app switcher on iPhone X, the user swipes up from the bottom of the screen and pauses midway. This interface re-creates this behavior.

Key Features

Pause is calculated based on the gesture’s acceleration. Faster stopping results in a faster response. No timers.

Design Theory

Fluid interfaces should be fast. A delay from a timer, even if short, can make an interface feel sluggish.

This interface is particularly cool because its reaction time is based on the user’s motion. If they quickly pause, the interface quickly responds. If they slowly pause, it slowly responds.

Critical Code

In order to measure acceleration, we can track the most recent values of the pan gesture’s velocity.

private var velocities = [CGFloat]()

private func track(velocity: CGFloat) {

if velocities.count < numberOfVelocities {

velocities.append(velocity)

} else {

velocities = Array(velocities.dropFirst())

velocities.append(velocity)

}

}

This code updates the velocities array to always have the last seven velocities, which are used to calculate the acceleration.

To determine if the acceleration is great enough, we can measure the difference between the first velocity in our array against the current velocity.

if abs(velocity) > 100 || abs(offset) < 50 { return }

let ratio = abs(firstRecordedVelocity - velocity) / abs(firstRecordedVelocity)

if ratio > 0.9 {

pauseLabel.alpha = 1

feedbackGenerator.impactOccurred()

hasPaused = true

}

We also check to make sure that the motion has a minimum displacement and velocity. If the gesture has lost more than 90% of its velocity, we consider it to be paused.

My implementation is not perfect. In my testing it seems to work pretty well, but there is an opportunity for a better heuristic to measure acceleration.

Interface #6: Rewarding Momentum

A drawer with open and closed states that has bounciness based on the velocity of the gesture.

Key Features

Tapping the drawer opens it without bounciness. Flicking the drawer opens it with bounciness. Interactive, interruptible, and reversible.

Design Theory

This drawer shows the concept of rewarding momentum. When the user swipes a view with velocity, it’s much more satisfying to animate the view with bounciness. This makes the interface feel alive and fun.

When the drawer is tapped, it animates without bounciness, which feels appropriate, since a tap has no momentum in a particular direction.

When designing custom interactions, it’s important to remember that interfaces can have different animations for different interactions.

Critical Code

To simplify the logic of tapping versus panning, we can use a custom gesture recognizer subclass that immediately enters the began state on touch down.

class InstantPanGestureRecognizer: UIPanGestureRecognizer {

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {

super.touchesBegan(touches, with: event)

self.state = .began

}

}

This also allows the user to tap on the drawer during its motion to pause it, similar to tapping on a scroll view that’s currently scrolling. To handle taps, we can check if the velocity is zero when the gesture ends and continue the animation.

if yVelocity == 0 {

animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

}

To handle a gesture with velocity, we first need to calculate its velocity relative to the total remaining displacement.

let fractionRemaining = 1 - animator.fractionComplete

let distanceRemaining = fractionRemaining * closedTransform.ty

if distanceRemaining == 0 {

animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

break

}

let relativeVelocity = abs(yVelocity) / distanceRemaining

We can use this relative velocity to continue the animation with the timing parameters that include a little bit of bounciness.

let timingParameters = UISpringTimingParameters(damping: 0.8, response: 0.3, initialVelocity: CGVector(dx: relativeVelocity, dy: relativeVelocity)) let newDuration = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters).duration let durationFactor = CGFloat(newDuration / animator.duration) animator.continueAnimation(withTimingParameters: timingParameters, durationFactor: durationFactor)

Here we are creating a new UIViewPropertyAnimator to calculate the time the animation should take so we can provide the correct durationFactor when continuing the animation.

There are more complexities related to reversing the animation that I am not going to cover here. If you want to learn more, I wrote a full tutorial for this component: Building Better iOS App Animations.

Interface #7: FaceTime PiP

A re-creation of the picture-in-picture UI of the iOS FaceTime app.

Key Features

Lightweight, airy interaction. Projected position is based on UIScrollView 's deceleration rate. Continuous animation that respects the gesture’s initial velocity.

Critical Code

Our end goal is to write something like this.

let params = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity) let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params) animator.addAnimations {

self.pipView.center = nearestCornerPosition

} animator.startAnimation()

We would like to create an animation with an initial velocity that matches the velocity of the pan gesture and animate the pip to the nearest corner.

First, let’s calculate the initial velocity.

To do this, we need to calculate a relative velocity based on the current velocity, current position, and target position.

let relativeInitialVelocity = CGVector(

dx: relativeVelocity(forVelocity: velocity.x, from: pipView.center.x, to: nearestCornerPosition.x),

dy: relativeVelocity(forVelocity: velocity.y, from: pipView.center.y, to: nearestCornerPosition.y)

) func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {

guard currentValue - targetValue != 0 else { return 0 }

return velocity / (targetValue - currentValue)

}

We can split the velocity into its x and y components and determine the relative velocity for each.

Next, let’s calculate the corner for the PiP to animate to.

In order to make our interface feel natural and lightweight, we are going to project the final position of the PiP based on its current motion. If the PiP were to slide and come to a stop, where would it land?

let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue

let velocity = recognizer.velocity(in: view)

let projectedPosition = CGPoint(

x: pipView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),

y: pipView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)

)

let nearestCornerPosition = nearestCorner(to: projectedPosition)

We can use the deceleration rate of a UIScrollView to calculate this resting position. This is important because it references the user’s muscle memory for scrolling. If a user knows about how far a view scrolls, they can use that previous knowledge to intuitively guess how much force is needed to move the PiP to their desired target.

This deceleration rate is also quite generous, making the interaction feel lightweight—only a small flick is needed to send the PiP flying all the way across the screen.

We can use the projection function provided in the “Designing Fluid Interfaces” talk to calculate the final projected position.

/// Distance traveled after decelerating to zero velocity at a constant rate.

func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {

return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)

}

The last piece missing is the logic to find the nearest corner based on the projected position. To do this we can loop through all corner positions and find the one with the smallest distance to the projected landing position.

func nearestCorner(to point: CGPoint) -> CGPoint {

var minDistance = CGFloat.greatestFiniteMagnitude

var closestPosition = CGPoint.zero

for position in pipPositions {

let distance = point.distance(to: position)

if distance < minDistance {

closestPosition = position

minDistance = distance

}

}

return closestPosition

}

To summarize the final implementation: We use UIScrollView 's deceleration rate to project the pip’s motion to its final resting position, and calculate the relative velocity to feed it all into UISpringTimingParameters .

Interface #8: Rotation

Applying the concepts from the PiP interface to a rotation animation.

Key Features

Uses projection to respect the gesture’s velocity. Always ends in a valid orientation.

Critical Code

The code here is very similar to the previous PiP interface. We will use the same building blocks, except swapping the nearestCorner function for a closestAngle function.

func project(...) { ... } func relativeVelocity(...) { ... } func closestAngle(...) { ... }

When it’s time to finally create the UISpringTimingParameters , we are required to use a CGVector for the initial velocity even though our rotation only has one dimension. In any case where the animated property has only one dimension, set the dx value to the desired velocity and set the dy value to zero.

let timingParameters = UISpringTimingParameters(

damping: 0.8,

response: 0.4,

initialVelocity: CGVector(dx: relativeInitialVelocity, dy: 0)

)

Internally the animator will ignore the dy value and use the dx value to create the timing curve.

Try it yourself!

These interfaces are much more fun on a real device. To play with these interfaces yourself, the demo app is available on GitHub.