UIMotionEffect: Easily adding depth to your UI

One of the “delightful” features of iOS is the almost imperceptible UI effects they add to give the illusion of depth. One of the most under-appreciated features is UIMotionEffect, which ties the device’s gyroscope to your views to make them adapt to how the user moves their phone.

This can be seen throughout iOS, from your lock screen to your app icons in Springboard (the iOS app launcher). Done right, the user won’t consciously notice these views moving, but it helps set certain views apart from the rest of the app’s UI, helping them “pop” and be more noticeably separate from the rest of the app.

In this post I’ll go over what UIMotionEffects are, how they work, and will share my approach for simplifying how to add motion effects throughout your application.

What are motion effects?

Quite simply, motion effects allow the gyroscope to directly influence a property on one of your views. The primary class used for this is UIInterpolatingMotionEffect, and operates on a particular key path in your view. For example, to adjust a view’s horizontal position when the device is tilted side-to-side:

let effect = UIInterpolatingMotionEffect(
    keyPath: "center.x",
    type: .tiltAlongHorizontalAxis)
effect.minimumRelativeValue = -10
effect.maximumRelativeValue = 10

view.addMotionEffect(effect)

As the device is moved, the view’s center X position is adjusted, interpolating the values between those minimum and maximum relative values.

Multiple motion effects may be added simultaneously, and on multiple views. Since it uses Key-Value Coding (KVC) to manipulate the views’ position, almost anything can be adjusted using this approach.

How to make buttons “float”

Since motion effects are best used as a way to separate different “layers” of your UI, to give the illusion of depth, lets create an app with a floating “Add” button above a scrolling table view.

let horizontalEffect = UIInterpolatingMotionEffect(
    keyPath: "center.x",
    type: .tiltAlongHorizontalAxis)
horizontalEffect.minimumRelativeValue = -16
horizontalEffect.maximumRelativeValue = 16

let verticalEffect = UIInterpolatingMotionEffect(
    keyPath: "center.y",
    type: .tiltAlongVerticalAxis)
verticalEffect.minimumRelativeValue = -16
verticalEffect.maximumRelativeValue = 16

let effectGroup = UIMotionEffectGroup()
effectGroup.motionEffects = [ horizontalEffect,
                              verticalEffect ]

addButton.addMotionEffect(effectGroup)

This gives the user the impression that the button is floating above the table view, and as they move their device around it gives them glimpses of content below the button. Finding the right values for the size of the view being moved, as well as the intended “depth” you want to provide, is mostly a trial & error process.

How to “peek under” fixed objects

Shadows are usually the primary way designers and app developers give the impression of depth. But most of the time the shadows are in a fixed position. The previous example showed a button floating above other views, but what about keeping an object fixed while still giving the user the illusion of depth?

Shadows are controlled from the CALayer object which backs every view in iOS. The shadowOpacity, shadowColor, and shadowRadius properties of course form the basis for making a shadow appear below a view. But the shadowOffset property is what we’re looking at controlling with UIMotionEffect, since it controls the horizontal and vertical offset from the parent view where the shadow will appear.

With UIInterpolatingMotionEffect we can utilize the keyPath layer.shadowOffset.width and layer.shadowOffset.height to control the shadow’s relative position from the view based on the device’s gyroscope.

let horizontalEffect = UIInterpolatingMotionEffect(
    keyPath: "layer.shadowOffset.width",
    type: .tiltAlongHorizontalAxis)
horizontalEffect.minimumRelativeValue = 16
horizontalEffect.maximumRelativeValue = -16

let verticalEffect = UIInterpolatingMotionEffect(
    keyPath: "layer.shadowOffset.height",
    type: .tiltAlongVerticalAxis)
verticalEffect.minimumRelativeValue = 16
verticalEffect.maximumRelativeValue = -16

let effectGroup = UIMotionEffectGroup()
effectGroup.motionEffects = [ horizontalEffect,
                              verticalEffect ]

button.addMotionEffect(effectGroup)

There are two important differences between this code sample and the previous one:

  1. The keyPaths are adjusting the layer’s shadow offset values;
  2. The minimum and maximum value ranges are inverted, which causes the shadow’s motion to move in opposition to the device’s physical movement.
Side-by-side comparison of the device’s screen, and the perspective-view from the user’s vantage point.

