NEW: My new book Pro SwiftUI is out now – level up your SwiftUI skills today! >>

How to use inner shadows to simulate depth with SwiftUI and Core Motion

Tilt your device to move the shadow, as if there’s a light source shining from above.

Paul Hudson       @twostraws

SwiftUI comes with a whole range of advanced effects we can use to customize the way our content is drawn, and from iOS 16 onwards we gain another important option: the ability to create inner shadows. Inner shadows create the illusion that some shape or text is cut out, placing the shadows on the area inside the shape as if an overhead light source were in place.

That alone is nice enough, but with a tiny bit of Core Motion work we can make our shadow move as the user’s device is tilted, as if there really were a fixed light overhead. We can then go even further to add some 3D rotation into the shape – you’ll see how that’s only a small step further.

I think you’ll be surprised how easy this effect is, but please do read to the end before you use this code in your own projects – there are some important tips I want you to be aware of!

Hacking with Swift is sponsored by MadMachine

SPONSORED Want to try Swift on microcontrollers? MadMachine provides ways to interact with the physical world in a Swift way. Join us and have fun!

Get it now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Tracking Core Motion

Our first step is to create an observable object that can watch for device motion changes, and send them off to any views that might be interested in it. This can be done using CMMotionManager, which we get through the Core Motion framework, so start by adding an import for that:

import CoreMotion

Now we’re going to create a new MotionManager class that conforms to ObservableObject. This needs to have three properties:

  • The CMMotionManager that is currently monitoring movement. This can be a private constant because it’s only for internal use.
  • The amount of X movement we want to apply.
  • The amount of Y movement we want to apply.

Both those last two should trigger change notifications when they are modified, so we’ll mark them with @Published.

So, add this new class now:

class MotionManager: ObservableObject {
    private let motionManager = CMMotionManager()
    @Published var x = 0.0
    @Published var y = 0.0
}

In my example code, we want to start reading motion data as soon as this manager is started, so we’ll use the initializer to call startDeviceMotionUpdates() on our motion manager. This needs to know where it should send motion data to, and then needs to be given some code to act on the rotation somehow.

In our case, we’re going to ask for the motion updates to be delivered to the main queue, which is where our UI work take place. We can then read the device’s attitude – its orientation – and copy that into our x and y properties.

Add this initializer now:

init() {
    motionManager.startDeviceMotionUpdates(to: .main) { [weak self] data, error in

        guard let motion = data?.attitude else { return }
        self?.x = motion.roll
        self?.y = motion.pitch
    }
}

That code isn’t perfect, and I’ll explain why later. But for now, that’s our MotionManager class done: it knows how to start watching for motion data, and knows how to publish new motion data so that any SwiftUI views that are watching can use it.

Now let’s put it into practice…

Over to SwiftUI

We’ve got enough of our motion manager in place that we can already use it. Like I said, there are some tips I want you to be aware of, but before we get onto them let’s have some fun and make our tilt effect come to life.

The first step is to instantiate one of our motion managers in a SwiftUI view. This is an observable object, so we can use @StateObject to create one in a view:

@StateObject private var motion = MotionManager()

When it comes to applying a shadow, we can use this effect on images, shapes, and other views such as text, all using the foregroundStyle() modifier. When we’re choosing the style to apply, add the shadow() modifier to it to create your inner shadow, specifying the color and radius you want.

For example, I could create a huge SF Symbols icon with a gentle blue gradient fill, then apply a 10-point black inner shadow like this:

Image(systemName: "arrow.down.message.fill")
    .foregroundStyle(
        .blue.gradient.shadow(
            .inner(color: .black, radius: 10)
        )
    )
    .font(.system(size: 600).bold())

Now, that by itself will create an inner shadow centered on the image, which means it will apply it equally on all edges. However, we can also provide X and Y offsets for our shadow to control where it’s place, and this is where our motion manager comes in: we can read its X and Y coordinates, multiply them to make the effect more pronounced, then send those into the shadow.

So, our new inner shadow becomes this:

.inner(color: .black, radius: 10, x: motion.x * 50, y: motion.y * 50)

And that’s it! That’s the basic effect done already – it’s not perfect, and again I’ll explain more that shortly, but it does look pretty darn awesome out of the box.

This effect can actually be stacked, meaning that we can apply more than one shadow. For example, if we also added a small drop shadow we get a sort of embossed effect:

Image(systemName: "arrow.down.message.fill")
    .foregroundStyle(
        .blue.gradient.shadow(
            .inner(color: .black, radius: 10, x: motion.x * 50, y: motion.y * 50)
        )
        .shadow(
            .drop(color: .black.opacity(0.2), radius: 10, x: motion.x * 50, y: motion.y * 50)
        )
    )
    .font(.system(size: 600).bold())

Best of all, this exact effect applies just as well to other SwiftUI views using the exact as modifiers. So, we could place our image alongside some text inside a VStack, then apply the foregroundStyle() and font() modifiers to both:

VStack {
    Text("?")
    Image(systemName: "arrow.down.message.fill")
}
// existing modifiers from the Image here

SwiftUI fills in the text with the gentle blue gradient, then applies the double shadow – it Just Works.

Last of all, there’s no reason why we couldn’t apply this same technique to other modifiers. For example, we could ask SwiftUI to rotate our VStack in 3D space based on the motion data we received:

.rotation3DEffect(.degrees(motion.x * 20), axis: (x: 0, y: 1, z: 0))
.rotation3DEffect(.degrees(motion.y * 20), axis: (x: -1, y: 0, z: 0))

Before you try running with those new modifiers in place, I’d like you to change your foreground style to this:

.foregroundStyle(
    .blue.gradient.shadow(
        .inner(color: .black, radius: 10, x: motion.x * -20, y: motion.y * -20)
    )
)

That tones down the other effects a little – it brings down the inner shadow to 20 rather than 50, then removes the drop shadow, to avoid it all getting a bit much on screen. But critically it also flips the direction of our X and Y movement, because with this 3D rotation effect in place flipping the direction of the shadow creates the lovely effect that we’re peeking behind part of the shape that was previously invisible – try it and you’ll see what I mean!

Now for some tips

Okay, you’ve seen the basic effect, and you’ve seen how we can add to it and tweak it to create other similar effects, all without a great deal of code. Hopefully you’re inspired to try something similar in your own code, but before you do that, I have some important tips for you about what the code is doing.

First, our MotionManager class manages its two public properties using @Published, like this:

@Published var x = 0.0
@Published var y = 0.0

Whenever we change a published property, any SwiftUI views watching this class know to reinvoke their body. You might look at our code and think, “we’re always changing both properties, so that’s going to force SwiftUI to do twice as much work by reloading twice!” But it’s okay: SwiftUI will automatically coalesce these changes, so every motion update triggers only one SwiftUI view refresh.

Second, we start monitoring for device motion like this:

motionManager.startDeviceMotionUpdates(to: .main) { [weak self] data, error in

If you read the documentation for startDeviceMotionUpdates(), you’ll see it explicitly says “using the main operation queue is not recommended,” so you might think this is a bad idea. The problem is that if we use any other queue – a local operation queue for example – we still need to push our work back to the main queue so we can update our two published properties, otherwise SwiftUI would complain that we’re attempting to update the UI from a background thread.

So, again this looks like it’s going to be problematic code, but it’s actually fine. The reason the documentation warns against the main queue is because motion updates can arrive very quickly, so rather than try to do the work on a background queue then push it back to the main queue, I want to show you an alternative – add this line of code before the call to startDeviceMotionUpdates():

motionManager.deviceMotionUpdateInterval = 1 / 15

That tells the motion manager to send new motion data only 15 times a second, which is likely to be more than fast enough – we still get a lovely 3D effect, but we ease off the heavy load of motion updates. If you find 1/15th of a second isn’t quite right you should experiment, but definitely set something so that iOS isn’t maxing out a CPU core just rendering your shadows!

Third, inside the call to startDeviceMotionUpdates() I have use [weak self] to stop a retain cycle. This means when the MotionManager object is destroyed, the internal CMMotionManager will also be destroyed, ensuring that our app isn’t trying to continue trying to read motion updates unnecessarily.

If you intend to use this effect in many places, you should not create multiple instances of the MotionManager class. Instead, create it once and share it in all the views that need it using something like environmentObject().

Once the motion data is being shared, I would recommend you move the call to startDeviceMotionUpdates() into its own start() method, then add an equivalent stop() method that calls stopDeviceMotionUpdates() when no views are using the motion data, so that you don’t waste battery power. If you go down this route, try adding a activeCount property to the class that tracks how many views are currently using motion data, so you can automatically stop and start the motion updates as needed.

Finally, in our little test sandbox here, I haven’t taken into account device rotation, and I’ve been using my iPad in portrait mode only. If you intend to ship this for real, and you support multiple orientations, make sure you track this extra information and adjust your shadows appropriately.

Anyway, hopefully you’ve enjoyed this article and learned something new – go forth and add inner shadows to your apps, and perhaps even add Core Motion to add an extra bit of joy to your UI! If you make something fun with this, tweet me a video @twostraws, because I’d love to see it in action!

Hacking with Swift is sponsored by MadMachine

SPONSORED Want to explore your Swift skill outside of the Apple world? Join the MadMachine community and start to program microcontrollers in Swift.

Get it now

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

About the author

Paul Hudson is the creator of Hacking with Swift, the most comprehensive series of Swift books in the world. He's also the editor of Swift Developer News, the maintainer of the Swift Knowledge Base, and a speaker at Swift events around the world. If you're curious you can learn more here.

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.