Compare commits
3 Commits
3dc36d98c1
...
08846bbc09
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 08846bbc09 | |
Shadowfacts | 9ddd39da4c | |
Shadowfacts | 1eea2313cd |
|
@ -18,6 +18,7 @@
|
||||||
D610D2F6233945C0009EB06A /* SheetImagePicker.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D610D2D1233945AF009EB06A /* SheetImagePicker.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
D610D2F6233945C0009EB06A /* SheetImagePicker.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D610D2D1233945AF009EB06A /* SheetImagePicker.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||||
D610D2FD23394E00009EB06A /* SheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */; };
|
D610D2FD23394E00009EB06A /* SheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */; };
|
||||||
D610D2FF23395975009EB06A /* Detent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D610D2FE23395975009EB06A /* Detent.swift */; };
|
D610D2FF23395975009EB06A /* Detent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D610D2FE23395975009EB06A /* Detent.swift */; };
|
||||||
|
D6B61D63233A748300809DE7 /* MathHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B61D62233A748300809DE7 /* MathHelpers.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -58,6 +59,7 @@
|
||||||
D610D2F0233945BB009EB06A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D610D2F0233945BB009EB06A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetContainerViewController.swift; sourceTree = "<group>"; };
|
D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
D610D2FE23395975009EB06A /* Detent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Detent.swift; sourceTree = "<group>"; };
|
D610D2FE23395975009EB06A /* Detent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Detent.swift; sourceTree = "<group>"; };
|
||||||
|
D6B61D62233A748300809DE7 /* MathHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MathHelpers.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -102,6 +104,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D610D2D4233945AF009EB06A /* SheetImagePicker.h */,
|
D610D2D4233945AF009EB06A /* SheetImagePicker.h */,
|
||||||
|
D6B61D62233A748300809DE7 /* MathHelpers.swift */,
|
||||||
D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */,
|
D610D2FC23394E00009EB06A /* SheetContainerViewController.swift */,
|
||||||
D610D2FE23395975009EB06A /* Detent.swift */,
|
D610D2FE23395975009EB06A /* Detent.swift */,
|
||||||
D610D2D5233945AF009EB06A /* Info.plist */,
|
D610D2D5233945AF009EB06A /* Info.plist */,
|
||||||
|
@ -246,6 +249,7 @@
|
||||||
files = (
|
files = (
|
||||||
D610D2FD23394E00009EB06A /* SheetContainerViewController.swift in Sources */,
|
D610D2FD23394E00009EB06A /* SheetContainerViewController.swift in Sources */,
|
||||||
D610D2FF23395975009EB06A /* Detent.swift in Sources */,
|
D610D2FF23395975009EB06A /* Detent.swift in Sources */,
|
||||||
|
D6B61D63233A748300809DE7 /* MathHelpers.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public enum Detent {
|
public enum Detent: Equatable {
|
||||||
case top
|
case top
|
||||||
case middle
|
case middle
|
||||||
case bottom
|
case bottom
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// MathHelpers.swift
|
||||||
|
// SheetImagePicker
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -8,22 +8,42 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
public protocol SheetContainerViewControllerDelegate {
|
||||||
|
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool
|
||||||
|
func sheetContainer(_ sheetContainer: SheetContainerViewController, didSnapToDetent detent: Detent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// default no-op implementation
|
||||||
|
public extension SheetContainerViewControllerDelegate {
|
||||||
|
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func sheetContainer(_ sheetContainer: SheetContainerViewController, didSnapToDetent detent: Detent) {}
|
||||||
|
}
|
||||||
|
|
||||||
public class SheetContainerViewController: UIViewController {
|
public class SheetContainerViewController: UIViewController {
|
||||||
|
|
||||||
|
public var delegate: SheetContainerViewControllerDelegate?
|
||||||
|
|
||||||
let content: UIViewController
|
let content: UIViewController
|
||||||
|
|
||||||
public var detents: [Detent] = [.bottom, .middle, .top] {
|
public var detents: [Detent] = [.bottom, .middle, .top]
|
||||||
didSet {
|
var topDetent: (detent: Detent, offset: CGFloat) {
|
||||||
sortDetents()
|
return detents.map { ($0, $0.offset(in: view)) }.min(by: { $0.1 < $1.1 })!
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var sortedDetentOffsets: [CGFloat] = []
|
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!
|
var topConstraint: NSLayoutConstraint!
|
||||||
lazy var initialConstant: CGFloat = view.bounds.height / 2
|
lazy var initialConstant: CGFloat = view.bounds.height / 2
|
||||||
|
|
||||||
public var minimumDetentJumpVelocity: CGFloat = 500
|
var dimmingView: UIView!
|
||||||
public var maximumStretchDistance: CGFloat = 15
|
public var minimumDimmingAlpha: CGFloat = 0
|
||||||
|
public var maximumDimmingAlpha: CGFloat = 0.75
|
||||||
|
|
||||||
public init(content: UIViewController) {
|
public init(content: UIViewController) {
|
||||||
self.content = content
|
self.content = content
|
||||||
|
@ -38,6 +58,18 @@ public class SheetContainerViewController: UIViewController {
|
||||||
override public func viewDidLoad() {
|
override public func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
dimmingView = UIView()
|
||||||
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
dimmingView.backgroundColor = .systemGray
|
||||||
|
dimmingView.alpha = (maximumDimmingAlpha - minimumDimmingAlpha) / 2
|
||||||
|
view.addSubview(dimmingView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
addChild(content)
|
addChild(content)
|
||||||
content.didMove(toParent: self)
|
content.didMove(toParent: self)
|
||||||
view.addSubview(content.view)
|
view.addSubview(content.view)
|
||||||
|
@ -53,18 +85,6 @@ public class SheetContainerViewController: UIViewController {
|
||||||
content.view.addGestureRecognizer(panGesture)
|
content.view.addGestureRecognizer(panGesture)
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
sortDetents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sortDetents() {
|
|
||||||
sortedDetentOffsets = detents.map {
|
|
||||||
$0.offset(in: view)
|
|
||||||
}.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
@objc func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
case .began:
|
||||||
|
@ -73,62 +93,59 @@ public class SheetContainerViewController: UIViewController {
|
||||||
case .changed:
|
case .changed:
|
||||||
let translation = recognizer.translation(in: content.view)
|
let translation = recognizer.translation(in: content.view)
|
||||||
var realOffset = initialConstant + translation.y
|
var realOffset = initialConstant + translation.y
|
||||||
if realOffset < sortedDetentOffsets.first! {
|
let topOffset = topDetent.offset
|
||||||
func clamp(_ value: CGFloat, from: CGFloat, to: CGFloat) -> CGFloat {
|
if realOffset < topOffset {
|
||||||
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)
|
|
||||||
}
|
|
||||||
let topOffset = sortedDetentOffsets.first!
|
|
||||||
let smoothed = smoothstep(value: realOffset, from: topOffset, to: 0)
|
let smoothed = smoothstep(value: realOffset, from: topOffset, to: 0)
|
||||||
realOffset = topOffset - smoothed * maximumStretchDistance
|
realOffset = topOffset - smoothed * maximumStretchDistance
|
||||||
|
|
||||||
}
|
}
|
||||||
topConstraint.constant = realOffset
|
topConstraint.constant = realOffset
|
||||||
|
dimmingView.alpha = lerp(realOffset, min: topOffset, max: bottomDetent.offset, from: maximumDimmingAlpha, to: minimumDimmingAlpha)
|
||||||
|
|
||||||
case .ended:
|
case .ended:
|
||||||
let velocity = recognizer.velocity(in: view)
|
let velocity = recognizer.velocity(in: view)
|
||||||
|
|
||||||
let springToDetent: CGFloat
|
let springToDetent: (Detent, CGFloat)
|
||||||
if abs(velocity.y) > minimumDetentJumpVelocity,
|
if abs(velocity.y) > minimumDetentJumpVelocity,
|
||||||
let offsetInVelocityDirection = nearestDetentOffset(currentOffset: topConstraint.constant, direction: velocity.y) {
|
let detentInVelocityDirection = nearestDetentOffset(currentOffset: topConstraint.constant, direction: velocity.y) {
|
||||||
springToDetent = offsetInVelocityDirection
|
springToDetent = detentInVelocityDirection
|
||||||
} else if let nearestOffset = nearestDetentOffset(currentOffset: topConstraint.constant) {
|
} else if let nearestDetent = nearestDetentOffset(currentOffset: topConstraint.constant) {
|
||||||
springToDetent = nearestOffset
|
springToDetent = nearestDetent
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let springDistance = abs(topConstraint.constant - springToDetent)
|
if delegate?.sheetContainer(self, willSnapToDetent: springToDetent.0) ?? true {
|
||||||
self.topConstraint.constant = springToDetent
|
let springDistance = abs(topConstraint.constant - springToDetent.1)
|
||||||
let springVelocity = velocity.y / springDistance
|
self.topConstraint.constant = springToDetent.1
|
||||||
|
let springVelocity = velocity.y / springDistance
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: springVelocity, animations: {
|
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: springVelocity, animations: {
|
||||||
self.view.layoutIfNeeded()
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func nearestDetentOffset(currentOffset: CGFloat) -> CGFloat? {
|
func nearestDetentOffset(currentOffset: CGFloat) -> (Detent, CGFloat)? {
|
||||||
return sortedDetentOffsets.min(by: { abs($0 - currentOffset) < abs($1 - currentOffset) })
|
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) -> CGFloat? {
|
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 {
|
if direction < 0 {
|
||||||
return sortedDetentOffsets.last(where: { $0 < currentOffset })
|
return sorted.last(where: { $0.1 < currentOffset })
|
||||||
} else {
|
} else {
|
||||||
return sortedDetentOffsets.first(where: { $0 > currentOffset })
|
return sorted.first(where: { $0.1 > currentOffset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,12 +14,15 @@ class ViewController: UIViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
// Do any additional setup after loading the view.
|
// Do any additional setup after loading the view.
|
||||||
|
view.backgroundColor = .green
|
||||||
|
|
||||||
let content = UIViewController()
|
let content = UIViewController()
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = false
|
content.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
content.view.backgroundColor = .red
|
content.view.backgroundColor = .red
|
||||||
let sheet = SheetContainerViewController(content: content)
|
let sheet = SheetContainerViewController(content: content)
|
||||||
sheet.view.backgroundColor = .blue
|
sheet.delegate = self
|
||||||
|
sheet.detents = [.bottom, .middle, .top]
|
||||||
|
// sheet.view.backgroundColor = .blue
|
||||||
|
|
||||||
addChild(sheet)
|
addChild(sheet)
|
||||||
sheet.didMove(toParent: self)
|
sheet.didMove(toParent: self)
|
||||||
|
@ -34,3 +37,18 @@ class ViewController: UIViewController {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ViewController: SheetContainerViewControllerDelegate {
|
||||||
|
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool {
|
||||||
|
if detent == .bottom {
|
||||||
|
UIView.animate(withDuration: 0.35, animations: {
|
||||||
|
sheetContainer.view.transform = CGAffineTransform(translationX: 0, y: sheetContainer.view.bounds.height)
|
||||||
|
}, completion: { (finished) in
|
||||||
|
sheetContainer.removeFromParent()
|
||||||
|
sheetContainer.didMove(toParent: nil)
|
||||||
|
sheetContainer.view.removeFromSuperview()
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue