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