SheetImagePicker/SheetImagePicker/SheetContainerViewControlle...

253 lines
10 KiB
Swift

//
// SheetContainerViewController.swift
// SheetImagePicker
//
// Created by Shadowfacts on 9/23/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
public protocol SheetContainerViewControllerDelegate {
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool
func sheetContainer(_ sheetContainer: SheetContainerViewController, didSnapToDetent detent: Detent)
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView?
func sheetContainer(_ sheetContainer: SheetContainerViewController, topContentOffsetForScrollView scrollView: UIScrollView) -> CGFloat
}
// default no-op implementation
public extension SheetContainerViewControllerDelegate {
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool {
return true
}
func sheetContainer(_ sheetContainer: SheetContainerViewController, didSnapToDetent detent: Detent) {}
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView? {
return nil
}
func sheetContainer(_ sheetContainer: SheetContainerViewController, topContentOffsetForScrollView scrollView: UIScrollView) -> CGFloat {
return 0
}
}
public class SheetContainerViewController: UIViewController {
public var delegate: SheetContainerViewControllerDelegate?
public let content: UIViewController
public var detents: [Detent] = [.bottom, .middle, .top]
var topDetent: (detent: Detent, offset: CGFloat) {
return detents.map { ($0, $0.offset(in: view)) }.min(by: { $0.1 < $1.1 })!
}
var bottomDetent: (detent: Detent, offset: CGFloat) {
return detents.map { ($0, $0.offset(in: view)) }.max(by: { $0.1 < $1.1 })!
}
public var minimumDetentJumpVelocity: CGFloat = 500
public var maximumStretchDistance: CGFloat = 15
var topConstraint: NSLayoutConstraint!
lazy var initialConstant: CGFloat = view.bounds.height / 2
var dimmingView: UIView!
public var minimumDimmingAlpha: CGFloat = 0
public var maximumDimmingAlpha: CGFloat = 0.75
var contentScrollView: UIScrollView? {
delegate?.sheetContainerContentScrollView(self) ?? content.view as? UIScrollView
}
var initialScrollViewContentOffset: CGPoint?
var scrollViewIsMovingSheet = false
public init(content: UIViewController) {
self.content = content
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = self
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func viewDidLoad() {
super.viewDidLoad()
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = .black
dimmingView.alpha = (maximumDimmingAlpha - minimumDimmingAlpha) / 2
view.addSubview(dimmingView)
addChild(content)
content.didMove(toParent: self)
view.addSubview(content.view)
topConstraint = content.view.topAnchor.constraint(equalTo: view.topAnchor, constant: initialConstant)
NSLayoutConstraint.activate([
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
topConstraint,
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
// we add the content's corner radius here, so that the dimming view extends under the transparent parts of the content view's rounded corners
dimmingView.bottomAnchor.constraint(equalTo: content.view.topAnchor, constant: content.view.layer.cornerRadius)
])
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized(_:)))
panGesture.delegate = self
content.view.addGestureRecognizer(panGesture)
contentScrollViewChanged()
}
public func contentScrollViewChanged() {
if let scrollView = contentScrollView {
scrollView.panGestureRecognizer.addTarget(self, action: #selector(scrollViewPanGestureRecognized))
}
}
@objc func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
initialConstant = topConstraint.constant
case .changed:
let translation = recognizer.translation(in: content.view)
let realOffset = initialConstant + translation.y
setTopOffset(realOffset)
case .ended:
let velocity = recognizer.velocity(in: view)
springToNearestDetent(verticalVelocity: velocity.y)
default:
break
}
}
@objc func scrollViewPanGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
guard let scrollView = recognizer.view as? UIScrollView else { return }
let velocity = recognizer.velocity(in: scrollView)
let topContentOffset: CGFloat = delegate?.sheetContainer(self, topContentOffsetForScrollView: scrollView) ?? 0
let shouldMoveSheetDown = scrollView.contentOffset.y <= topContentOffset && velocity.y > 0 // scrolled to top and dragging down
let shouldMoveSheetUp = topConstraint.constant > topDetent.offset && velocity.y < 0 // not fully expanded and dragging up
let shouldMoveSheet = shouldMoveSheetDown || shouldMoveSheetUp
switch recognizer.state {
case .began:
scrollView.bounces = false
scrollViewIsMovingSheet = shouldMoveSheet
if shouldMoveSheet {
initialScrollViewContentOffset = scrollView.contentOffset
}
case .changed:
scrollViewIsMovingSheet = scrollViewIsMovingSheet || shouldMoveSheet
if scrollViewIsMovingSheet {
if initialScrollViewContentOffset == nil {
initialScrollViewContentOffset = scrollView.contentOffset
}
scrollView.setContentOffset(initialScrollViewContentOffset!, animated: false)
let translation = recognizer.translation(in: scrollView)
setTopOffset(topConstraint.constant + translation.y)
recognizer.setTranslation(.zero, in: scrollView)
}
case .ended:
scrollView.bounces = true
if scrollViewIsMovingSheet {
scrollView.setContentOffset(initialScrollViewContentOffset!, animated: false)
springToNearestDetent(verticalVelocity: velocity.y)
}
scrollViewIsMovingSheet = false
initialScrollViewContentOffset = nil
default:
break
}
}
func setTopOffset(_ offset: CGFloat) {
var offset = offset
let topOffset = topDetent.offset
if offset < topOffset {
let smoothed = smoothstep(value: offset, from: topOffset, to: 0)
offset = topOffset - smoothed * maximumStretchDistance
}
topConstraint.constant = offset
dimmingView.alpha = lerp(offset, min: topOffset, max: bottomDetent.offset, from: maximumDimmingAlpha, to: minimumDimmingAlpha)
}
func springToNearestDetent(verticalVelocity velocity: CGFloat) {
let springToDetent: (Detent, CGFloat)
if abs(velocity) > minimumDetentJumpVelocity,
let detentInVelocityDirection = nearestDetentOffset(currentOffset: topConstraint.constant, direction: velocity) {
springToDetent = detentInVelocityDirection
} else if let nearestDetent = nearestDetentOffset(currentOffset: topConstraint.constant) {
springToDetent = nearestDetent
} else {
return
}
if delegate?.sheetContainer(self, willSnapToDetent: springToDetent.0) ?? true {
let springDistance = abs(topConstraint.constant - springToDetent.1)
self.topConstraint.constant = springToDetent.1
let springVelocity = velocity / springDistance
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: springVelocity, animations: {
self.view.layoutIfNeeded()
self.dimmingView.alpha = lerp(springToDetent.1, min: self.topDetent.offset, max: self.bottomDetent.offset, from: self.maximumDimmingAlpha, to: self.minimumDimmingAlpha)
}, completion: { (finished) in
self.delegate?.sheetContainer(self, didSnapToDetent: springToDetent.0)
})
}
}
func nearestDetentOffset(currentOffset: CGFloat) -> (Detent, CGFloat)? {
return detents.map { ($0, $0.offset(in: view)) }.min { (a, b) -> Bool in
return abs(a.1 - currentOffset) < abs(b.1 - currentOffset)
}
}
func nearestDetentOffset(currentOffset: CGFloat, direction: CGFloat) -> (Detent, CGFloat)? {
let sorted = detents.map { ($0, $0.offset(in: view)) }.sorted { (a, b) -> Bool in
return a.1 < b.1
}
if direction < 0 {
return sorted.last(where: { $0.1 < currentOffset })
} else {
return sorted.first(where: { $0.1 > currentOffset })
}
}
}
extension SheetContainerViewController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if topConstraint.constant <= topDetent.offset {
return (gestureRecognizer as! UIPanGestureRecognizer).translation(in: gestureRecognizer.view!).y > 0
}
return true
}
}
extension SheetContainerViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SheetContainerPresentationAnimationController()
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SheetContainerDismissAnimationController()
}
}