Fear and Loathing in Auto Layout: Animating Auto Layout Constraints with a Tap, Drag, Pinch and Rotate in Swift (Xcode 7.2, iOS 9.2.1; updated Swift 3, Xcode 8 beta 6)
** Jump to Swift 3 code **
So you want to move things across the screen? But how is this done when we're using Auto Layout and not just frames and centres to place objects? Well this is something that I've started to look at following this StackOverflow response.
Turns out the ordering of the code and the calling of layoutIfNeeded() is important although we're free to update constraint values before or within animation closures, as long as we do so before the second call to layoutIfNeeded(). (The first time is to clear out any layout that needs to happen first, the second is to make the new changes. So we end up with a kind of layoutIfNeeded() sandwich.)
Tapping a view
I'm first of all going to extract the methods that get called by each gesture recogniser. The code is not complete in these extracts. For example, here we are missing the code to attach the gesture recogniser and also the bool that is being checked and changed. But all the surrounding code is present in the code at the end of the post, which is a complete cut and paste for tapping, dragging and pinching.
func tap(gest:UITapGestureRecognizer) { if movedRight { self.view.layoutIfNeeded() self.verticalConstraint.constant += 25 UIView.animateWithDuration(0.5){ self.view.layoutIfNeeded() } movedRight = false } else { self.view.layoutIfNeeded() UIView.animateWithDuration(0.5){ self.horizontalConstraint.constant += 25 self.view.layoutIfNeeded() } movedRight = true } }Using a UIPropertyAnimator (Swift 4):
@objc func tapPiece(_ gestureRecognizer : UITapGestureRecognizer ) { guard gestureRecognizer.view != nil else { return } self.view.layoutIfNeeded() if(self.topicScrollViewBottomConstraint.constant==0){self.topicScrollViewBottomConstraint.constant += 80} else {self.topicScrollViewBottomConstraint.constant = 0} if gestureRecognizer.state == .ended { let animator = UIViewPropertyAnimator(duration: 0.25, curve: .easeOut, animations: { self.view.layoutIfNeeded() }) animator.startAnimation() }}In tapping the view, there's nothing very special here beyond what Gervasio Marchand provides on StackOverflow. A red view animates a little way across the screen every time you tap it.
Dragging a view
And this is my method for handling a pan gesture, inspired again by a StackOverflow entry.func drag(gest:UIPanGestureRecognizer) { view.layoutIfNeeded() let translation = gest.translationInView(self.redView) switch (gest.state) { case .Began: offSet = CGPoint(x: horizontalConstraint.constant, y: verticalConstraint.constant) break; case .Changed: horizontalConstraint.constant = offSet.x + translation.x verticalConstraint.constant = offSet.y + translation.y view.layoutIfNeeded() break; case .Ended: break; default: break; } }Whenever a drag begins the position is obtained and then the offset is added to this, which prevents any of those familiar leaps that happen when a view resets to its original position.
Stretching a view
It would be great to be able to resize a view with a pinch as well:func pinch(gest:UIPinchGestureRecognizer) { view.layoutIfNeeded() let scale = gest.scale switch (gest.state) { case .Began: viewSize = CGSize(width: widthConstraint.constant, height: heightConstraint.constant) break; case .Changed: widthConstraint.constant = viewSize.width * scale heightConstraint.constant = viewSize.height * scale view.layoutIfNeeded() break; case .Ended: break; default: break; } }Here the dragging code is adapted for a scale. Notice how similar it is. The only real thing to note here before the complete code is given is that users are used to seeing a view scale from the centre and the simplest way that I know to achieve this is by setting all measurements and constraints offset from the centre of the superview. To achieve this I use the centerXAnchors and the centerYAnchors of the views involved. You might also in real use want to set a minimum size for the resizable view so that it doesn't become too small to expand (or to find some strategy where you attach the pinch gesture to a superview).
Tap, drag, pinch and rotate
Now time to add in the surrounding code, which looks like this:import UIKit class ViewController: UIViewController { var redView:UIView! var movedRight:Bool = false var verticalConstraint:NSLayoutConstraint! var horizontalConstraint:NSLayoutConstraint! var widthConstraint:NSLayoutConstraint! var heightConstraint:NSLayoutConstraint! var offSet:CGPoint! var transform: CGAffineTransform! var viewSize:CGSize! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. redView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) redView.backgroundColor = .redColor() view.addSubview(redView) let tap = UITapGestureRecognizer(target: self, action: Selector("tap:")) redView.addGestureRecognizer(tap) let drag = UIPanGestureRecognizer(target: self, action: Selector("drag:")) redView.addGestureRecognizer(drag) let pinch = UIPinchGestureRecognizer(target: self, action: Selector("pinch:")) redView.addGestureRecognizer(pinch) let rotate = UIRotationGestureRecognizer(target: self, action: Selector("rotate:")) redView.addGestureRecognizer(rotate) redView.userInteractionEnabled = true alignView(redView) } func rotate(gest:UIRotationGestureRecognizer) { // view.layoutIfNeeded() let rotation = gest.rotation if let viewToTransform = gest.view { switch (gest.state) { case .Began: transform = viewToTransform.transform break; case .Changed: viewToTransform.transform = CGAffineTransformConcat(transform,CGAffineTransformMakeRotation(rotation)) break; case .Ended: break; default: break; } } } // see http://stackoverflow.com/a/21803627/1694526 func drag(gest:UIPanGestureRecognizer) { view.layoutIfNeeded() let translation = gest.translationInView(self.view) switch (gest.state) { case .Began: offSet = CGPoint(x: horizontalConstraint.constant, y: verticalConstraint.constant) break; case .Changed: horizontalConstraint.constant = offSet.x + translation.x verticalConstraint.constant = offSet.y + translation.y view.layoutIfNeeded() break; case .Ended: break; default: break; } } func pinch(gest:UIPinchGestureRecognizer) { view.layoutIfNeeded() let scale = gest.scale switch (gest.state) { case .Began: viewSize = CGSize(width: widthConstraint.constant, height: heightConstraint.constant) break; case .Changed: widthConstraint.constant = viewSize.width * scale heightConstraint.constant = viewSize.height * scale view.layoutIfNeeded() break; case .Ended: break; default: break; } } func tap(gest:UITapGestureRecognizer) { if movedRight { self.view.layoutIfNeeded() self.verticalConstraint.constant += 25 UIView.animateWithDuration(0.5){ self.view.layoutIfNeeded() } movedRight = false } else { self.view.layoutIfNeeded() UIView.animateWithDuration(0.5){ self.horizontalConstraint.constant += 25 self.view.layoutIfNeeded() } movedRight = true } } func alignView(viewToConstrain:UIView) { // a precaution viewToConstrain.removeConstraints(viewToConstrain.constraints) // enable AutoLayout viewToConstrain.translatesAutoresizingMaskIntoConstraints = false // set horizontal and vertical constraints horizontalConstraint = viewToConstrain.centerXAnchor.constraintEqualToAnchor(view.centerXAnchor, constant: viewToConstrain.center.x - view.center.x) horizontalConstraint.active = true verticalConstraint = viewToConstrain.centerYAnchor.constraintEqualToAnchor(view.centerYAnchor, constant: viewToConstrain.center.y - view.center.y) verticalConstraint.active = true // set height and width anchors heightConstraint = viewToConstrain.heightAnchor.constraintEqualToConstant(CGRectGetHeight(viewToConstrain.frame)) heightConstraint.active = true widthConstraint = viewToConstrain.widthAnchor.constraintEqualToConstant(CGRectGetWidth(viewToConstrain.frame)) widthConstraint.active = true } }Rotation doesn't have an impact on Auto Layout, but seeing as we're already tapping, dragging and pinching I thought why not add it in. You can now tap the view, drag the view, pinch the view or rotate it and it will be have as you have grown to expect.
Updated: Swift 3 (Xcode 8 beta 6)
import UIKit class ViewController: UIViewController { var redView:UIView! var movedRight:Bool = false var verticalConstraint:NSLayoutConstraint! var horizontalConstraint:NSLayoutConstraint! var widthConstraint:NSLayoutConstraint! var heightConstraint:NSLayoutConstraint! var offSet:CGPoint! var transform: CGAffineTransform! var viewSize:CGSize! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. redView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) redView.backgroundColor = .red view.addSubview(redView) let taps = UITapGestureRecognizer(target: self, action: #selector(tap)) redView.addGestureRecognizer(taps) let drags = UIPanGestureRecognizer(target: self, action: #selector(drag)) redView.addGestureRecognizer(drags) let pinches = UIPinchGestureRecognizer(target: self, action: #selector(pinch)) redView.addGestureRecognizer(pinches) let rotates = UIRotationGestureRecognizer(target: self, action: #selector(rotate)) redView.addGestureRecognizer(rotates) redView.isUserInteractionEnabled = true alignView(viewToConstrain: redView) } @objc func rotate(gest:UIRotationGestureRecognizer) { // view.layoutIfNeeded() let rotation = gest.rotation if let viewToTransform = gest.view { switch (gest.state) { case .began: transform = viewToTransform.transform break; case .changed: viewToTransform.transform = transform.concatenating(CGAffineTransform(rotationAngle: rotation)) break; case .ended: break; default: break; } } } // see http://stackoverflow.com/a/21803627/1694526 @objc func drag(gest:UIPanGestureRecognizer) { view.layoutIfNeeded() let translation = gest.translation(in: self.view) switch (gest.state) { case .began: offSet = CGPoint(x: horizontalConstraint.constant, y: verticalConstraint.constant) break; case .changed: horizontalConstraint.constant = offSet.x + translation.x verticalConstraint.constant = offSet.y + translation.y view.layoutIfNeeded() break; case .ended: break; default: break; } } @objc func pinch(gest:UIPinchGestureRecognizer) { view.layoutIfNeeded() let scale = gest.scale switch (gest.state) { case .began: viewSize = CGSize(width: widthConstraint.constant, height: heightConstraint.constant) break; case .changed: widthConstraint.constant = viewSize.width * scale heightConstraint.constant = viewSize.height * scale view.layoutIfNeeded() break; case .ended: break; default: break; } } @objc func tap(gest:UITapGestureRecognizer) { if movedRight { self.view.layoutIfNeeded() self.verticalConstraint.constant += 25 UIView.animate(withDuration: 0.5){ self.view.layoutIfNeeded() } movedRight = false } else { self.view.layoutIfNeeded() UIView.animate(withDuration: 0.5){ self.horizontalConstraint.constant += 25 self.view.layoutIfNeeded() } movedRight = true } } func alignView(viewToConstrain:UIView) { // a precaution viewToConstrain.removeConstraints(viewToConstrain.constraints) // enable AutoLayout viewToConstrain.translatesAutoresizingMaskIntoConstraints = false // set horizontal and vertical constraints horizontalConstraint = viewToConstrain.centerXAnchor.constraint(equalTo: view.centerXAnchor) horizontalConstraint.isActive = true verticalConstraint = viewToConstrain.centerYAnchor.constraint(equalTo: view.centerYAnchor) verticalConstraint.isActive = true // set height and width anchors heightConstraint = viewToConstrain.heightAnchor.constraint(equalToConstant: viewToConstrain.frame.height) heightConstraint.isActive = true widthConstraint = viewToConstrain.widthAnchor.constraint(equalToConstant: viewToConstrain.frame.width) widthConstraint.isActive = true } }
Next time
I'm hoping in my next post on this subject to see if we can move away from constants so that layout is more proportional and works even better across device sizes.
Great tutorial. Explained very well. Thank you.
ReplyDelete