// // 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, DuckableViewControllerDelegate { 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 { UIImpactFeedbackGenerator(style: .light).impactOccurred() 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.duckableDelegate = self let nav = UINavigationController(rootViewController: viewController) nav.modalPresentationStyle = .custom nav.transitioningDelegate = self present(nav, animated: animated) { self.configureChildForDuckedPlaceholder() completion?() } } public func duckableViewControllerWillDismiss(animated: Bool) { 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 } let placeholder = createPlaceholderForDuckedViewController(viewController) state = .ducked(viewController, placeholder: placeholder) configureChildForDuckedPlaceholder() 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.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 = UISheetPresentationController(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, *) 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() } } }