Greg Greg - 9 days ago 5
iOS Question

my UIViews muck-up when I combine UIPanGestureRecognizer and autolayout

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, as shown.

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)
])
}
}

Answer

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)
}