// // 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() } }