From ca374422db48f381f46be8b5dae52c944e51daf0 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 1 Jan 2020 11:57:39 -0500 Subject: [PATCH] Add sheet controller --- Package.swift | 3 + Sources/SheetController/Detent.swift | 31 +++ Sources/SheetController/MathHelpers.swift | 30 +++ ...tContainerDismissAnimationController.swift | 46 ++++ ...ainerPresentationAnimationController.swift | 53 ++++ .../SheetContainerViewController.swift | 252 ++++++++++++++++++ Sources/SheetController/SheetController.swift | 3 - 7 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 Sources/SheetController/Detent.swift create mode 100644 Sources/SheetController/MathHelpers.swift create mode 100644 Sources/SheetController/SheetContainerDismissAnimationController.swift create mode 100644 Sources/SheetController/SheetContainerPresentationAnimationController.swift create mode 100644 Sources/SheetController/SheetContainerViewController.swift delete mode 100644 Sources/SheetController/SheetController.swift diff --git a/Package.swift b/Package.swift index 4273a74..a94908c 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "SheetController", + platforms: [ + .iOS(.v13) + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( diff --git a/Sources/SheetController/Detent.swift b/Sources/SheetController/Detent.swift new file mode 100644 index 0000000..8728d2f --- /dev/null +++ b/Sources/SheetController/Detent.swift @@ -0,0 +1,31 @@ +// +// Detent.swift +// SheetController +// +// Created by Shadowfacts on 9/23/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit + +public enum Detent: Equatable { + case top + case middle + case bottom + case other(CGFloat) +} + +extension Detent { + func offset(in view: UIView) -> CGFloat { + switch self { + case .top: + return max(view.safeAreaInsets.top, 50) + case .middle: + return view.bounds.midY + case .bottom: + return view.bounds.height - view.safeAreaInsets.bottom - 100//max(view.safeAreaInsets.bottom, 100) + case let .other(value): + return value + } + } +} diff --git a/Sources/SheetController/MathHelpers.swift b/Sources/SheetController/MathHelpers.swift new file mode 100644 index 0000000..d8f89b5 --- /dev/null +++ b/Sources/SheetController/MathHelpers.swift @@ -0,0 +1,30 @@ +// +// MathHelpers.swift +// SheetController +// +// Created by Shadowfacts on 9/24/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit + +func clamp(_ value: CGFloat, from: CGFloat, to: CGFloat) -> CGFloat { + if value < from { + return from + } else if value > to { + return to + } else { + return value + } +} + +func smoothstep(value: CGFloat, from: CGFloat, to: CGFloat) -> CGFloat { + let x = clamp((value - from) / (to - from), from: 0, to: 1) + // 3x^2 - 2x^3 + return 3 * pow(x, 2) - 2 * pow(x, 3) +} + +func lerp(_ value: CGFloat, min: CGFloat, max: CGFloat, from: CGFloat, to: CGFloat) -> CGFloat { + let value = clamp((value - min) / (max - min), from: 0, to: 1) + return value * (to - from) + from +} diff --git a/Sources/SheetController/SheetContainerDismissAnimationController.swift b/Sources/SheetController/SheetContainerDismissAnimationController.swift new file mode 100644 index 0000000..9e036ce --- /dev/null +++ b/Sources/SheetController/SheetContainerDismissAnimationController.swift @@ -0,0 +1,46 @@ +// +// SheetContainerDismissAnimationController.swift +// SheetController +// +// Created by Shadowfacts on 9/24/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit + +class SheetContainerDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.35 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from) as? SheetContainerViewController else { + fatalError() + } + + fromVC.view.alpha = 1.0 + fromVC.dimmingView.isHidden = true + + // match the frame to the original dimming view's frame so that it doesn't go under the content + let dimmingView = UIView(frame: fromVC.dimmingView.bounds) + dimmingView.backgroundColor = fromVC.dimmingView.backgroundColor + dimmingView.alpha = fromVC.dimmingView.alpha + + let container = transitionContext.containerView + container.addSubview(dimmingView) + container.addSubview(fromVC.view) + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, animations: { + dimmingView.frame = container.bounds + dimmingView.alpha = 0 + fromVC.view.transform = CGAffineTransform(translationX: 0, y: fromVC.content.view.bounds.height) + }, completion: { (finished) in + dimmingView.removeFromSuperview() + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + } + +} diff --git a/Sources/SheetController/SheetContainerPresentationAnimationController.swift b/Sources/SheetController/SheetContainerPresentationAnimationController.swift new file mode 100644 index 0000000..1cc50b5 --- /dev/null +++ b/Sources/SheetController/SheetContainerPresentationAnimationController.swift @@ -0,0 +1,53 @@ +// +// SheetContainerPresentationAnimationController.swift +// SheetController +// +// Created by Shadowfacts on 9/24/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit + +class SheetContainerPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.35 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let toVC = transitionContext.viewController(forKey: .to) as? SheetContainerViewController else { + fatalError() + } + + toVC.dimmingView.isHidden = true + + let finalFrame = transitionContext.finalFrame(for: toVC) + + let dimmingView = UIView(frame: finalFrame) + dimmingView.backgroundColor = toVC.dimmingView.backgroundColor + dimmingView.alpha = 0 + + let container = transitionContext.containerView + container.addSubview(dimmingView) + container.addSubview(toVC.view) + + toVC.view.transform = CGAffineTransform(translationX: 0, y: toVC.initialConstant) + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, animations: { + // we animate the dimming view's frame so that it doesn't go under the content view, in case there's a transparent background + // we also extend the dimming view under any rounded corners the content view has + dimmingView.frame = CGRect(x: 0, y: 0, width: dimmingView.bounds.width, height: toVC.initialConstant + toVC.content.view.layer.cornerRadius) + dimmingView.alpha = toVC.dimmingView.alpha + toVC.view.transform = .identity + }, completion: { (finished) in + dimmingView.removeFromSuperview() + toVC.dimmingView.isHidden = false + + toVC.view.frame = finalFrame + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + + } + +} diff --git a/Sources/SheetController/SheetContainerViewController.swift b/Sources/SheetController/SheetContainerViewController.swift new file mode 100644 index 0000000..84077c5 --- /dev/null +++ b/Sources/SheetController/SheetContainerViewController.swift @@ -0,0 +1,252 @@ +// +// SheetContainerViewController.swift +// SheetController +// +// 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() + } +} diff --git a/Sources/SheetController/SheetController.swift b/Sources/SheetController/SheetController.swift deleted file mode 100644 index 497cb9e..0000000 --- a/Sources/SheetController/SheetController.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct SheetController { - var text = "Hello, World!" -}