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)
1. Create required outlets
@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.