Add sheet controller
This commit is contained in:
parent
5781e29f5d
commit
ca374422db
|
@ -5,6 +5,9 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "SheetController",
|
name: "SheetController",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v13)
|
||||||
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries produced by a package, and make them visible to other packages.
|
// Products define the executables and libraries produced by a package, and make them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
struct SheetController {
|
|
||||||
var text = "Hello, World!"
|
|
||||||
}
|
|
Loading…
Reference in New Issue