Skip to content

Instantly share code, notes, and snippets.

@jstheoriginal
Last active April 14, 2021 17:01
Show Gist options
  • Save jstheoriginal/69fe963c21a3be6e64bc07aae8b6569c to your computer and use it in GitHub Desktop.
Save jstheoriginal/69fe963c21a3be6e64bc07aae8b6569c to your computer and use it in GitHub Desktop.
A bottom anchored modal (bottom sheet) that allows adding a child view controller. It will self-size itself based on the content in the child view controller. It respects the safe area, including having a max height. To ensure content can be fully viewed if it hits the max height, the child view controller needs to have a tableview or scrollview.
//
// BottomAnchoredModalTransitioner.swift
// BB Links
//
// Created by Justin Stanley on 2017-09-07.
// Copyright © 2017 Justin Stanley. All rights reserved.
//
import Foundation
import UIKit
internal final class BottomAnchoredModalTransitioner: NSObject, UIViewControllerTransitioningDelegate {
internal let transitionAnimator = CustomModalTransition()
internal var modalPresentAnimations: TransitionAnimations?
internal var modalDismissAnimations: TransitionAnimations?
internal var interactionIsInProgress = false
internal var shouldCompleteTransition = false
public func animationController(forPresented _: UIViewController,
presenting _: UIViewController,
source _: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
transitionAnimator.context = .presenting
return transitionAnimator
}
public func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionAnimator.context = .dismissing
return transitionAnimator
}
public func interactionControllerForPresentation(using _: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
nil
}
public func interactionControllerForDismissal(using _: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
transitionAnimator.context = .dismissing
return transitionAnimator
}
public func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source _: UIViewController) -> UIPresentationController?
{
CustomModalPresentationController(presentedViewController: presented, presenting: presenting)
}
}
//
// BottomAnchoredModalViewController.swift
// BB Links
//
// Created by Justin Stanley on 2017-09-09.
// Copyright © 2017 Justin Stanley. All rights reserved.
//
import Foundation
import SnapKit
import UIKit
// MARK: - Direction
internal enum Direction {
case upwards
case downwards
case left
case right
case undetermined
}
// MARK: - UIPanGestureRecognizer
internal extension UIPanGestureRecognizer {
var direction: Direction {
let vel = velocity(in: view)
let isVertical = abs(vel.y) > abs(vel.x)
switch (isVertical, vel.x, vel.y) {
case (true, _, let yVelocity) where yVelocity < 0: return .upwards
case (true, _, let yVelocity) where yVelocity > 0: return .downwards
case (false, let xVelocity, _) where xVelocity > 0: return .right
case (false, let xVelocity, _) where xVelocity < 0: return .left
default: return .undetermined
}
}
var isDownwardPan: Bool {
switch direction {
case .downwards: return true
case .upwards, .left, .right, .undetermined: return false
}
}
}
// MARK: - BottomAnchoredModalViewControllerProtocol
internal protocol BottomAnchoredModalViewControllerProtocol: AnyObject {
var view: UIView! { get }
var title: String? { get set }
var onCloseButtonTap: (() -> Void)? { get set }
var contentView: UIScrollView { get }
}
internal typealias TransitionAnimations = (() -> Void)
internal typealias TransitionCompletion = (() -> Void)
// MARK: - BottomAnchoredModalViewController
internal final class BottomAnchoredModalViewController: UIViewController {
// MARK: Outlets
@IBOutlet private var modalContainer: UIView!
@IBOutlet private var childVCContainer: UIView!
@IBOutlet private var maskView: UIView!
@IBOutlet private var headerContainer: UIView!
@IBOutlet private var titleLabel: UILabel!
@IBOutlet private var closeButton: UIButton!
@IBOutlet private var closeHandle: UIImageView!
// MARK: Properties
private let dimmedView = UIView()
private let childVC: BottomAnchoredModalViewControllerProtocol
private let transitioner = BottomAnchoredModalTransitioner()
private var modalShowingConstraint: Constraint?
private var modalHiddenConstraint: Constraint?
private static let presentedAlpha: CGFloat = 1
private let minTopGapSize: CGFloat = 20
private var fullModalContentHeight: CGFloat {
childVC.contentView.contentSize.height + childVC.contentView.adjustedContentInset.top + childVC.contentView.adjustedContentInset.bottom
}
private var maxModalContentHeight: CGFloat {
view.bounds.height - headerContainer.bounds.height - topGapMinHeight
}
private var topGapMinHeight: CGFloat {
view.safeAreaInsets.top + minTopGapSize
}
internal init(childVC: BottomAnchoredModalViewControllerProtocol) {
self.childVC = childVC
super.init(nibName: String(describing: type(of: self)), bundle: nil)
transitioningDelegate = transitioner
modalPresentationStyle = .overFullScreen
transitioner.transitionAnimator.presentAnimator = presentAnimatorClosure()
transitioner.transitionAnimator.dismissAnimator = dismissAnimatorClosure()
transitioner.transitionAnimator.onPanCancelled = onPanCancelled()
transitioner.transitionAnimator.onDismissEnd = onDismissEnd()
}
@available(*, unavailable)
internal required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override internal func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(updateDisplay),
name: NSNotification.Name(rawValue: ThemeChangedNotification), object: nil)
setupViews()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
childVC.view.layoutIfNeeded()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
childVC.view.layoutIfNeeded()
childVC.view.snp.remakeConstraints {
$0.edges.equalToSuperview()
$0.height.equalTo(min(self.fullModalContentHeight, self.maxModalContentHeight))
}
}
// MARK: Setup
private func setupViews() {
view.translatesAutoresizingMaskIntoConstraints = false
setupMainViewColours()
titleLabel.font = .systemFont(ofSize: 22, weight: .black)
titleLabel.text = childVC.title?.uppercased()
dimmedView.alpha = 0
dimmedView.isUserInteractionEnabled = true
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDimmedViewTap))
dimmedView.addGestureRecognizer(tapRecognizer)
view.addSubview(dimmedView)
dimmedView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
setupModalVC()
}
private func setupMainViewColours() {
let theme = ThemeManager.shared.theme
view.backgroundColor = .clear
headerContainer.backgroundColor = theme.primaryBackgroundColor
let titleTextColor: UIColor
if ThemeManager.shared.useSecondaryTintForPrimaryBackgroundColorOption {
titleTextColor = theme.textColorOnSecondaryTintColor
} else {
titleTextColor = theme.primaryTextColor
}
titleLabel.textColor = titleTextColor
dimmedView.backgroundColor = theme.dimmedTransparentBackgroundColor
}
private func setupModalVC() {
setupModalVCColors()
childVC.onCloseButtonTap = { [unowned self] in
self.dismiss(wantsInteractiveStart: false)
}
modalContainer.applyShadow(.Modal)
setupPanGestureRecognizer(on: modalContainer)
view.addSubview(modalContainer)
modalContainer.snp.makeConstraints {
$0.centerX.equalToSuperview()
$0.leading.trailing.lessThanOrEqualToSuperview().priority(.medium)
$0.top.lessThanOrEqualTo(self.view.safeAreaLayoutGuide).offset(minTopGapSize).priority(.high)
$0.width.lessThanOrEqualTo(500).priority(.required)
self.modalShowingConstraint = $0.bottom.equalTo(self.view.snp.bottom).constraint
self.modalHiddenConstraint = $0.top.equalTo(self.view.snp.bottom).offset(20).constraint
}
updateModalConstraints(isShowing: false)
maskView.addCorners()
if let childVC = childVC as? UIViewController {
addChild(childVC)
childVCContainer.addSubview(childVC.view)
childVC.didMove(toParent: self)
childVC.view.snp.makeConstraints {
$0.edges.equalToSuperview()
// temporary to let it calculate its content height
$0.height.equalTo(200)
}
childVC.view.layoutIfNeeded()
}
}
private func setupModalVCColors() {
let theme = ThemeManager.shared.theme
let alpha: CGFloat = 0.35
let tintColor: UIColor
if ThemeManager.shared.useSecondaryTintForPrimaryBackgroundColorOption {
tintColor = theme.textColorOnSecondaryTintColor.withAlphaComponent(alpha)
} else {
tintColor = theme.primaryTextColor.withAlphaComponent(alpha)
}
closeButton.tintColor = tintColor
closeHandle.tintColor = tintColor
modalContainer.backgroundColor = .clear
maskView.backgroundColor = theme.primaryBackgroundColor
}
private func setupPanGestureRecognizer(on view: UIView) {
let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
view.addGestureRecognizer(gestureRecognizer)
}
private func updateModalConstraints(isShowing: Bool) {
if isShowing {
modalHiddenConstraint?.deactivate()
modalShowingConstraint?.activate()
} else {
modalShowingConstraint?.deactivate()
modalHiddenConstraint?.activate()
}
}
// MARK: Gestures
@objc
internal func handleDimmedViewTap(_ recognizer: UITapGestureRecognizer) {
if recognizer.state == .ended {
dismiss(wantsInteractiveStart: false)
}
}
@IBAction private func closeButtonTapped() {
dismiss(wantsInteractiveStart: false)
}
@objc
private func handlePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let gestureRecognizerView = gestureRecognizer.view else { return }
let translation = gestureRecognizer.translation(in: gestureRecognizerView)
var progress = translation.y / gestureRecognizerView.bounds.height
progress = min(max(progress, 0), 1)
switch gestureRecognizer.state {
case .began:
transitioner.interactionIsInProgress = true
dismiss(wantsInteractiveStart: true)
case .changed:
transitioner.shouldCompleteTransition = (gestureRecognizer.isDownwardPan && translation.y > 40) || progress > 0.3
transitioner.transitionAnimator.update(progress)
case .ended:
transitioner.interactionIsInProgress = false
if transitioner.shouldCompleteTransition {
transitioner.transitionAnimator.finish()
} else {
transitioner.transitionAnimator.cancel()
}
case .cancelled:
transitioner.interactionIsInProgress = false
transitioner.transitionAnimator.cancel()
case .failed, .possible:
break
@unknown default:
fatalError("Unknown UIGestureRecognizer.State: \(gestureRecognizer.state)")
}
}
internal func onPanCancelled() -> (() -> Void) {
return { [weak self] in
self?.updateModalConstraints(isShowing: true)
}
}
internal func onDismissEnd() -> (() -> Void) {
return { [weak self] in
self?.childVC.view.removeFromSuperview()
self?.view.removeFromSuperview()
}
}
private func dismiss(wantsInteractiveStart: Bool) {
transitioner.transitionAnimator.wantsInteractiveStart = wantsInteractiveStart
dismiss(animated: true)
}
@objc
private func updateDisplay() {
setupMainViewColours()
setupModalVCColors()
}
// MARK: Animators
private func presentAnimatorClosure() -> (() -> UIViewPropertyAnimator) {
return { [unowned self] in
let animator = UIViewPropertyAnimator(duration: Constants.AnimationDuration.bottomModalPresent, dampingRatio: 0.78)
let dimmedViewAnimations = { [unowned self] in
self.dimmedView.alpha = BottomAnchoredModalViewController.presentedAlpha
}
let modalAnimations = { [unowned self] in
self.updateModalConstraints(isShowing: true)
self.view.layoutIfNeeded()
}
animator.addAnimations(dimmedViewAnimations)
animator.addAnimations(modalAnimations)
return animator
}
}
private func dismissAnimatorClosure() -> (() -> UIViewPropertyAnimator) {
return { [unowned self] in
let animator = UIViewPropertyAnimator(duration: Constants.AnimationDuration.bottomModalDismiss, curve: .easeInOut)
let dimmedViewAnimations = { [unowned self] in
self.dimmedView.alpha = 0
}
let modalAnimations = { [unowned self] in
self.updateModalConstraints(isShowing: false)
self.view.layoutIfNeeded()
}
animator.addAnimations(dimmedViewAnimations)
animator.addAnimations(modalAnimations)
return animator
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="BottomAnchoredModalViewController" customModule="BB_Links" customModuleProvider="target">
<connections>
<outlet property="closeButton" destination="Wze-Dd-bEZ" id="J6X-6X-Oca"/>
<outlet property="closeHandle" destination="sej-t9-lQE" id="Xsu-Oj-y91"/>
<outlet property="headerContainer" destination="a5B-dp-gse" id="Q0y-u1-s2Q"/>
<outlet property="maskView" destination="DvA-6U-B1q" id="1vm-cr-4CY"/>
<outlet property="childVCContainer" destination="mQ6-Qt-rN3" id="Qci-wn-afI"/>
<outlet property="modalContainer" destination="btX-6M-PDH" id="VPn-Cf-rs1"/>
<outlet property="titleLabel" destination="WPO-Gd-5Hb" id="xtY-qi-hB7"/>
<outlet property="view" destination="iN0-l3-epB" id="nST-Cg-JJa"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="330" height="550"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="btX-6M-PDH" userLabel="Modal Container">
<rect key="frame" x="0.0" y="250" width="330" height="300"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DvA-6U-B1q" userLabel="Modal Mask View">
<rect key="frame" x="0.0" y="0.0" width="330" height="350"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mQ6-Qt-rN3" userLabel="Visible Content">
<rect key="frame" x="0.0" y="52" width="330" height="248"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="a5B-dp-gse" userLabel="Modal Header">
<rect key="frame" x="0.0" y="0.0" width="330" height="52"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="WPO-Gd-5Hb">
<rect key="frame" x="30" y="17" width="270" height="27"/>
<constraints>
<constraint firstAttribute="height" constant="27" id="N56-4b-nuj"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="black" pointSize="22"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Wze-Dd-bEZ">
<rect key="frame" x="302" y="3" width="25" height="25"/>
<constraints>
<constraint firstAttribute="height" constant="25" id="Gmn-Uc-bCc"/>
<constraint firstAttribute="width" constant="25" id="Gq2-Wp-E2V"/>
</constraints>
<state key="normal" image="close-glyph"/>
<connections>
<action selector="closeButtonTapped" destination="-1" eventType="touchUpInside" id="ygu-RX-I9j"/>
</connections>
</button>
<imageView userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="handle-glyph" translatesAutoresizingMaskIntoConstraints="NO" id="sej-t9-lQE">
<rect key="frame" x="146.5" y="8" width="37" height="5"/>
<constraints>
<constraint firstAttribute="height" constant="5" id="2jf-fp-9Ih"/>
<constraint firstAttribute="width" constant="37" id="gvS-2g-sX4"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="WPO-Gd-5Hb" secondAttribute="bottom" constant="8" id="6DX-C3-OiV"/>
<constraint firstAttribute="trailing" secondItem="WPO-Gd-5Hb" secondAttribute="trailing" constant="30" id="LIG-Vz-ZKX"/>
<constraint firstItem="WPO-Gd-5Hb" firstAttribute="centerX" secondItem="a5B-dp-gse" secondAttribute="centerX" id="MkO-Bv-fZF"/>
<constraint firstItem="WPO-Gd-5Hb" firstAttribute="top" secondItem="a5B-dp-gse" secondAttribute="top" constant="17" id="Mo8-NZ-ArP"/>
<constraint firstItem="Wze-Dd-bEZ" firstAttribute="top" secondItem="a5B-dp-gse" secondAttribute="top" constant="3" id="RSx-xK-eto"/>
<constraint firstItem="sej-t9-lQE" firstAttribute="centerX" secondItem="a5B-dp-gse" secondAttribute="centerX" id="Ryd-Wc-1Sa"/>
<constraint firstAttribute="trailing" secondItem="Wze-Dd-bEZ" secondAttribute="trailing" constant="3" id="VmX-2i-D0f"/>
<constraint firstItem="WPO-Gd-5Hb" firstAttribute="leading" secondItem="a5B-dp-gse" secondAttribute="leading" constant="30" id="aGE-jq-oZx"/>
<constraint firstItem="sej-t9-lQE" firstAttribute="top" secondItem="a5B-dp-gse" secondAttribute="top" constant="8" id="frd-87-dQJ"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<constraints>
<constraint firstItem="a5B-dp-gse" firstAttribute="top" secondItem="DvA-6U-B1q" secondAttribute="top" id="08G-zb-8EK"/>
<constraint firstAttribute="trailing" secondItem="a5B-dp-gse" secondAttribute="trailing" id="8A9-V8-3Yd"/>
<constraint firstAttribute="bottom" secondItem="mQ6-Qt-rN3" secondAttribute="bottom" constant="50" id="EXW-yv-XTa"/>
<constraint firstItem="a5B-dp-gse" firstAttribute="bottom" secondItem="mQ6-Qt-rN3" secondAttribute="top" id="IEQ-Lx-l0G"/>
<constraint firstItem="a5B-dp-gse" firstAttribute="leading" secondItem="DvA-6U-B1q" secondAttribute="leading" id="aT9-yg-0AA"/>
<constraint firstAttribute="trailing" secondItem="mQ6-Qt-rN3" secondAttribute="trailing" id="eki-mY-hgI"/>
<constraint firstItem="mQ6-Qt-rN3" firstAttribute="leading" secondItem="DvA-6U-B1q" secondAttribute="leading" id="mTI-bY-SlW"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<constraints>
<constraint firstItem="DvA-6U-B1q" firstAttribute="leading" secondItem="btX-6M-PDH" secondAttribute="leading" id="Gaq-Uf-i4e"/>
<constraint firstAttribute="trailing" secondItem="DvA-6U-B1q" secondAttribute="trailing" id="VKR-Z1-AMb"/>
<constraint firstAttribute="height" constant="300" placeholder="YES" id="aaY-8G-289"/>
<constraint firstAttribute="bottom" secondItem="DvA-6U-B1q" secondAttribute="bottom" constant="-50" id="h7c-yA-wFZ"/>
<constraint firstItem="DvA-6U-B1q" firstAttribute="top" secondItem="btX-6M-PDH" secondAttribute="top" id="rWV-Be-XaQ"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="btX-6M-PDH" secondAttribute="trailing" placeholder="YES" id="CUV-9L-55H"/>
<constraint firstItem="btX-6M-PDH" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" placeholder="YES" id="Emd-Ct-ZZG"/>
<constraint firstItem="btX-6M-PDH" firstAttribute="top" relation="greaterThanOrEqual" secondItem="iN0-l3-epB" secondAttribute="top" placeholder="YES" id="dKN-ff-Zk0"/>
<constraint firstAttribute="bottom" secondItem="btX-6M-PDH" secondAttribute="bottom" placeholder="YES" id="oBZ-vd-sep"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<viewLayoutGuide key="safeArea" id="Xke-q2-Jgy"/>
<point key="canvasLocation" x="-8" y="-283.35832083958024"/>
</view>
</objects>
<resources>
<image name="close-glyph" width="15" height="15"/>
<image name="handle-glyph" width="37" height="5"/>
</resources>
</document>
//
// CustomModalPresentationController.swift
// BB Links
//
// Created by Justin Stanley on 2017-09-07.
// Copyright © 2017 Justin Stanley. All rights reserved.
//
import Foundation
import UIKit
internal final class CustomModalPresentationController: UIPresentationController {
override internal var shouldPresentInFullscreen: Bool {
false
}
override internal var frameOfPresentedViewInContainerView: CGRect {
guard let containerView = containerView else { return CGRect() }
// We don't want the presented view to fill the whole container view, so inset it's frame
return (containerView.bounds).insetBy(dx: 50, dy: 50)
}
lazy var dimmingView: UIView = {
let view = UIView(frame: self.containerView!.bounds)
view.backgroundColor = UIColor.blue.withAlphaComponent(0.3)
view.alpha = 0.0
return view
}()
override internal func presentationTransitionWillBegin() {
guard let containerView = containerView, let presentedView = presentedView else { return }
// Add the dimming view and the presented view to the heirarchy
dimmingView.frame = frameOfPresentedViewInContainerView
containerView.addSubview(dimmingView)
containerView.addSubview(presentedView)
// Fade in the dimming view alongside the transition
if let transitionCoordinator = presentingViewController.transitionCoordinator {
transitionCoordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1
}, completion: nil)
}
}
override func presentationTransitionDidEnd(_ completed: Bool) {
// If the presentation didn't complete, remove the dimming view
if !completed {
dimmingView.removeFromSuperview()
}
}
override func dismissalTransitionWillBegin() {
// Fade out the dimming view alongside the transition
if let transitionCoordinator = presentingViewController.transitionCoordinator {
transitionCoordinator.animate(alongsideTransition: { context in
self.dimmingView.alpha = 1 - (0.5 * context.percentComplete)
}, completion: nil)
}
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
// If the dismissal completed, remove the dimming view
if completed {
dimmingView.removeFromSuperview()
}
}
// ---- UIContentContainer protocol methods
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
guard let containerView = containerView else { return }
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.frame = containerView.bounds
}, completion: nil)
}
}
//
// CustomModalTransition.swift
// BB Links
//
// Created by Justin Stanley on 2017-09-09.
// Copyright © 2017 Justin Stanley. All rights reserved.
//
import Foundation
import SnapKit
import UIKit
internal enum CustomModalTransitionContext {
case presenting, dismissing
}
internal final class CustomModalTransition: UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning {
internal var presentAnimator: (() -> UIViewPropertyAnimator)!
internal var dismissAnimator: (() -> UIViewPropertyAnimator)!
internal var onPanCancelled: (() -> Void)?
internal var onDismissEnd: (() -> Void)?
internal var context: CustomModalTransitionContext = .presenting
/// Used when tapped button/view
internal func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
transitionAnimator(using: transitionContext).startAnimation()
}
/// Used when panning
internal func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
transitionAnimator(using: transitionContext)
}
internal func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
switch context {
case .presenting: return Constants.AnimationDuration.bottomModalPresent
case .dismissing: return Constants.AnimationDuration.bottomModalDismiss
}
}
private func transitionAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
switch context {
case .presenting:
guard let toView = transitionContext.view(forKey: .to) else {
fatalError("Failed to get View for the VC transitioning to.")
}
// Add the view of the VC being presented to the presentation container view and layout
transitionContext.containerView.addSubview(toView)
toView.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
transitionContext.containerView.layoutIfNeeded()
let animator = presentAnimator()
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
return animator
case .dismissing:
let animator = dismissAnimator()
animator.addCompletion { [unowned self] position in
switch position {
case .end where transitionContext.transitionWasCancelled:
transitionContext.completeTransition(false)
case .end:
self.onDismissEnd?()
transitionContext.completeTransition(true)
case .start where transitionContext.transitionWasCancelled:
// Need to remake constraints since the values were already changed
self.onPanCancelled?()
transitionContext.completeTransition(false)
case .current, .start:
transitionContext.completeTransition(false)
@unknown
default:
fatalError("Unknown UIViewAnimatingPosition: \(position)")
}
}
return animator
}
}
}
//
// UIView.swift
// BBLinks
//
// Created by Justin Stanley on 2016-07-02.
// Copyright © 2016 Justin Stanley. All rights reserved.
//
import Foundation
import UIKit
// MARK: Styling
enum ShadowDirection {
case above
case below
fileprivate func height(elevation: ShadowElevation) -> CGFloat {
switch self {
case .above: return -elevation.absoluteHeight
case .below: return elevation.absoluteHeight
}
}
}
enum ShadowElevation {
case modal
case floating
case chip
case bar
case low
case none
fileprivate var absoluteHeight: CGFloat {
switch self {
case .modal: return 10
case .floating: return 2
case .chip: return 1
case .low: return 1
case .bar: return 3
case .none: return 0
}
}
fileprivate var radius: CGFloat {
switch self {
case .modal: return 16
case .floating: return 5
case .chip: return 4
case .low: return 2
case .bar: return 2
case .none: return 0
}
}
private var lightThemeOpacity: Float {
switch self {
case .modal: return 0.5
case .floating: return 0.2
case .chip: return 0.2
case .low: return 0.2
case .bar: return 0.2
case .none: return 0
}
}
fileprivate var opacity: Float {
let isLightTheme = ThemeManager.shared.theme.isLightTheme
return isLightTheme ? lightThemeOpacity : 2 * lightThemeOpacity
}
}
struct ShadowStyle {
let direction: ShadowDirection
let elevation: ShadowElevation
init(direction: ShadowDirection = .below, elevation: ShadowElevation) {
self.direction = direction
self.elevation = elevation
}
fileprivate var offset: CGSize {
CGSize(width: 0, height: direction.height(elevation: elevation))
}
fileprivate var radius: CGFloat {
elevation.radius
}
fileprivate var opacity: Float {
elevation.opacity
}
fileprivate var color: CGColor {
UIColor.black.cgColor
}
static var Floating: ShadowStyle {
ShadowStyle(elevation: .floating)
}
static var Chip: ShadowStyle {
ShadowStyle(elevation: .chip)
}
static var Standard: ShadowStyle {
ShadowStyle(elevation: .bar)
}
static var TabBar: ShadowStyle {
ShadowStyle(direction: .above, elevation: .bar)
}
static var Modal: ShadowStyle {
ShadowStyle(elevation: .modal)
}
static var Low: ShadowStyle {
ShadowStyle(elevation: .low)
}
static var None: ShadowStyle {
ShadowStyle(elevation: .none)
}
}
public enum CornerRadius: Equatable {
case small
case smallMedium
case medium
case large
case extraLarge
case buttonSmall
case buttonLarge
case custom(value: CGFloat)
public var value: CGFloat {
switch self {
case .small: return 5
case .smallMedium: return 8
case .medium: return 12
case .large, .buttonSmall: return 15
case .extraLarge: return 20
case .buttonLarge: return 17
case let .custom(value): return value
}
}
}
internal extension UIView {
/// If the view has rounded corners, apply the shadow to a parent view with a nil background colour instead.
func applyShadow(_ style: ShadowStyle) {
layer.masksToBounds = false
layer.shadowColor = style.color
layer.shadowOpacity = style.opacity
layer.shadowRadius = style.radius
layer.shadowOffset = style.offset
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
}
/// Add corners to a view without a shadow. If the view requires a shadow, apply the shadow to a parent view with a nil background colour.
func addCorners(radius: CornerRadius = .large, useContinuousCurve: Bool = true) {
layer.masksToBounds = true
layer.cornerRadius = radius.value
layer.cornerCurve = useContinuousCurve ? .continuous : .circular
}
var safeAreaBottomInset: CGFloat {
safeAreaInsets.bottom
}
}
// MARK: - Layout Extensions
public extension UIView {
/**
Sets the view's constraints equal to its superview. Padding is set to zero unless provided.
You must add the view as a subview of its parent before calling
*/
func addEdgeConstraints(_ view: UIView, padding: UIEdgeInsets? = nil) {
view.snp.makeConstraints { make in
if let padding = padding {
make.edges.equalToSuperview().inset(padding)
} else {
make.edges.equalToSuperview()
}
}
}
/**
Adds the view as a subview and sets the view's constraints equal to its superview. Optionally provide padding to inset by.
*/
func addSubviewAndEdgeConstraints(_ view: UIView, padding: UIEdgeInsets? = nil) {
addSubview(view)
addEdgeConstraints(view, padding: padding)
}
/**
Sets constraints that enable the child to be centered horizontally with the ability to grow vertically. Optionally provide padding to inset by.
You must add the view as a subview of its parent before calling.
*/
func addCenteringConstraints(_ view: UIView, padding: UIEdgeInsets? = nil) {
view.snp.makeConstraints { make in
if let padding = padding {
make.top.bottom.equalToSuperview().inset(padding)
make.leading.greaterThanOrEqualToSuperview().inset(padding)
make.trailing.lessThanOrEqualToSuperview().inset(padding)
} else {
make.top.bottom.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
make.centerX.equalToSuperview()
}
}
/**
Adds the view as a subview and sets constraints that enable the child to be centered horizontally with the ability to grow vertically. Optionally provide padding to inset by.
*/
func addSubviewAndCenteringConstraints(_ view: UIView, padding: UIEdgeInsets? = nil) {
addSubview(view)
addCenteringConstraints(view, padding: padding)
}
}
extension UIView {
static func emptyFullWidthView(height: CGFloat = 15) -> UIView {
UIView(frame: CGRect(x: 0,
y: 0,
width: min(UIScreen.main.bounds.width, UIScreen.main.bounds.height),
height: height))
}
}
// MARK: Rounding Corners
public extension UIView {
/// Note: Since UIRectCorner is a bitmask, you can provide a single value or array of values.
func roundCorners(_ corners: UIRectCorner, cornerRadius: CGFloat) {
layer.cornerRadius = cornerRadius
layer.maskedCorners = corners.maskedCorners
}
}
private extension UIRectCorner {
var maskedCorners: CACornerMask {
var cornerMask = CACornerMask()
if contains(.allCorners) {
cornerMask.insert([.layerMaxXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMinXMinYCorner])
return cornerMask
} else {
if contains(.topLeft) {
cornerMask.insert(.layerMinXMinYCorner)
}
if contains(.topRight) {
cornerMask.insert(.layerMaxXMinYCorner)
}
if contains(.bottomLeft) {
cornerMask.insert(.layerMinXMaxYCorner)
}
if contains(.bottomRight) {
cornerMask.insert(.layerMaxXMaxYCorner)
}
return cornerMask
}
}
}
import UIKit
class SomeVC: UIViewController {
override func viewDidAppear() {
super.viewDidAppear()
// instantiate and setup the child view controller that conforms to the protocol
// the tableView/scrollview probably should have these properties set:
// tableView.insetsContentViewsToSafeArea = true
// tableView.contentInsetAdjustmentBehavior = .scrollableAxes
let childVC = SomeViewController()
// the title will show in the top bar
childVC.title = "Justin was here"
// instantiate the bottom modal vc
let bottomModalVC = BottomAnchoredModalViewController(childVC: childVC)
// present the bottom modal vc
present(bottomModalVC, animated: true)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment