Search code examples
swiftuipangesturerecognizercatransform3d

How to 3Drotate and 3Dtranslate CALayer with 180 degree angle using UIPanGestureRecognizer?


How to rotate and transform together using UIPanGestureRecognizer with 180 degree like the layer moving on semi-sphere, I have tried doing something and I can transform it in all directions but the transition between directions is not smooth

Briefly I want to make it like in this video

I'm simply coded these class, it works for all directions but the result not as desired:

//
//  MoveCircleToolViewController.swift
//
//  Created by Coder ACJHP on 17.06.2020.
//  Copyright © 2020 Coder ACJHP. All rights reserved.
//

import UIKit

class MoveCircleToolViewController: UIViewController {

    var currentAngleX: CGFloat = 0
    var currentOffsetX: CGFloat = 0
    var currentAngleY: CGFloat = 0
    var currentOffsetY: CGFloat = 0
    var cardSize: CGSize = .zero
    let transformLayer = CATransformLayer()
    var directionsFrames = Array<CGRect>()

    override func viewDidLoad() {
        super.viewDidLoad()

        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        view.addGestureRecognizer(panGesture)

        transformLayer.frame = view.bounds
        view.layer.addSublayer(transformLayer)

        /// Add simple CALayer (circle shape)
        addCircleView()

        /** Calculate 4 corners TR, TL, BR, BL and store them in array list
         to use them inside pan gesture event */
        calculateCorners()
    }

    private func degreeToRadians(degree: CGFloat) -> CGFloat {
        return (degree * CGFloat.pi) / 180
    }

    private func addCircleView() {

        let singleSideSize = self.view.bounds.width * 0.18
        cardSize = CGSize(width: singleSideSize, height: singleSideSize)
        let imageLayer = CALayer()
        let origin = CGPoint(x: (view.bounds.width / 2) - (cardSize.width / 2),
                             y: (view.bounds.height / 2) - (cardSize.height / 2))
        imageLayer.frame = CGRect(origin: origin, size: cardSize)

        imageLayer.contentsGravity = .resizeAspectFill
        imageLayer.borderColor = UIColor.cyan.cgColor
        imageLayer.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
        imageLayer.borderWidth = 3.0
        imageLayer.cornerRadius = cardSize.width / 2
        imageLayer.masksToBounds = true
        imageLayer.isDoubleSided = true
        transformLayer.addSublayer(imageLayer)
    }

    private func calculateCorners() {
        let quarterW = self.view.bounds.width / 2
        let quarterH = self.view.bounds.height / 2

        let topLeftRect = CGRect(x: 0,
                             y: 0,
                             width: quarterW - cardSize.width / 2,
                             height: quarterH - cardSize.height / 2)

        let topRightRect = CGRect(x: topLeftRect.width + cardSize.width,
                             y: 0,
                             width: quarterW - cardSize.width / 2,
                             height: quarterH - cardSize.height / 2)

        let bottomLeftRect = CGRect(x: 0,
                             y: topLeftRect.height + cardSize.height,
                             width: quarterW - cardSize.width / 2,
                             height: quarterH - cardSize.height / 2)

        let bottomRightRect = CGRect(x: bottomLeftRect.width + cardSize.height,
                             y: topRightRect.height + cardSize.height,
                             width: quarterW - cardSize.width / 2,
                             height: quarterH - cardSize.height / 2)

        directionsFrames.append(topLeftRect)
        directionsFrames.append(topRightRect)
        directionsFrames.append(bottomLeftRect)
        directionsFrames.append(bottomRightRect)
    }

    @objc
    private func handlePan(_ gestureRecognier: UIPanGestureRecognizer) {

        let translationPoint = gestureRecognier.translation(in: view)
        let location = gestureRecognier.location(in: view)

        /// Calculate X and Y offset for animation
        let xOffset = gestureRecognier.translation(in: view).x
        let yOffset = gestureRecognier.translation(in: view).y

        /// Reset offsets
        if gestureRecognier.state == .began {
            currentOffsetX = 0
            currentOffsetY = 0
        }

        /// Calculate angle for rotation X
        let xDifference = xOffset * 0.6 - currentOffsetX
        currentOffsetX += xDifference
        currentAngleX += xDifference
        let angleOffsetX = currentAngleX

        /// Calculate angle for rotation Y
        let yDifference = yOffset * 0.6 + currentOffsetY
        currentOffsetY -= yDifference
        currentAngleY -= yDifference
        let angleOffsetY = currentAngleY

        /// Create transform object
        var transform = CATransform3DIdentity
        transform.m34 = -1 / self.view.bounds.width

        // Top Left
        if directionsFrames[0].contains(location) {
            transform = CATransform3DRotate(transform, degreeToRadians(degree: 30), 1, 0, 0)
            transform = CATransform3DTranslate(transform, translationPoint.x, translationPoint.y, 200)

        // Top Right
        } else if directionsFrames[1].contains(location) {
            transform = CATransform3DRotate(transform, degreeToRadians(degree: 30), 1, 0, 0)
            transform = CATransform3DTranslate(transform, translationPoint.x, translationPoint.y, 200)

        // Bottom Left
        } else if directionsFrames[2].contains(location) {
            transform = CATransform3DRotate(transform, degreeToRadians(degree: -30), 1, 0, 0)
            transform = CATransform3DTranslate(transform, translationPoint.x, translationPoint.y, 200)

        // Bottom Right
        } else if directionsFrames[3].contains(location) {
            transform = CATransform3DRotate(transform, degreeToRadians(degree: -30), 1, 0, 0)
            transform = CATransform3DTranslate(transform, translationPoint.x, translationPoint.y, 200)

        } else {

            if let direction = gestureRecognier.direction {
                switch direction {
                case .Left, .Right:
                    transform = CATransform3DRotate(transform, degreeToRadians(degree: angleOffsetX), 0, 1, 0)
                    transform = CATransform3DTranslate(transform, 0, 0, 200)
                case .Up, .Down:
                    transform = CATransform3DRotate(transform, degreeToRadians(degree: angleOffsetY), 1, 0, 0)
                    transform = CATransform3DTranslate(transform, 0, 0, 200)
                }
            }
        }
        CATransaction.setAnimationDuration(0)
        transformLayer.transform = transform
    }
}

public extension UIPanGestureRecognizer {

    enum PanDirection: Int {
        case Up, Down, Left, Right
        public var isVertical: Bool { return [.Up, .Down].contains(self) }
        public var isHorizontal: Bool { return !isVertical }
    }

    var direction: PanDirection? {
        let translation = self.translation(in: view)
        let isVertical = abs(translation.y) > abs(translation.x)
        switch (isVertical, translation.x, translation.y) {
        case (true, _, let y) where y < 0: return .Up
        case (true, _, let y) where y > 0: return .Down
        case (false, let x, _) where x > 0: return .Right
        case (false, let x, _) where x < 0: return .Left
        default: return nil
        }
    }
}

Thanks in advance


Solution

  • It looks like you're only allowing the user to rotate on one axis or the other, as well as possibly overcomplicating things.

    Given some UIView that contains a "primaryView" subview that we want to rotate, and a "secondaryView" subview that we want to both rotate and shift forwards in 3D space (acting as the circle in the video you linked), and given some 2D point, and a distance "in front" of that point, we can calculate the X and Y Euler angles in radians from that point to that 2D projected point:

    // Note, "self" is some UIView. "primaryView" and "secondaryView"s are subviews of self in the below example:
    
    let somePoint: CGPoint = CGPoint(...)
    let distanceInfront: CGFloat = 10
    
    // First we need to convert this point to a coordinate relative to the "center" of what we're trying to orbit around. For your case, this center would be the center of the view itself:
    
    let center = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
    let offset = CGPoint(x: center.x - somePoint, y: center.y - somePoint)
    
    // If we picture the problem as if we're looking at it "from the side", we're essentially trying to calculate a 2D angle between a horizontal line and some 2D point to obtain the x rotation angle. The point we're trying to calculate the angle to has an x value of our desired distance, and a y value of the calculated offset's y value:
    
    let xP = CGPoint(x: distance, y: offset.y)
    let xAngle = atan2(xP.y, xP.x)
    
    // We can do the same to calculate the y angle, picturing the problem "from above":
    
    let yP = CGPoint(x: distance, y: offset.x)
    let yAngle = atan2(yP.y, yP.x)
    
    // Now we can use our calculated x and y angles to compute our transform:
    
    var primaryTransform = CATransform3DIdentity
    primaryTransform.m34 = 1 / self.bounds.width
    primaryTransform = CATransform3DRotate(primaryTransform, yAngle, 0, 1, 0)
    primaryTransform = CATransform3DRotate(primaryTransform, -xAngle, 1, 0, 0)
    primaryView.layer.transform = primaryTransform
    
    // Our primary view is now "looking at" the 2D point we've provided, at a distance "distance" in front of our view.
    
    // We can then take that same transform and shift it forwards by our desired distance to compute the transform of the secondaryView (the circle view):
    let secondaryTransform = CATransform3DTranslate(primaryTransform, 0, 0, -distance)
    secondaryView.layer.transform = secondaryTransform
    

    Here is a Swift Playground that wraps it all together to demonstrate:

    import UIKit
    import PlaygroundSupport
    
    class OrbitView: UIView {
    
        let primaryView = UIView()
        let secondaryView = UIView()
    
        public init(primaryRadius: CGFloat, secondaryRadius: CGFloat) {
            super.init(frame: .zero)
            primaryView.backgroundColor = .blue
            primaryView.layer.cornerRadius = primaryRadius/2.0
            primaryView.translatesAutoresizingMaskIntoConstraints = false
            addSubview(primaryView)
            NSLayoutConstraint.activate([
                primaryView.centerXAnchor.constraint(equalTo: centerXAnchor),
                primaryView.centerYAnchor.constraint(equalTo: centerYAnchor),
                primaryView.widthAnchor.constraint(equalToConstant: primaryRadius),
                primaryView.heightAnchor.constraint(equalTo: primaryView.widthAnchor),
            ])
    
            secondaryView.backgroundColor = .red
            secondaryView.layer.cornerRadius = secondaryRadius/2.0
            secondaryView.translatesAutoresizingMaskIntoConstraints = false
            primaryView.addSubview(secondaryView)
            NSLayoutConstraint.activate([
                secondaryView.centerXAnchor.constraint(equalTo: centerXAnchor),
                secondaryView.centerYAnchor.constraint(equalTo: centerYAnchor),
                secondaryView.widthAnchor.constraint(equalToConstant: secondaryRadius),
                secondaryView.heightAnchor.constraint(equalTo: secondaryView.widthAnchor),
            ])
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        public func lookAt(_ location: CGPoint, distanceInfront distance: CGFloat) {
    
            // Compute how far the location is from the center of our view
            let center = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
            let offset = CGPoint(x: center.x - location.x, y: center.y - location.y)
    
            // Calculate the x angle to the point "infront" of us
            let xP = CGPoint(x: distance, y: offset.y)
            let xAngle = atan2(xP.y, xP.x)
    
            // Calculate the y angle to the point "infront" of us
            let yP = CGPoint(x: distance, y: offset.x)
            let yAngle = atan2(yP.y, yP.x)
    
    
            // Construct a transform that rotates our primary subview's layer to point to the location in 3D space
            var primaryTransform = CATransform3DIdentity
            primaryTransform.m34 = 1 / self.bounds.width
            primaryTransform = CATransform3DRotate(primaryTransform, yAngle, 0, 1, 0)
            primaryTransform = CATransform3DRotate(primaryTransform, -xAngle, 1, 0, 0)
    
            // Set our primary layer's transform
            primaryView.layer.transform = primaryTransform
    
            // Now, shift this primary transform forward by the distance infront of our view we're "looking",
            // and apply this transform to our secondary subview
            let secondaryTransform = CATransform3DTranslate(primaryTransform, 0, 0, -distance)
            secondaryView.layer.transform = secondaryTransform
    
        }
    
    }
    
    
    class MyViewController : UIViewController {
    
        let orbitView = OrbitView(primaryRadius: 200, secondaryRadius: 50)
    
        override func loadView() {
            let view = UIView()
            view.backgroundColor = .white
            self.view = view
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Construct an orbit view for demonstration purposes, and embed it in our view
            orbitView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(orbitView)
            NSLayoutConstraint.activate([
                orbitView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                orbitView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
                orbitView.widthAnchor.constraint(equalToConstant: 200),
                orbitView.heightAnchor.constraint(equalTo: orbitView.widthAnchor),
            ])
    
            // We'll use a pan gesture recognizer to update our orbit view
            let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panRecognized(_:)))
            view.addGestureRecognizer(panGesture)
        }
    
        @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
            // Tell our orbit view to "look" at a point in 3D space relative to where we are currently touching
            let location = recognizer.location(in: self.orbitView)
            orbitView.lookAt(location, distanceInfront: 100)
        }
    
    }
    
    // Present the view controller in the Live View window
    PlaygroundPage.current.liveView = MyViewController()
    

    enter image description here

    Note in the above GIF the red circle "pops" forward the first time we start dragging. You'll probably want to start off by setting the red circle (secondaryView)'s transform to be shifted forward by your desired distance when it is first created to avoid this.