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
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()
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.