Tilt your device to move the shadow, as if there’s a light source shining from above.
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!
SPONSORED AppSweep by Guardsquare helps developers automate the mobile app security testing process with fast, free scans. By using AppSweep’s actionable recommendations, developers can improve the security posture of their apps in accordance with security standards like OWASP.
Sponsor Hacking with Swift and reach the world's largest Swift community!
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:
CMMotionManager
that is currently monitoring movement. This can be a private constant because it’s only for internal use.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…
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!
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!
SPONSORED AppSweep by Guardsquare helps developers automate the mobile app security testing process with fast, free scans. By using AppSweep’s actionable recommendations, developers can improve the security posture of their apps in accordance with security standards like OWASP.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.