Thursday, April 4, 2019

Animation: moving contents with constraints in Swift

I'm developing a pet project, named MyCalculator, using Swift 3. In portrait mode, the app shows only the basic operation buttons but when the user swipes to right, the basic operation buttons should slide out and the advance operation buttons should slide in as shown in the image below.


I put the basic operation buttons in one stackview and the advanced operation buttons in another stackview. Then, I put those two stackviews in another stackview together, named Buttons Stack View. I added two constraints for the Buttons Stack View to define its leading edge and trailing edge equal to the ViewController's view's leading edge and trailing edge, respectively.


To make the basic operation buttons disappear and the advanced operation buttons appear, I changed to constant property of the constraint as following:

self.buttonsStackviewLeading.constant = self.buttonsStackviewLeading.constant + view.bounds.width
self.buttonsStackviewTrailing.constant = self.buttonsStackviewTrailing.constant + (-1 * view.bounds.width)
    
But, I want to do more than that. i want to container stackview of those buttons move according to the user's finger movement point by point. This requires more effort and I put it in the steps below.

1. Create required outlets 


Create outlets for the Buttons Stack View and it's leading and trailing constraints as following:

    @IBOutlet weak var buttonsStackView: UIStackView!
    @IBOutlet weak var buttonsStackviewLeading: NSLayoutConstraint!
    @IBOutlet weak var buttonsStackviewTrailing: NSLayoutConstraint!
    

2. Initializing UIPanGestureRecognizer 


- Add the following lines to the ViewController's viewDidLoad() method.

let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: #selector(panGestureHandler(_:)))
buttonsStackView.addGestureRecognizer(panGestureRecognizer)
    

3. Handle the Pan Gesture Recognizer

Add these properties to the ViewController

    private var beganPoint:CGPoint?
    private var previousPoint:CGPoint?
    private var beganStackviewFrameMinX:CGFloat?
    private var beganStackviewFrameMaxX:CGFloat?
    private static let swipeFastMinPointsPerSecond = CGFloat(400)
    private static let maxPointsToMoveWhenSwipeFast = CGFloat(20)
    

Then add the following method:

    @objc public func panGestureHandler(_ recognizer:UIPanGestureRecognizer) {
        // Don't use UIDevice.current.orientation.isLandscape here
        // because its value can be 'flat (face-up or face-down)'
        // then the user would still be able to swipe when the app's UI is in landscape.
        if UIApplication.shared.statusBarOrientation.isLandscape {
            return
        }
        
        let currentPoint = recognizer.translation(in: view)
        let halfScreenWidth = view.bounds.width / 2
        
        if recognizer.state == .began {
            previousPoint = currentPoint
            beganStackviewFrameMinX = buttonsStackView.frame.minX
            beganStackviewFrameMaxX = buttonsStackView.frame.maxX
        } else if recognizer.state == .changed {            
            var changedPointsDiff = currentPoint.x - (previousPoint?.x)!
            previousPoint = currentPoint
            
            if swipedFast(recognizer) {
                changedPointsDiff = recognizer.velocity(in: view).x < 0 ? (-1 * ViewController.maxPointsToMoveWhenSwipeFast) : ViewController.maxPointsToMoveWhenSwipeFast
            }
            
            // If in case like the basic buttons are showing
            // and the user swipes/drags from right to left,
            if beganStackviewFrameMinX! == view.bounds.minX && buttonsStackviewLeading.constant > halfScreenWidth
                || beganStackviewFrameMaxX! == view.bounds.maxX && buttonsStackviewTrailing.constant > halfScreenWidth {
                
                // so the user can't drag those buttons more than half the screen
                return
            }
            
            buttonsStackviewLeading.constant = buttonsStackviewLeading.constant + changedPointsDiff
            buttonsStackviewTrailing.constant = buttonsStackviewTrailing.constant + (-1 * changedPointsDiff)
        } else if recognizer.state == .ended {
            
            let totalPointsMoved = buttonsStackView.frame.minX - beganStackviewFrameMinX!
            var finalPointsToMove:CGFloat = -1 * totalPointsMoved // Should move back to original position by default

            let swipedPointsPerSecond = recognizer.velocity(in: view).x
            if swipedFast(recognizer) {
                
                // When the user swipes fast, slide in advanced buttons or basic buttons.
                // It doesn't have to be a long swipe/drag.
                if beganStackviewFrameMinX! == view.bounds.minX && swipedPointsPerSecond < 0 {
                    finalPointsToMove = -1 * view.bounds.width - totalPointsMoved
                } else if beganStackviewFrameMaxX! == view.bounds.maxX && swipedPointsPerSecond > 0 {
                    finalPointsToMove =  view.bounds.width - totalPointsMoved
                }
            } else if beganStackviewFrameMinX == view.bounds.minX && totalPointsMoved > 0
                || beganStackviewFrameMaxX == view.bounds.maxX && totalPointsMoved < 0
                || abs(totalPointsMoved) < halfScreenWidth {
                
                // If the basic buttons are showing and the user swiped to left,
                // just move them to left a little bit and then move them back to the original position.
                finalPointsToMove = -1 * totalPointsMoved
            } else if abs(totalPointsMoved) > halfScreenWidth {
                
                // If dragged more than half the screen width,
                // show advanced/basic buttons
                finalPointsToMove = view.bounds.maxX - abs(totalPointsMoved)
                finalPointsToMove = totalPointsMoved > 0 ? finalPointsToMove : -1 * finalPointsToMove
            }
            
            self.buttonsStackviewLeading.constant = self.buttonsStackviewLeading.constant + finalPointsToMove
            self.buttonsStackviewTrailing.constant = self.buttonsStackviewTrailing.constant + (-1 * finalPointsToMove)
            UIView.animate(withDuration: 0.3, animations: {
                self.view.layoutIfNeeded()
            }, completion: {(finished:Bool) in
                if finished {
                    if self.buttonsStackviewLeading.constant >= 0 {
                        self.showedAdvancedOperationsInPortrait = true
                    } else {
                        self.showedAdvancedOperationsInPortrait = false
                    }
                }
            })
        }
    }
    

And add this helper method too

    private func swipedFast(_ recognizer:UIPanGestureRecognizer) -> Bool {
        return abs(recognizer.velocity(in: view).x) > ViewController.swipeFastMinPointsPerSecond
    }
    

The recognizer.velocity() method below returns the number of points per second in which the user has swiped the screen. The amount of points the user has swiped across the screen might be only 30 but the velocity might be 3000 points per second.


Note (AffineTransform)


We can also use AffineTransform struct to move the buttons as following:
    
buttonsStackView.transform = buttonsStackView.transform.translatedBy(x: pointsToMove, y: buttonsStackView.frame.minY)
    
But, using AffineTranform does not affect any constraints then it would be very hard to manage as the constraints and the AffineTransform might be conflicted with each other. I tried it in my example project too and it didn't work well. I had constraints to change the location of the stackview according to the screen's orientation. Using the AffineTransform struct worked in portrait mode but when the screen rotated to landscape, some buttons went off the screen.

I think if it's a slide-in popup view like in this post, using AfflineTransform is okay because the popup view usually stay on top of other views and it does not need constraints relative to the other views so changing its location and size does not really affect the others.


No comments:

Post a Comment