The great thing about utilizing UIMotionEffect in this way is that it gives the illusion of depth without the high overhead of actually implementing real 3D.

Interpolating non-primitive values

In 2010 I wrote an article on fun shadow effects using custom CALayer shadow paths which can easily be combined with UIMotionEffect to make your views really pop. This is done by altering the shadowPath of the layer to provide a custom shape for your view.

Interpolating shadow paths

It turns out you can combine this with UIInterpolatingMotionEffect quite nicely since it will interpolate between many different value types, not just floats. In the case of shadows, it accepts a CGPath object which can be used to define a complex bezier path. The layer will automatically interpolate intermediary states between the minimum and maximum paths, allowing the gyroscope to tweak the shadow as the device is rotated.

let size = contentView.bounds.size
let minimumPath = UIBezierPath()
minimumPath.move(to: CGPoint(x: size.width * 0.33,
                             y: size.height * 0.66))
minimumPath.addLine(to: CGPoint(x: size.width * 0.66,
                                y: size.height * 0.66))
minimumPath.addLine(to: CGPoint(x: size.width * 1.15,
                                y: size.height * 1.15))
minimumPath.addLine(to: CGPoint(x: size.width * -0.15,
                                y: size.height * 1.15))
minimumPath.close()

let maximumPath = UIBezierPath(rect: CGRect(origin: .zero, size: size))

let effect = UIInterpolatingMotionEffect(
    keyPath: "layer.shadowPath",
    type: .tiltAlongVerticalAxis)
effect.minimumRelativeValue = minimumPath.cgPath
effect.maximumRelativeValue = maximumPath.cgPath

contentView.addMotionEffect(effect)

Interpolating 3D affine transforms

In fact, you can do more than just adjust shadow paths. You could alter a layer’s entire transform property if you like, bringing rise to true 3D transformations of pieces of your view hierarchy. Consider the following example where the vertical dimension of the gyroscope is tied to a 3D affine transformation, rotating the view 45º about the view’s X axis.

var identity = CATransform3DIdentity
identity.m34 = -1 / 500.0

let minimum = CATransform3DRotate(identity, (315 * .pi) / 180.0, 1.0, 0.0, 0.0)
let maximum = CATransform3DRotate(identity, (45 * .pi) / 180.0, 1.0, 0.0, 0.0)

contentView.layer.transform = identity
let effect = UIInterpolatingMotionEffect(
    keyPath: "layer.transform",
    type: .tiltAlongVerticalAxis)
effect.minimumRelativeValue = minimum
effect.maximumRelativeValue = maximum

contentView.addMotionEffect(effect)

Simplifying UIMotionEffect in your applications

As you could see from the two examples above, UIMotionEffect is straightforward to use, but can be a bit verbose, especially since the biggest thing you want to have control over is the amount of influence the gyro has over a view.

I recently wrote an article about styling your app using custom UIAppearance properties, and I’ve found that approach works really well for configuring your views to easily add motion effects on-demand to views, either using Interface Builder or UIAppearance.

Applying motion effects via Interface Builder properties

You can also use those same properties to add motion effects using UIAppearance proxies.

CustomButton.appearance().shadowMotionOffset = CGSize(width: 16, height: 16)
CustomButton.appearance().shadowColor = UIColor.black
CustomButton.appearance().shadowOpacity = 0.75
CustomButton.appearance().shadowRadius = 3

For the full project, please check out the GitHub repo NachoMan/Example-UIMotionEffect that accompanies this article. In particular, the UIView Swift extension that adds these motion effects as controllable properties. The project also includes demonstrations of the various examples given in this article so you can try it for yourself.

Summary

Modern mobile experiences of course need subtlety, but with all the distractions a mobile user faces, it’s more important than ever to draw the user’s attention to actionable buttons or other UI elements in a way that’s almost imperceptible to the user.

UIMotionEffect is a valuable and often forgotten tool in our UIKit quiver, and making it easier to apply makes it simpler for us to optimize our user’s experience to get the most out of our app.

Let me know what you think in the comments, or on Twitter. And if you have any other ideas for how this can be applied, I’d love to hear your thoughts!

1 thought on “UIMotionEffect: Easily adding depth to your UI”

  1. Pingback: Swift News #57 – UIMotionEffects, Models, Indie App Development & More! | Nikkies Tutorials

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.