Compare commits

...

3 Commits

5 changed files with 126 additions and 57 deletions

View File

@ -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;
}; };

View File

@ -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

View File

@ -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
}

View File

@ -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 bottomDetent: (detent: Detent, offset: CGFloat) {
return detents.map { ($0, $0.offset(in: view)) }.max(by: { $0.1 < $1.1 })!
} }
var sortedDetentOffsets: [CGFloat] = []
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)
self.topConstraint.constant = springToDetent.1
let springVelocity = velocity.y / springDistance 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 })
} }
} }

View File

@ -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
}
}