SwiftUI Parallax Motion Effect

UPDATE (05 JULY, 2020): Thank you to one of our readers for pointing out that we were originally instantiating a CMMotionManager instance inside each instantiated modifier. This goes against Apple's documentation on when to create CMMotionManager instance. Multiple instances of this class can affect the rate at which data is received from the accelerometer and gyroscope. The tutorial below has been modified to correct this issue. Thank you Dennis!

In this tutorial we're going to take a look at an interesting SwiftUI Motion effect, and discuss how we can easily apply it to our views by creating our own custom ViewModifier struct.

This effect responds to the device attitude (roll, effect, and yaw) by adjusting the offset of the view it's applied to. It creates this crazy parallax effect when the user moves their device. Checkout the video below for an example.

Before getting started, please consider subscribing using this link, and if you aren't reading this on TrailingClosure.com, please come check us out sometime!

Building The ParallaxMotionModifier Struct

The ParallaxMotionModifier struct is going to hold our MotionManager class. The MotionManager class is going to be an ObservableObject which publishes updates on the device attitude (pitch, roll, yaw).

Here is the boilerplate code for the ParallaxMotionModifier struct and MotionManager ObservableObject class.

import SwiftUI
import CoreMotion

struct ParallaxMotionModifier: ViewModifier {
            
    func body(content: Content) -> some View {
        content
    }
    
}


class MotionManager: ObservableObject {

    init() {

    }
}

Setup and Receive Motion Updates

1. First we need to add @Published variables to the MotionManager class for the pitch and roll. We won't be needing the yaw of the device for this effect.

class MotionManager: ObservableObject {

    @Published var pitch: Double = 0.0
    @Published var roll: Double = 0.0

    init() {

    }
}

2. Next we create an instance of the CMMotionManager class to receive motion updates from the device within our class. Additionally we set the update interval to 60 times per second.

class MotionManager: ObservableObject {

    @Published var pitch: Double = 0.0
    @Published var roll: Double = 0.0

    private var manager: CMMotionManager

    init() {
        self.manager = CMMotionManager()
        self.manager.deviceMotionUpdateInterval = 1/60

    }
}

3. Finally we need to subscribe to motion updates and then set the new motion values to our pitch and roll variables. That's it for the MotionManager class.

class MotionManager: ObservableObject {

    @Published var pitch: Double = 0.0
    @Published var roll: Double = 0.0

    private var motionManager: CMMotionManager

    init() {
        self.manager = CMMotionManager()
        self.manager.deviceMotionUpdateInterval = 1/60
        self.manager.startDeviceMotionUpdates(to: .main) { (motionData, error) in
            guard error == nil else {
                print(error!)
                return
            }

            if let motionData = motionData {
                self.pitch = motionData.attitude.pitch
                self.roll = motionData.attitude.roll
            }
        }

    }
}

Apply Motion Updates to View Content

Let's move back to the ParallaxMotionModifier struct.

1. First at the top of ParallaxMotionModifier create an instance of the MotionManager class we just finished building. This will be an @ObservedObject so that the ParallaxMotionModifier struct automatically applies the device's motion updates to our content.

struct ParallaxMotionModifier: ViewModifier {
        
    @ObservedObject var manager: MotionManager = MotionManager()
    
    func body(content: Content) -> some View {
        content
    }
}

2. Inside the body(content:) function apply an offset change to the content using the data from the MotionManager instance.

struct ParallaxMotionModifier: ViewModifier {
        
    @ObservedObject var manager: MotionManager = MotionManager()
    
    func body(content: Content) -> some View {
        content
            .offset(x: CGFloat(manager.roll), y: CGFloat(manager.pitch))
    }
}

3. If you tried using this modifier now, you wouldn't see much. The view you apply it to would only move a minuscule amount. This is why we need to create a new variable called magnitude. We're going to use this when applying our offset to change how much the view moves relative to device motion.

var magnitude: Double

Then apply it inside our offset calculations:

func body(content: Content) -> some View {
    content
        .offset(x: CGFloat(manager.roll * magnitude), y: CGFloat(manager.pitch * magnitude))
}

Full Code for ParallaxMotionModifier and MotionManager

import SwiftUI
import CoreMotion

struct ParallaxMotionModifier: ViewModifier {
    
    @ObservedObject var manager: MotionManager
    var magnitude: Double
    
    func body(content: Content) -> some View {
        content
            .offset(x: CGFloat(manager.roll * magnitude), y: CGFloat(manager.pitch * magnitude))
    }
}

class MotionManager: ObservableObject {

    @Published var pitch: Double = 0.0
    @Published var roll: Double = 0.0
    
    private var manager: CMMotionManager

    init() {
        self.manager = CMMotionManager()
        self.manager.deviceMotionUpdateInterval = 1/60
        self.manager.startDeviceMotionUpdates(to: .main) { (motionData, error) in
            guard error == nil else {
                print(error!)
                return
            }

            if let motionData = motionData {
                self.pitch = motionData.attitude.pitch
                self.roll = motionData.attitude.roll
            }
        }

    }
}

Show us what you made!

We want to see what you've made using this tutorial! Send us pics! Find us on Twitter @TrailingClosure, on Instagram, or email us at howdy@TrailingClosure.com

Examples

import SwiftUI

struct ParallaxMotionTestView: View {

    @ObservedObject var manager = MotionManager()
    
    var body: some View {
        Color.red
            .frame(width: 100, height: 100, alignment: .center)
            .modifier(ParallaxMotionModifier(manager: manager, magnitude: 10))
    }
}