my UIViews muck-up when I combine UIPanGestureRecognizer and autolayout
Asked Answered
U

1

6

I’d like a ball to track my finger as I drag it along a circular trajectory for every allowable device orientation on iPhone or iPad. Views appear to be correctly centred when a device is rotated but the ball will not stay on the circumference and seems to go anywhere when I drag it.


EDIT

Martin R's answer now displays this as required. My only additional code change was to remove an unnecessary declaration var shapeLayer = CAShapeLayer()

enter image description here


The maths in this example made perfect sense until I tried constraining both ball and trajectory to the view's centre and adding the ball’s centre coordinates as offsets at run time. I followed these recommendations on how to constrain a view.

There are three things I don’t understand.

First, calculating the circle’s circumference from two variables trackRadius and angle theta and using sin and cos of theta to find x and y coordinates will not place the ball in the right position.

Second, using atan to find the angle theta between the view centre and the point touched, and using trackRadius with theta to find x and y coordinates will not place or move the ball to a new place along the circumference.

And third, whenever I drag the ball, a message in the debug area says that Xcode is Unable to simultaneously satisfy constraints, although no constraints problems are reported prior to dragging it.

There may be more than one problem here. My brain is starting to hurt and I’d be grateful if someone could point out what I have done wrong.

Here is my code.

import UIKit

class ViewController: UIViewController {

override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .all }
var shapeLayer                      = CAShapeLayer()
let track                           = ShapeView()
var ball                            = ShapeView()
var theta                           = CGFloat()

private let trackRadius: CGFloat    = 125
private let ballRadius: CGFloat     = 10

override func viewDidLoad() {
    super.viewDidLoad()
    createTrack()
    createBall()
}

private func createTrack() {
    track.translatesAutoresizingMaskIntoConstraints = false
    track.shapeLayer.path = UIBezierPath(ovalIn: CGRect(x: -trackRadius, y: -trackRadius, width: 2 * trackRadius, height: 2 * trackRadius)).cgPath
    track.shapeLayer.fillColor      = UIColor.clear.cgColor
    track.shapeLayer.strokeColor    = UIColor.red.cgColor
    view.addSubview(track)

    track.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    track.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
}

private func createBall() {

    let offset = placeBallOnCircumference()

    drawBall()
    constrainBall(offset: offset)

    let touch = UIPanGestureRecognizer(target: self, action:#selector(dragBall(recognizer:)))
    view.addGestureRecognizer(touch)
}

private func placeBallOnCircumference() -> CGPoint {
    let theta: Double = 0                                         // at 0 radians
    let x = CGFloat(cos(theta)) * trackRadius                     // find x and y coords on
    let y = CGFloat(sin(theta)) * trackRadius                     // circle circumference
    return CGPoint(x: x, y: y)
}

func dragBall(recognizer: UIPanGestureRecognizer) {

    var offset = CGPoint()

    let finger : CGPoint = recognizer.location(in: self.view)
    theta  = CGFloat(atan2(Double(finger.x), Double(finger.y)))   // get angle from finger tip to centre
    offset.x = CGFloat(cos(theta)) * trackRadius                  // use angle and radius to get x and
    offset.y = CGFloat(sin(theta)) * trackRadius                  //  y coords on circle circumference

    drawBall()
    constrainBall(offset: offset)
}

private func drawBall() {
    ball.shapeLayer.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 2 * ballRadius, height: 2 * ballRadius)).cgPath
    ball.shapeLayer.fillColor    = UIColor.cyan.cgColor
    ball.shapeLayer.strokeColor  = UIColor.black.cgColor
    view.addSubview(ball)
}

private func constrainBall(offset: CGPoint) {
    ball.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        ball.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: offset.x),
        ball.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: offset.y),
        ball.widthAnchor.constraint(equalToConstant: trackRadius),
        ball.heightAnchor.constraint(equalToConstant: trackRadius)
        ])
    }
}
Uzziel answered 30/11, 2016 at 10:16 Comment(0)
P
4

The main error is that

theta = CGFloat(atan2(Double(finger.x), Double(finger.y)))   // get angle from finger tip to centre

does not take the views (or track) center into account, and that the arguments to atan2() are the wrong way around (y comes first). It should be:

theta = atan2(finger.y - track.center.y, finger.x - track.center.x)

Another problem is that you add more and more contraints in func constrainBall(), without removing the previous ones. You should keep references to the constraints and modify them instead.

Finally note that the width/height constraint for the ball should be 2*ballRadius, not trackRadius.

Putting it all together (and removing some unnecessary type conversions), it would look like this:

var ballXconstraint: NSLayoutConstraint!
var ballYconstraint: NSLayoutConstraint!

override func viewDidLoad() {
    super.viewDidLoad()
    createTrack()
    createBall()

    let touch = UIPanGestureRecognizer(target: self, action:#selector(dragBall(recognizer:)))
    view.addGestureRecognizer(touch)
}

private func createTrack() {
    track.translatesAutoresizingMaskIntoConstraints = false
    track.shapeLayer.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 2 * trackRadius, height: 2 * trackRadius)).cgPath
    track.shapeLayer.fillColor      = UIColor.clear.cgColor
    track.shapeLayer.strokeColor    = UIColor.red.cgColor
    view.addSubview(track)

    track.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    track.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    track.widthAnchor.constraint(equalToConstant: 2 * trackRadius).isActive = true
    track.heightAnchor.constraint(equalToConstant: 2 * trackRadius).isActive = true
}

private func createBall() {

    // Create ball:
    ball.translatesAutoresizingMaskIntoConstraints = false
    ball.shapeLayer.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 2 * ballRadius, height: 2 * ballRadius)).cgPath
    ball.shapeLayer.fillColor    = UIColor.cyan.cgColor
    ball.shapeLayer.strokeColor  = UIColor.black.cgColor
    view.addSubview(ball)

    // Width/Height contraints:
    ball.widthAnchor.constraint(equalToConstant: 2 * ballRadius).isActive = true
    ball.heightAnchor.constraint(equalToConstant: 2 * ballRadius).isActive = true

    // X/Y constraints:
    let offset = pointOnCircumference(0.0)
    ballXconstraint = ball.centerXAnchor.constraint(equalTo: track.centerXAnchor, constant: offset.x)
    ballYconstraint = ball.centerYAnchor.constraint(equalTo: track.centerYAnchor, constant: offset.y)
    ballXconstraint.isActive = true
    ballYconstraint.isActive = true
}

func dragBall(recognizer: UIPanGestureRecognizer) {

    let finger = recognizer.location(in: self.view)

    // Angle from track center to touch location:
    theta = atan2(finger.y - track.center.y, finger.x - track.center.x)

    // Update X/Y contraints of the ball:
    let offset = pointOnCircumference(theta)
    ballXconstraint.constant = offset.x
    ballYconstraint.constant = offset.y
}


private func pointOnCircumference(_ theta: CGFloat) -> CGPoint {
    let x = cos(theta) * trackRadius
    let y = sin(theta) * trackRadius
    return CGPoint(x: x, y: y)
}
Psi answered 30/11, 2016 at 14:28 Comment(1)
after a good night’s sleep, what a joy it is waking up to this! You nailed everything I tried and made the Swift code more self explanatory in the process. It has to be the accepted answer.Uzziel

© 2022 - 2024 — McMap. All rights reserved.