Skip to content

Instantly share code, notes, and snippets.

@cristhianleonli
Created July 12, 2019 08:22
Show Gist options
  • Save cristhianleonli/8b1d78a65ba068871de20c8fcc114b0c to your computer and use it in GitHub Desktop.
Save cristhianleonli/8b1d78a65ba068871de20c8fcc114b0c to your computer and use it in GitHub Desktop.
import UIKit
open class PullUpController: UIViewController {
public enum Action {
/**
The action used when the pull up controller's view is added to its parent view
*/
case add
/**
The action used when the pull up controller's view is removed to its parent view
*/
case remove
/**
The action used when the pull up controller's view position change
*/
case move
}
// MARK: - Open properties
/**
The desired size of the pull up controller’s view, in screen units.
The default value is width: UIScreen.main.bounds.width, height: 400.
*/
open var pullUpControllerPreferredSize: CGSize {
return CGSize(width: UIScreen.main.bounds.width, height: 400)
}
/**
The desired size of the pull up controller’s view, in screen units when the device is in landscape mode.
The default value is (x: 10, y: 10, width: 300, height: UIScreen.main.bounds.height - 20).
*/
open var pullUpControllerPreferredLandscapeFrame: CGRect {
return CGRect(x: 10, y: 10, width: 300, height: UIScreen.main.bounds.height - 20)
}
/**
A list of y values, in screen units expressed in the pull up controller coordinate system.
At the end of the gestures the pull up controller will scroll to the nearest point in the list.
Please keep in mind that this array should contains only sticky points in the middle of the pull up controller's view;
There is therefore no need to add the fist one (pullUpControllerPreviewOffset), and/or the last one (pullUpControllerPreferredSize.height).
For a complete list of all the sticky points you can use `pullUpControllerAllStickyPoints`.
*/
open var pullUpControllerMiddleStickyPoints: [CGFloat] {
return []
}
/**
A CGFloat value that determines how much the pull up controller's view can bounce outside it's size.
The default value is 0 and that means the the view cannot expand beyond its size.
*/
open var pullUpControllerBounceOffset: CGFloat {
return 0
}
/**
A CGFloat value that represent the current point, expressed in the pull up controller coordinate system,
where the pull up controller's view is positioned.
*/
open var pullUpControllerCurrentPointOffset: CGFloat {
guard
let parentViewHeight = parent?.view.frame.height
else { return 0 }
return parentViewHeight - (topConstraint?.constant ?? 0)
}
/**
A CGFloat value that represent the vertical velocity threshold (expressed in points/sec) beyond wich
the target sticky point is skippend and the view is positioned to the next one.
*/
open var pullUpControllerSkipPointVerticalVelocityThreshold: CGFloat {
return 700
}
// MARK: - Public properties
/**
A list of y values, in screen units expressed in the pull up controller coordinate system.
At the end of the gesture the pull up controller will scroll at the nearest point in the list.
*/
public final var pullUpControllerAllStickyPoints: [CGFloat] {
var sc_allStickyPoints = [initialStickyPointOffset, pullUpControllerPreferredSize.height].compactMap { $0 }
sc_allStickyPoints.append(contentsOf: pullUpControllerMiddleStickyPoints)
return sc_allStickyPoints.sorted()
}
private var leftConstraint: NSLayoutConstraint?
private var topConstraint: NSLayoutConstraint?
private var bottomConstraint: NSLayoutConstraint?
private var widthConstraint: NSLayoutConstraint?
private var heightConstraint: NSLayoutConstraint?
private var panGestureRecognizer: UIPanGestureRecognizer?
private var isPortrait: Bool {
return UIScreen.main.bounds.height > UIScreen.main.bounds.width
}
private var portraitPreviousStickyPointIndex: Int?
fileprivate weak var internalScrollView: UIScrollView?
private var initialInternalScrollViewContentOffset: CGPoint = .zero
private var initialStickyPointOffset: CGFloat?
private var currentStickyPointIndex: Int {
let stickyPointTreshold = (self.parent?.view.frame.height ?? 0) - (topConstraint?.constant ?? 0)
let stickyPointsLessCurrentPosition = pullUpControllerAllStickyPoints.map { abs($0 - stickyPointTreshold) }
guard let minStickyPointDifference = stickyPointsLessCurrentPosition.min() else { return 0 }
return stickyPointsLessCurrentPosition.firstIndex(of: minStickyPointDifference) ?? 0
}
// MARK: - Open methods
/**
This method is called before the pull up controller's view move to a point.
The default implementation of this method does nothing.
- parameter point: The target point, expressed in the pull up controller coordinate system
*/
open func pullUpControllerWillMove(to point: CGFloat) { }
/**
This method is called after the pull up controller's view move to a point.
The default implementation of this method does nothing.
- parameter point: The target point, expressed in the pull up controller coordinate system
*/
open func pullUpControllerDidMove(to point: CGFloat) { }
/**
This method is called after the pull up controller's view is dragged to a point.
The default implementation of this method does nothing.
- parameter stickyPoint: The target point, expressed in the pull up controller coordinate system
*/
open func pullUpControllerDidDrag(to point: CGFloat) { }
/**
This method will move the pull up controller's view in order to show the provided visible point.
You may use on of `pullUpControllerAllStickyPoints` item to provide a valid visible point.
- parameter visiblePoint: the y value to make visible, in screen units expressed in the pull up controller coordinate system.
- parameter animated: Pass true to animate the move; otherwise, pass false.
- parameter completion: The closure to execute after the animation is completed. This block has no return value and takes no parameters. You may specify nil for this parameter.
*/
open func pullUpControllerMoveToVisiblePoint(_ visiblePoint: CGFloat, animated: Bool, completion: (() -> Void)?) {
guard
isPortrait,
let parentViewHeight = parent?.view.frame.height
else { return }
topConstraint?.constant = parentViewHeight - visiblePoint
pullUpControllerWillMove(to: visiblePoint)
pullUpControllerAnimate(
action: .move,
withDuration: animated ? 0.3 : 0,
animations: { [weak self] in
self?.parent?.view?.layoutIfNeeded()
},
completion: { [weak self] _ in
self?.pullUpControllerDidMove(to: visiblePoint)
completion?()
})
}
/**
This method update the pull up controller's view size according to `pullUpControllerPreferredSize` and `pullUpControllerPreferredLandscapeFrame`.
If the device is in portrait, the pull up controller's view will be attached to the nearest sticky point after the resize.
- parameter animated: Pass true to animate the resize; otherwise, pass false.
*/
open func updatePreferredFrameIfNeeded(animated: Bool) {
guard
let parentView = parent?.view
else { return }
refreshConstraints(newSize: parentView.frame.size,
customTopOffset: parentView.frame.size.height - (pullUpControllerAllStickyPoints.first ?? 0))
pullUpControllerAnimate(
action: .move,
withDuration: animated ? 0.3 : 0,
animations: { [weak self] in
self?.view.layoutIfNeeded()
},
completion: nil)
}
/**
This method will be called when an animation needs to be performed.
You can consider override this method and customize the animation using the method
`UIView.animate(withDuration:, delay:, usingSpringWithDamping:, initialSpringVelocity:, options:, animations:, completion:)`
- parameter action: The action that is about to be performed, see `PullUpController.Action` for more info
- parameter duration: The total duration of the animations, measured in seconds. If you specify a negative value or 0, the changes are made without animating them.
- parameter animations: A block object containing the changes to commit to the views.
- parameter completion: A block object to be executed when the animation sequence ends.
*/
open func pullUpControllerAnimate(action: Action,
withDuration duration: TimeInterval,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)?) {
UIView.animate(withDuration: duration, animations: animations, completion: completion)
}
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
let isNewSizePortrait = size.height > size.width
var targetStickyPoint: CGFloat?
if !isNewSizePortrait {
portraitPreviousStickyPointIndex = currentStickyPointIndex
} else if
let portraitPreviousStickyPointIndex = portraitPreviousStickyPointIndex,
portraitPreviousStickyPointIndex < pullUpControllerAllStickyPoints.count
{
targetStickyPoint = pullUpControllerAllStickyPoints[portraitPreviousStickyPointIndex]
self.portraitPreviousStickyPointIndex = nil
}
coordinator.animate(alongsideTransition: { [weak self] coordinator in
self?.refreshConstraints(newSize: size)
if let targetStickyPoint = targetStickyPoint {
self?.pullUpControllerMoveToVisiblePoint(targetStickyPoint, animated: true, completion: nil)
}
})
}
// MARK: - Setup
fileprivate func setup(superview: UIView, initialStickyPointOffset: CGFloat) {
self.initialStickyPointOffset = initialStickyPointOffset
view.translatesAutoresizingMaskIntoConstraints = false
superview.addSubview(view)
view.frame = CGRect(origin: CGPoint(x: view.frame.origin.x,
y: superview.bounds.height),
size: view.frame.size)
setupPanGestureRecognizer()
setupConstraints()
refreshConstraints(newSize: superview.frame.size,
customTopOffset: superview.frame.height - initialStickyPointOffset)
}
fileprivate func addInternalScrollViewPanGesture() {
internalScrollView?.panGestureRecognizer.addTarget(self, action: #selector(handleScrollViewGestureRecognizer(_:)))
}
fileprivate func removeInternalScrollViewPanGestureRecognizer() {
internalScrollView?.panGestureRecognizer.removeTarget(self, action: #selector(handleScrollViewGestureRecognizer(_:)))
}
private func setupPanGestureRecognizer() {
addInternalScrollViewPanGesture()
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureRecognizer(_:)))
panGestureRecognizer?.minimumNumberOfTouches = 1
panGestureRecognizer?.maximumNumberOfTouches = 1
if let panGestureRecognizer = panGestureRecognizer {
view.addGestureRecognizer(panGestureRecognizer)
}
}
private func setupConstraints() {
guard
let parentView = parent?.view
else { return }
topConstraint = view.topAnchor.constraint(equalTo: parentView.topAnchor)
leftConstraint = view.leftAnchor.constraint(equalTo: parentView.leftAnchor)
widthConstraint = view.widthAnchor.constraint(equalToConstant: pullUpControllerPreferredSize.width)
heightConstraint = view.heightAnchor.constraint(equalToConstant: pullUpControllerPreferredSize.height)
heightConstraint?.priority = .defaultLow
bottomConstraint = parentView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
let constraintsToActivate = [topConstraint,
leftConstraint,
widthConstraint,
heightConstraint,
bottomConstraint].compactMap { $0 }
NSLayoutConstraint.activate(constraintsToActivate)
}
private func refreshConstraints(newSize: CGSize, customTopOffset: CGFloat? = nil) {
if newSize.height > newSize.width {
setPortraitConstraints(parentViewSize: newSize, customTopOffset: customTopOffset)
} else {
setLandscapeConstraints()
}
}
private func nearestStickyPointY(yVelocity: CGFloat) -> CGFloat {
var currentStickyPointIndex = self.currentStickyPointIndex
if abs(yVelocity) > pullUpControllerSkipPointVerticalVelocityThreshold {
if yVelocity > 0 {
currentStickyPointIndex = max(currentStickyPointIndex - 1, 0)
} else {
currentStickyPointIndex = min(currentStickyPointIndex + 1, pullUpControllerAllStickyPoints.count - 1)
}
}
return (parent?.view.frame.height ?? 0) - pullUpControllerAllStickyPoints[currentStickyPointIndex]
}
@objc private func handleScrollViewGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
guard
isPortrait,
let scrollView = internalScrollView,
let topConstraint = topConstraint,
let lastStickyPoint = pullUpControllerAllStickyPoints.last,
let parentViewHeight = parent?.view.bounds.height
else { return }
let isFullOpened = topConstraint.constant <= parentViewHeight - lastStickyPoint
let yTranslation = gestureRecognizer.translation(in: scrollView).y
let isScrollingDown = gestureRecognizer.velocity(in: scrollView).y > 0
/**
The user should be able to drag the view down through the internal scroll view when
- the scroll direction is down (`isScrollingDown`)
- the internal scroll view is scrolled to the top (`scrollView.contentOffset.y <= 0`)
*/
let shouldDragViewDown = isScrollingDown && scrollView.contentOffset.y <= 0
/**
The user should be able to drag the view up through the internal scroll view when
- the scroll direction is up (`!isScrollingDown`)
- the PullUpController's view is fully opened. (`topConstraint.constant <= parentViewHeight - lastStickyPoint`)
*/
let shouldDragViewUp = !isScrollingDown && !isFullOpened
let shouldDragView = shouldDragViewDown || shouldDragViewUp
if shouldDragView {
scrollView.bounces = false
scrollView.setContentOffset(.zero, animated: false)
}
switch gestureRecognizer.state {
case .began:
initialInternalScrollViewContentOffset = scrollView.contentOffset
case .changed:
guard
shouldDragView
else { break }
setTopOffset(topConstraint.constant + yTranslation - initialInternalScrollViewContentOffset.y)
gestureRecognizer.setTranslation(initialInternalScrollViewContentOffset, in: scrollView)
case .ended:
scrollView.bounces = true
goToNearestStickyPoint(verticalVelocity: gestureRecognizer.velocity(in: view).y)
default:
break
}
}
@objc private func handlePanGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
guard
isPortrait,
let topConstraint = topConstraint
else { return }
let yTranslation = gestureRecognizer.translation(in: view).y
switch gestureRecognizer.state {
case .changed:
setTopOffset(topConstraint.constant + yTranslation, allowBounce: true)
gestureRecognizer.setTranslation(.zero, in: view)
case .ended:
goToNearestStickyPoint(verticalVelocity: gestureRecognizer.velocity(in: view).y)
default:
break
}
}
private func goToNearestStickyPoint(verticalVelocity: CGFloat) {
guard
isPortrait,
let topConstraint = topConstraint
else { return }
let targetTopOffset = nearestStickyPointY(yVelocity: verticalVelocity) // v = px/s
let distanceToConver = topConstraint.constant - targetTopOffset // px
let animationDuration = max(0.08, min(0.3, TimeInterval(abs(distanceToConver/verticalVelocity)))) // s = px/v
setTopOffset(targetTopOffset, animationDuration: animationDuration)
}
private func setTopOffset(_ value: CGFloat,
animationDuration: TimeInterval? = nil,
allowBounce: Bool = false) {
guard
let parentViewHeight = parent?.view.frame.height
else { return }
// Apply right value bounding for the provided bounce offset if needed
let value: CGFloat = {
guard
let firstStickyPoint = pullUpControllerAllStickyPoints.first,
let lastStickyPoint = pullUpControllerAllStickyPoints.last
else {
return value
}
let bounceOffset = allowBounce ? pullUpControllerBounceOffset : 0
let minValue = parentViewHeight - lastStickyPoint - bounceOffset
let maxValue = parentViewHeight - firstStickyPoint + bounceOffset
return max(min(value, maxValue), minValue)
}()
let targetPoint = parentViewHeight - value
/*
`willMoveToStickyPoint` and `didMoveToStickyPoint` should be
called only if the user has ended the gesture
*/
let shouldNotifyObserver = animationDuration != nil
topConstraint?.constant = value
pullUpControllerDidDrag(to: targetPoint)
if shouldNotifyObserver {
pullUpControllerWillMove(to: targetPoint)
}
pullUpControllerAnimate(
action: .move,
withDuration: animationDuration ?? 0,
animations: { [weak self] in
self?.parent?.view.layoutIfNeeded()
},
completion: { [weak self] _ in
if shouldNotifyObserver {
self?.pullUpControllerDidMove(to: targetPoint)
}
}
)
}
private func setPortraitConstraints(parentViewSize: CGSize, customTopOffset: CGFloat? = nil) {
if let customTopOffset = customTopOffset {
topConstraint?.constant = customTopOffset
} else {
topConstraint?.constant = nearestStickyPointY(yVelocity: 0)
}
leftConstraint?.constant = (parentViewSize.width - min(pullUpControllerPreferredSize.width, parentViewSize.width))/2
widthConstraint?.constant = pullUpControllerPreferredSize.width
heightConstraint?.constant = pullUpControllerPreferredSize.height
heightConstraint?.priority = .defaultLow
bottomConstraint?.constant = 0
}
private func setLandscapeConstraints() {
guard
let parentViewHeight = parent?.view.frame.height
else { return }
let landscapeFrame = pullUpControllerPreferredLandscapeFrame
topConstraint?.constant = landscapeFrame.origin.y
leftConstraint?.constant = landscapeFrame.origin.x
widthConstraint?.constant = landscapeFrame.width
heightConstraint?.constant = landscapeFrame.height
heightConstraint?.priority = .defaultHigh
bottomConstraint?.constant = parentViewHeight - landscapeFrame.height - landscapeFrame.origin.y
}
fileprivate func hide() {
guard
let parentViewHeight = parent?.view.frame.height
else { return }
topConstraint?.constant = parentViewHeight
}
}
extension UIViewController {
/**
Adds the specified pull up view controller as a child of the current view controller.
- parameter pullUpController: the pull up controller to add as a child of the current view controller.
- parameter initialStickyPointOffset: The point where the provided `pullUpController`'s view will be initially placed expressed in screen units of the pull up controller coordinate system. If this value is not provided, the `pullUpController`'s view will be initially placed expressed
- parameter animated: Pass true to animate the adding; otherwise, pass false.
*/
open func addPullUpController(_ pullUpController: PullUpController,
initialStickyPointOffset: CGFloat,
animated: Bool) {
assert(!(self is UITableViewController), "It's not possible to attach a PullUpController to a UITableViewController. Check this issue for more information: https://github.com/MarioIannotta/PullUpController/issues/14")
addChild(pullUpController)
pullUpController.setup(superview: view, initialStickyPointOffset: initialStickyPointOffset)
if animated {
pullUpController.pullUpControllerAnimate(
action: .add,
withDuration: 0.3,
animations: { [weak self] in
self?.view.layoutIfNeeded()
},
completion: nil)
} else {
view.layoutIfNeeded()
}
}
/**
Adds the specified pull up view controller as a child of the current view controller.
- parameter pullUpController: the pull up controller to remove as a child from the current view controller.
- parameter animated: Pass true to animate the removing; otherwise, pass false.
*/
open func removePullUpController(_ pullUpController: PullUpController, animated: Bool) {
pullUpController.hide()
if animated {
pullUpController.pullUpControllerAnimate(
action: .remove,
withDuration: 0.3,
animations: { [weak self] in
self?.view.layoutIfNeeded()
},
completion: { _ in
pullUpController.willMove(toParent: nil)
pullUpController.view.removeFromSuperview()
pullUpController.removeFromParent()
})
} else {
view.layoutIfNeeded()
pullUpController.willMove(toParent: nil)
pullUpController.view.removeFromSuperview()
pullUpController.removeFromParent()
}
}
}
extension UIScrollView {
/**
Attach the scroll view to the provided pull up controller in order to move it with the scroll view content.
- parameter pullUpController: the pull up controller to move with the current scroll view content.
*/
open func attach(to pullUpController: PullUpController) {
pullUpController.internalScrollView?.detach(from: pullUpController)
pullUpController.internalScrollView = self
pullUpController.addInternalScrollViewPanGesture()
}
/**
Remove the scroll view from the pull up controller so it no longer moves with the scroll view content.
- parameter pullUpController: the pull up controller to be removed from controlling the scroll view.
*/
open func detach(from pullUpController: PullUpController) {
pullUpController.removeInternalScrollViewPanGestureRecognizer()
pullUpController.internalScrollView = nil
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment