These are the assets used in the .xib file
- Close Button asset: https://www.dropbox.com/s/uu9cjo61ehpfx0e/close-glyph.pdf?dl=0
- Handle asset: https://www.dropbox.com/s/8gf7sty07m3zg17/handle-glyph.pdf?dl=0
// | |
// 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) | |
} | |
} |
These are the assets used in the .xib file