CoreMotion and how to update a reference CMAttitude?

176 Views Asked by At

I am implementing a custom UIView that listens to the device attitude in order to transform the view in 3d.

The following implementation works well:

class MyView: UIView {
    
    private let motionManager = CMMotionManager()
    private var referenceAttitude: CMAttitude?
    private let maxRotation = 45.0 * .pi / 180.0
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    private func commonInit() {
        motionManager.deviceMotionUpdateInterval = 0.02
        motionManager.startDeviceMotionUpdates(to: .main) { [weak self] (data, error) in
            guard let deviceMotion = data, error == nil else {
                return
            }
            self?.applyRotationEffect(deviceMotion)
        }
    }

    private func applyRotationEffect(_ deviceMotion: CMDeviceMotion) {
        guard let referenceAttitude = self.referenceAttitude else {
            self.referenceAttitude = deviceMotion.attitude
            return
        }

        deviceMotion.attitude.multiply(byInverseOf: referenceAttitude)
        
        let clampedPitch = min(max(deviceMotion.attitude.pitch, -maxRotation), maxRotation)
        let clampedRoll = min(max(deviceMotion.attitude.roll, -maxRotation), maxRotation)
        let clampedYaw = min(max(deviceMotion.attitude.yaw, -maxRotation), maxRotation)
        
        var transform = CATransform3DIdentity
        transform.m34 = 1 / 500
        transform = CATransform3DRotate(transform, CGFloat(clampedPitch), 1, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat(clampedRoll), 0, 1, 0)
        transform = CATransform3DRotate(transform, CGFloat(clampedYaw), 0, 0, 1)
        
        layer.transform = transform
    }
}

I store a referenceAttitude to be able to know how much the device moved in a given direction. I also make it possible to rotate max 45 degrees in every direction.

I would like to be able to update the referenceAttitude when the phone moves too much in a given direction.

For example, if I rotate the phone 60 degrees on the x axis, I'd like to shift referenceAttitude by 60 - 45 = 15 degrees on this axis. By doing so, the user won't have to move 15 degrees and more in the other direction to make the view move again.

I did not find a way to do that, any idea on how to implement that?

2

There are 2 best solutions below

0
HangarRash On

Since there's no API to create or modify instances of CMAttitude, the first step to is to create your own struct representing pitch, roll, and yaw.

struct MyAttitude {
    let pitch: Double
    let roll: Double
    let yaw: Double

    init(attitude: CMAttitude) {
        self.pitch = attitude.pitch
        self.roll = attitude.roll
        self.yaw = attitude.yaw
    }

    init(pitch: Double, roll: Double, yaw: Double) {
        self.pitch = pitch
        self.roll = roll
        self.yaw = yaw
    }
}

Next, you can create a class that contains the logic needed to calculate adjusted attitudes from the current reference attitude and the newest device attitude. This class can update the reference attitude when the user starts rotating back in the opposite direction after rotating beyond the maximum angle.

class ReferenceAttitude {
    var reference: MyAttitude
    var maxPitch = 0.0
    var minPitch = 0.0
    var maxRoll = 0.0
    var minRoll = 0.0
    var maxYaw = 0.0
    var minYaw = 0.0

    init(with attitude: CMAttitude) {
        reference = MyAttitude(pitch: attitude.pitch, roll: attitude.roll, yaw: attitude.yaw)
    }

    func relative(to attitude: CMAttitude, clampedTo angle: Double) -> MyAttitude {
        // Get the current relative attitude
        var pitch = attitude.pitch - reference.pitch
        var roll = attitude.roll - reference.roll
        var yaw = attitude.yaw - reference.yaw

        // Adjust the pitch as needed
        var newPitch: Double?
        // Is the pitch greater than the max angle?
        if (pitch > angle) {
            // Yes, are we still increasing?
            if (pitch >= maxPitch) {
                // Yes, track how far we've pitched so far
                maxPitch = pitch
            } else {
                // No, we maxed out and are now decreasing. Readjust to a new pitch reference
                newPitch = attitude.pitch - angle
                maxPitch = angle
                minPitch = 0
            }
            // Clamp the pitch
            pitch = angle
        // Is the pitch less than the min angle?
        } else if (pitch < -angle) {
            // Yes, are we still decreasing?
            if (pitch <= minPitch) {
                // Yes, track how far we've pitched so far
                minPitch = pitch
            } else {
                // No, we mined out and are now increasing. Readjust to a new pitch reference
                newPitch = attitude.pitch + angle
                minPitch = -angle
                maxPitch = 0
            }
            // Clamp the pitch
            pitch = -angle
        }

        // Same logic for roll as pitch
        var newRoll: Double?
        if (roll > angle) {
            if (roll >= maxRoll) {
                maxRoll = roll
            } else {
                newRoll = attitude.roll - angle
                maxRoll = angle
                minRoll = 0
            }
            roll = angle
        } else if (roll < -angle) {
            if (roll <= minRoll) {
                minRoll = roll
            } else {
                newRoll = attitude.roll + angle
                minRoll = -angle
                maxRoll = 0
            }
            roll = -angle
        }

        // Same logic for yaw as pitch
        var newYaw: Double?
        if (yaw > angle) {
            if (yaw >= maxYaw) {
                maxYaw = yaw
            } else {
                newYaw = attitude.yaw - angle
                maxYaw = angle
                minYaw = 0
            }
            yaw = angle
        } else if (yaw < -angle) {
            if (yaw <= minYaw) {
                minYaw = yaw
            } else {
                newYaw = attitude.yaw + angle
                minYaw = -angle
                maxYaw = 0
            }
            yaw = -angle
        }

        // Do we have any new reference values?
        if newPitch != nil || newRoll != nil || newYaw != nil {
            // Yes, create a new reference
            reference = MyAttitude(pitch: newPitch ?? reference.pitch, roll: newRoll ?? reference.roll, yaw: newYaw ?? reference.yaw)
        }

        // Return the new adjusted relative attitude
        return .init(pitch: pitch, roll: roll, yaw: yaw)
    }
}

Finally, your MyView can be updated to use this struct and class.

Change the declaration of referenceAttitude to:

private var referenceAttitude: ReferenceAttitude?

Then replace applyRotationEffect with:

private func applyRotationEffect(_ deviceMotion: CMDeviceMotion) {
    guard let referenceAttitude = self.referenceAttitude else {
        self.referenceAttitude = ReferenceAttitude(with: deviceMotion.attitude)
        return
    }

    let adjusted = referenceAttitude.relative(to: deviceMotion.attitude, clampedTo: maxRotation)

    var transform = CATransform3DIdentity
    transform.m34 = 1 / 500
    transform = CATransform3DRotate(transform, CGFloat(adjusted.pitch), 1, 0, 0)
    transform = CATransform3DRotate(transform, CGFloat(adjusted.roll), 0, 1, 0)
    transform = CATransform3DRotate(transform, CGFloat(adjusted.yaw), 0, 0, 1)

    layer.transform = transform
}

As an example, let's say the user starts the app with the phone laying flat on table. While keeping the phone flat on the table, if the user starts rotating the phone clockwise, the yaw is being changed. If the phone is rotated up to 45º the view moves a corresponding amount. If the device keeps rotating clockwise, the view stays at its max rotation of 45º. But as soon as the device begins to rotate back counter-clockwise (anti-clockwise), the view begins to rotate back. No need to get back to 45º first for the view to start rotating again.


To test this out, create a new Swift/Storyboard project. Add the new struct and class from this answer. Add the OP's MyView class with the changes from this answer. Then replace the stock ViewController implementation with the following:

class ViewController: UIViewController {
    var rotate = MyView()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .black
        
        rotate.backgroundColor = .red
        rotate.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(rotate)
        NSLayoutConstraint.activate([
            rotate.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            rotate.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            rotate.heightAnchor.constraint(equalToConstant: 300),
            rotate.widthAnchor.constraint(equalToConstant: 200),
        ])
    }
}

Run the app and rotate away.

1
Uniruddh On

To update the referenceAttitude when the device moves beyond the maximum rotation angle, you can introduce a threshold check in the applyRotationEffect method. If the device exceeds the maximum rotation angle in any direction, you can update the referenceAttitude accordingly.

Here's an updated version of the applyRotationEffect method that includes the threshold check and updates the referenceAttitude when necessary:

private func applyRotationEffect(_ deviceMotion: CMDeviceMotion) {
    guard let referenceAttitude = self.referenceAttitude else {
        self.referenceAttitude = deviceMotion.attitude
        return
    }

    deviceMotion.attitude.multiply(byInverseOf: referenceAttitude)

    let clampedPitch = min(max(deviceMotion.attitude.pitch, -maxRotation), maxRotation)
    let clampedRoll = min(max(deviceMotion.attitude.roll, -maxRotation), maxRotation)
    let clampedYaw = min(max(deviceMotion.attitude.yaw, -maxRotation), maxRotation)

    // Check if any rotation exceeds the maximum rotation angle
    let shouldUpdateReferenceAttitude = abs(deviceMotion.attitude.pitch) > maxRotation ||
        abs(deviceMotion.attitude.roll) > maxRotation ||
        abs(deviceMotion.attitude.yaw) > maxRotation

    if shouldUpdateReferenceAttitude {
        // Calculate the excess rotation
        let excessPitch = abs(deviceMotion.attitude.pitch) - maxRotation
        let excessRoll = abs(deviceMotion.attitude.roll) - maxRotation
        let excessYaw = abs(deviceMotion.attitude.yaw) - maxRotation

        // Update the reference attitude by subtracting the excess rotation
        let updatedReferenceAttitude = CMAttitude()
        updatedReferenceAttitude.pitch = excessPitch >= 0 ? excessPitch.copysign(to: deviceMotion.attitude.pitch) : deviceMotion.attitude.pitch
        updatedReferenceAttitude.roll = excessRoll >= 0 ? excessRoll.copysign(to: deviceMotion.attitude.roll) : deviceMotion.attitude.roll
        updatedReferenceAttitude.yaw = excessYaw >= 0 ? excessYaw.copysign(to: deviceMotion.attitude.yaw) : deviceMotion.attitude.yaw

        // Update the reference attitude
        referenceAttitude.multiply(byInverseOf: updatedReferenceAttitude)
        self.referenceAttitude = referenceAttitude
    }

    var transform = CATransform3DIdentity
    transform.m34 = 1 / 500
    transform = CATransform3DRotate(transform, CGFloat(clampedPitch), 1, 0, 0)
    transform = CATransform3DRotate(transform, CGFloat(clampedRoll), 0, 1, 0)
    transform = CATransform3DRotate(transform, CGFloat(clampedYaw), 0, 0, 1)

    layer.transform = transform
}

In this updated implementation, when any of the rotation angles exceeds the maximum rotation (maxRotation), the excess rotation is calculated (excessPitch, excessRoll, excessYaw). Then, a new CMAttitude instance, updatedReferenceAttitude, is created to store the excess rotations.

The signs of the excess rotations are determined using the copysign(to:) method to maintain the correct direction. Finally, the referenceAttitude is updated by multiplying it with the inverse of updatedReferenceAttitude, and the updated referenceAttitude is used for subsequent calculations.

With this approach, the referenceAttitude will be adjusted when the device exceeds the maximum rotation angle, allowing the view to respond to further rotations without requiring the user to move the device in the opposite direction.