259 lines
10 KiB
Swift
259 lines
10 KiB
Swift
//
|
|
// DuckableContainerViewController.swift
|
|
// Duckable
|
|
//
|
|
// Created by Shadowfacts on 11/7/22.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
let duckedCornerRadius: CGFloat = 10
|
|
let detentHeight: CGFloat = 44
|
|
|
|
@available(iOS 16.0, *)
|
|
public class DuckableContainerViewController: UIViewController {
|
|
|
|
public let child: UIViewController
|
|
private var bottomConstraint: NSLayoutConstraint!
|
|
private(set) var state = State.idle
|
|
|
|
public var duckedViewController: DuckableViewController? {
|
|
if case .ducked(let vc, placeholder: _) = state {
|
|
return vc
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public init(child: UIViewController) {
|
|
self.child = child
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
swizzleSheetController()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError()
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .black
|
|
|
|
child.beginAppearanceTransition(true, animated: false)
|
|
addChild(child)
|
|
child.didMove(toParent: self)
|
|
child.view.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(child.view)
|
|
child.endAppearanceTransition()
|
|
|
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
NSLayoutConstraint.activate([
|
|
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
child.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
bottomConstraint,
|
|
])
|
|
}
|
|
|
|
func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
|
guard case .idle = state else {
|
|
if animated,
|
|
case .ducked(_, placeholder: let placeholder) = state {
|
|
#if !os(visionOS)
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
#endif
|
|
let origConstant = placeholder.topConstraint.constant
|
|
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
|
placeholder.topConstraint.constant = origConstant - 20
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
|
placeholder.topConstraint.constant = origConstant
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if isDucked {
|
|
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
|
|
configureChildForDuckedPlaceholder()
|
|
} else {
|
|
state = .presentingDucked(viewController, isFirstPresentation: true)
|
|
doPresentDuckable(viewController, animated: animated, completion: completion)
|
|
}
|
|
}
|
|
|
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
|
viewController.modalPresentationStyle = .custom
|
|
viewController.transitioningDelegate = self
|
|
present(viewController, animated: animated) {
|
|
self.configureChildForDuckedPlaceholder()
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
func dismissalTransitionWillBegin() {
|
|
guard case .presentingDucked(_, _) = state else {
|
|
return
|
|
}
|
|
state = .idle
|
|
bottomConstraint.isActive = false
|
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
bottomConstraint.isActive = true
|
|
child.view.layer.cornerRadius = 0
|
|
setOverrideTraitCollection(nil, forChild: child)
|
|
}
|
|
|
|
func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController {
|
|
let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self)
|
|
placeholder.view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
placeholder.beginAppearanceTransition(true, animated: false)
|
|
self.addChild(placeholder)
|
|
placeholder.didMove(toParent: self)
|
|
self.view.addSubview(placeholder.view)
|
|
placeholder.endAppearanceTransition()
|
|
|
|
let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight)
|
|
placeholder.topConstraint = placeholderTopConstraint
|
|
NSLayoutConstraint.activate([
|
|
placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
|
placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
|
placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
|
placeholderTopConstraint
|
|
])
|
|
|
|
// otherwise the layout changes get lumped in with the system animation
|
|
UIView.performWithoutAnimation {
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
return placeholder
|
|
}
|
|
|
|
func duckViewController() {
|
|
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
|
return
|
|
}
|
|
switch viewController.duckableViewControllerShouldDuck() {
|
|
case .duck:
|
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
|
state = .ducked(viewController, placeholder: placeholder)
|
|
configureChildForDuckedPlaceholder()
|
|
dismiss(animated: true)
|
|
case .block:
|
|
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
|
case .dismiss:
|
|
// duckableViewControllerWillDismiss()
|
|
dismiss(animated: true)
|
|
}
|
|
}
|
|
|
|
private func configureChildForDuckedPlaceholder() {
|
|
bottomConstraint.isActive = false
|
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
|
bottomConstraint.isActive = true
|
|
|
|
child.view.layer.cornerRadius = duckedCornerRadius
|
|
child.view.layer.cornerCurve = .continuous
|
|
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
|
child.view.layer.masksToBounds = true
|
|
}
|
|
|
|
@objc func unduckViewController() {
|
|
guard case .ducked(let viewController, placeholder: let placeholder) = state else {
|
|
return
|
|
}
|
|
state = .presentingDucked(viewController, isFirstPresentation: false)
|
|
doPresentDuckable(viewController, animated: true) {
|
|
placeholder.view.removeFromSuperview()
|
|
placeholder.willMove(toParent: nil)
|
|
placeholder.removeFromParent()
|
|
}
|
|
}
|
|
|
|
func sheetOffsetDidChange() {
|
|
if case .presentingDucked(let duckable, isFirstPresentation: _) = state {
|
|
duckable.duckableViewControllerMayAttemptToDuck()
|
|
}
|
|
}
|
|
|
|
enum State {
|
|
case idle
|
|
case presentingDucked(DuckableViewController, isFirstPresentation: Bool)
|
|
case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController)
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, *)
|
|
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
|
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
|
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
|
|
controller.delegate = self
|
|
controller.prefersGrabberVisible = true
|
|
controller.selectedDetentIdentifier = .large
|
|
controller.largestUndimmedDetentIdentifier = .bottom
|
|
controller.detents = [
|
|
.custom(identifier: .bottom, resolver: { context in
|
|
return detentHeight
|
|
}),
|
|
.large(),
|
|
]
|
|
return controller
|
|
}
|
|
|
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
if case .ducked(_, placeholder: _) = state {
|
|
return DuckAnimationController(
|
|
owner: self,
|
|
needsShrinkAnimation: isDetentChangingDueToGrabberAction
|
|
)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.0, *)
|
|
class DuckableSheetPresentationController: UISheetPresentationController {
|
|
override func dismissalTransitionWillBegin() {
|
|
super.dismissalTransitionWillBegin()
|
|
(self.delegate as! DuckableContainerViewController).dismissalTransitionWillBegin()
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.0, *)
|
|
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
|
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
|
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
|
|
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
|
return
|
|
}
|
|
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
|
self.view.addSubview(snapshot)
|
|
NSLayoutConstraint.activate([
|
|
snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
|
|
snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
|
|
snapshot.topAnchor.constraint(equalTo: child.view.topAnchor),
|
|
snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
|
|
])
|
|
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
|
transitionCoordinator!.animate { context in
|
|
snapshot.layer.opacity = 0
|
|
} completion: { _ in
|
|
snapshot.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
|
|
if sheetPresentationController.selectedDetentIdentifier == .bottom {
|
|
duckViewController()
|
|
}
|
|
}
|
|
}
|