From b141dc5a45ddcd550cbffc111e3e26dce4e1818d Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Mon, 12 Jun 2023 22:05:03 +0300 Subject: [PATCH] feat: pan modal changes --- .../BottomSheet/recup_dir.107/f9510598.gz | Bin 0 -> 4096 bytes .../PanModal/Animator/PanModalAnimator.swift | 40 + .../PanModalPresentationAnimator.swift | 172 ++++ .../PanModalPresentationController.swift | 881 ++++++++++++++++++ .../PanModalPresentationDelegate.swift | 81 ++ TIBottomSheet/Sources/PanModal/LICENSE | 19 + TIBottomSheet/Sources/PanModal/PanModal.h | 19 + .../PanModal/Presentable/PanModalHeight.swift | 44 + .../PanModalPresentable+Defaults.swift | 128 +++ .../PanModalPresentable+LayoutHelpers.swift | 126 +++ ...PanModalPresentable+UIViewController.swift | 63 ++ .../Presentable/PanModalPresentable.swift | 231 +++++ .../Presenter/PanModalPresenter.swift | 38 + .../UIViewController+PanModalPresenter.swift | 66 ++ .../Sources/PanModal/View/DimmedView.swift | 131 +++ .../PanModal/View/PanContainerView.swift | 44 + .../Sources/Appearance/UIView+Layout.swift | 8 + ...uttonStyle.swift => BaseButtonStyle.swift} | 2 +- .../StatefulButton+ApplyStyle.swift | 8 + 19 files changed, 2100 insertions(+), 1 deletion(-) create mode 100644 TIBottomSheet/Sources/BottomSheet/recup_dir.107/f9510598.gz create mode 100644 TIBottomSheet/Sources/PanModal/Animator/PanModalAnimator.swift create mode 100644 TIBottomSheet/Sources/PanModal/Animator/PanModalPresentationAnimator.swift create mode 100644 TIBottomSheet/Sources/PanModal/Controller/PanModalPresentationController.swift create mode 100644 TIBottomSheet/Sources/PanModal/Delegate/PanModalPresentationDelegate.swift create mode 100644 TIBottomSheet/Sources/PanModal/LICENSE create mode 100644 TIBottomSheet/Sources/PanModal/PanModal.h create mode 100644 TIBottomSheet/Sources/PanModal/Presentable/PanModalHeight.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+Defaults.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+UIViewController.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presenter/PanModalPresenter.swift create mode 100644 TIBottomSheet/Sources/PanModal/Presenter/UIViewController+PanModalPresenter.swift create mode 100644 TIBottomSheet/Sources/PanModal/View/DimmedView.swift create mode 100644 TIBottomSheet/Sources/PanModal/View/PanContainerView.swift create mode 100644 TIUIElements/Sources/Appearance/UIView+Layout.swift rename TIUIElements/Sources/Views/Placeholder/Styles/{PlaceholderButtonStyle.swift => BaseButtonStyle.swift} (98%) create mode 100644 TIUIElements/Sources/Views/StatefulButton/StatefulButton+ApplyStyle.swift diff --git a/TIBottomSheet/Sources/BottomSheet/recup_dir.107/f9510598.gz b/TIBottomSheet/Sources/BottomSheet/recup_dir.107/f9510598.gz new file mode 100644 index 0000000000000000000000000000000000000000..94e0ad63e497fc23325d9257fb66539e0f245090 GIT binary patch literal 4096 zcmb2|=3oE=;kRK2^Da9GsCqhvc|~7gV3$8IqbZ(O{vet5ee*N5UWg#vO)`iH9ibQFYl3>>`X;GQHfwm5o=xm4mR5K)wVQE@pcUJl zNsPYLscQ<#g#4bDKdRB0@WlK{&0Rgmef}Hz86Hj)DfM?Vee*gJ^pe%AW#-p_gMYdie+ ze=NLp{)f}wd+i-RmS5f PanModalPresentable.LayoutType? { + switch transitionStyle { + case .presentation: + return context.viewController(forKey: .to) as? PanModalPresentable.LayoutType + case .dismissal: + return context.viewController(forKey: .from) as? PanModalPresentable.LayoutType + } + } + +} + +// MARK: - UIViewControllerAnimatedTransitioning Delegate + +extension PanModalPresentationAnimator: UIViewControllerAnimatedTransitioning { + + /** + Returns the transition duration + */ + public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + + guard + let context = transitionContext, + let presentable = panModalLayoutType(from: context) + else { return PanModalAnimator.Constants.defaultTransitionDuration } + + return presentable.transitionDuration + } + + /** + Performs the appropriate animation based on the transition style + */ + public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + switch transitionStyle { + case .presentation: + animatePresentation(transitionContext: transitionContext) + case .dismissal: + animateDismissal(transitionContext: transitionContext) + } + } + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Controller/PanModalPresentationController.swift b/TIBottomSheet/Sources/PanModal/Controller/PanModalPresentationController.swift new file mode 100644 index 00000000..bf1dab90 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Controller/PanModalPresentationController.swift @@ -0,0 +1,881 @@ +// +// PanModalPresentationController.swift +// PanModal +// +// Copyright © 2019 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import TIUIKitCore +import UIKit + +/** + The PanModalPresentationController is the middle layer between the presentingViewController + and the presentedViewController. + + It controls the coordination between the individual transition classes as well as + provides an abstraction over how the presented view is presented & displayed. + + For example, we add a drag indicator view above the presented view and + a background overlay between the presenting & presented view. + + The presented view's layout configuration & presentation is defined using the PanModalPresentable. + + By conforming to the PanModalPresentable protocol & overriding values + the presented view can define its layout configuration & presentation. + */ +open class PanModalPresentationController: UIPresentationController { + + /** + Enum representing the possible presentation states + */ + public enum PresentationState { + case shortForm + case mediumForm + case longForm + } + + /** + Constants + */ + struct Constants { + static let indicatorYOffset = CGFloat(8.0) + static let snapMovementSensitivity = CGFloat(0.7) + static let dragIndicatorSize = CGSize(width: 36.0, height: 5.0) + } + + // MARK: - Properties + + /** + A flag to track if the presented view is animating + */ + private var isPresentedViewAnimating = false + + /** + A flag to determine if scrolling should seamlessly transition + from the pan modal container view to the scroll view + once the scroll limit has been reached. + */ + private var extendsPanScrolling = true + + /** + A flag to determine if scrolling should be limited to the longFormHeight. + Return false to cap scrolling at .max height. + */ + private var anchorModalToLongForm = true + + /** + The y content offset value of the embedded scroll view + */ + private var scrollViewYOffset: CGFloat = 0.0 + + /** + An observer for the scroll view content offset + */ + private var scrollObserver: NSKeyValueObservation? + + // store the y positions so we don't have to keep re-calculating + + /** + The y value for the short form presentation state + */ + private var shortFormYPosition: CGFloat = 0 + + private var mediumFormYPosition: CGFloat = 0 + + /** + The y value for the long form presentation state + */ + private var longFormYPosition: CGFloat = 0 + + private var allowsDragToDismiss: Bool { + presentable?.onDragToDismiss != nil + } + + /** + Determine anchored Y postion based on the `anchorModalToLongForm` flag + */ + private var anchoredYPosition: CGFloat { + let defaultTopOffset = presentable?.topOffset ?? 0 + return anchorModalToLongForm ? longFormYPosition : defaultTopOffset + } + + /** + Configuration object for PanModalPresentationController + */ + private var presentable: PanModalPresentable? { + return presentedViewController as? PanModalPresentable + } + + // MARK: - Views + + /** + Background view used as an overlay over the presenting view + */ + private lazy var backgroundView: DimmedView = { + let view: DimmedView + let type = presentable?.dimmedViewType ?? .opaque + + if let color = presentable?.panModalBackgroundColor { + view = DimmedView(presentingController: presentingViewController, dimColor: color, appearanceType: type) + } else { + view = DimmedView(presentingController: presentingViewController, appearanceType: type) + } + + view.didTap = { [weak self] in + self?.presentable?.onTapToDismiss?() + } + + return view + }() + + /** + A wrapper around the presented view so that we can modify + the presented view apperance without changing + the presented view's properties + */ + private lazy var panContainerView: PanContainerView = { + let frame = containerView?.frame ?? .zero + return PanContainerView(presentedView: presentedViewController.view, frame: frame) + }() + + /** + Override presented view to return the pan container wrapper + */ + public override var presentedView: UIView { + return panContainerView + } + + // MARK: - Gesture Recognizers + + /** + Gesture recognizer to detect & track pan gestures + */ + private lazy var panGestureRecognizer: UIPanGestureRecognizer = { + let gesture = UIPanGestureRecognizer(target: self, action: #selector(didPanOnPresentedView(_ :))) + gesture.minimumNumberOfTouches = 1 + gesture.maximumNumberOfTouches = 1 + gesture.delegate = self + return gesture + }() + + // MARK: - Deinitializers + + deinit { + scrollObserver?.invalidate() + } + + // MARK: - Lifecycle + + override public func containerViewWillLayoutSubviews() { + super.containerViewWillLayoutSubviews() + configureViewLayout() + } + + override public func presentationTransitionWillBegin() { + + guard let containerView = containerView + else { return } + + layoutBackgroundView(in: containerView) + layoutPresentedView(in: containerView) + configureScrollViewInsets() + configureShadowIfNeeded() + + guard let coordinator = presentedViewController.transitionCoordinator else { + backgroundView.dimState = .max + return + } + + coordinator.animate(alongsideTransition: { [weak self] _ in + if let yPos = self?.shortFormYPosition { + self?.adjust(toYPosition: yPos) + } + self?.presentedViewController.setNeedsStatusBarAppearanceUpdate() + }) + } + + override public func presentationTransitionDidEnd(_ completed: Bool) { + if completed { return } + + backgroundView.removeFromSuperview() + } + + override public func dismissalTransitionWillBegin() { + presentable?.panModalWillDismiss() + + guard let coordinator = presentedViewController.transitionCoordinator else { + backgroundView.dimState = .off + return + } + + /** + Drag indicator is drawn outside of view bounds + so hiding it on view dismiss means avoiding visual bugs + */ + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.backgroundView.dimState = .off + self?.presentingViewController.setNeedsStatusBarAppearanceUpdate() + }) + } + + override public func dismissalTransitionDidEnd(_ completed: Bool) { + if !completed { return } + + presentable?.panModalDidDismiss() + } + + /** + Update presented view size in response to size class changes + */ + override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { [weak self] _ in + guard + let self = self, + let presentable = self.presentable + else { return } + + self.adjustPresentedViewFrame() + if presentable.shouldRoundTopCorners { + self.addRoundedCorners(to: self.presentedView) + } + }) + } + +} + +// MARK: - Public Methods + +public extension PanModalPresentationController { + + /** + Transition the PanModalPresentationController + to the given presentation state + */ + func transition(to state: PresentationState) { + + guard presentable?.shouldTransition(to: state) == true + else { return } + + presentable?.willTransition(to: state) + + switch state { + case .shortForm: + snap(toYPosition: shortFormYPosition) + + case .mediumForm: + snap(toYPosition: mediumFormYPosition) + + case .longForm: + snap(toYPosition: longFormYPosition) + } + } + + /** + Operations on the scroll view, such as content height changes, + or when inserting/deleting rows can cause the pan modal to jump, + caused by the pan modal responding to content offset changes. + + To avoid this, you can call this method to perform scroll view updates, + with scroll observation temporarily disabled. + */ + func performUpdates(_ updates: () -> Void) { + + guard let scrollView = presentable?.panScrollable + else { return } + + // Pause scroll observer + scrollObserver?.invalidate() + scrollObserver = nil + + // Perform updates + updates() + + // Resume scroll observer + trackScrolling(scrollView) + observe(scrollView: scrollView) + } + + /** + Updates the PanModalPresentationController layout + based on values in the PanModalPresentable + + - Note: This should be called whenever any + pan modal presentable value changes after the initial presentation + */ + func setNeedsLayoutUpdate() { + configureViewLayout() + adjustPresentedViewFrame() + observe(scrollView: presentable?.panScrollable) + configureScrollViewInsets() + } + +} + +// MARK: - Presented View Layout Configuration + +private extension PanModalPresentationController { + + /** + Boolean flag to determine if the presented view is anchored + */ + var isPresentedViewAnchored: Bool { + if !isPresentedViewAnimating + && extendsPanScrolling + && presentedView.frame.minY.rounded() <= anchoredYPosition.rounded() { + return true + } + + return false + } + + /** + Adds the presented view to the given container view + & configures the view elements such as drag indicator, rounded corners + based on the pan modal presentable. + */ + func layoutPresentedView(in containerView: UIView) { + + /** + If the presented view controller does not conform to pan modal presentable + don't configure + */ + guard let presentable = presentable + else { return } + + /** + ⚠️ If this class is NOT used in conjunction with the PanModalPresentationAnimator + & PanModalPresentable, the presented view should be added to the container view + in the presentation animator instead of here + */ + containerView.addSubview(presentedView) + containerView.addGestureRecognizer(panGestureRecognizer) + + if presentable.shouldRoundTopCorners { + addRoundedCorners(to: presentedView) + } + + setNeedsLayoutUpdate() + adjustPanContainerBackgroundColor() + } + + /** + Reduce height of presentedView so that it sits at the bottom of the screen + */ + func adjustPresentedViewFrame() { + + guard let frame = containerView?.frame + else { return } + + let adjustedSize = CGSize(width: frame.size.width, height: frame.size.height - anchoredYPosition) + let panFrame = panContainerView.frame + panContainerView.frame.size = frame.size + + if ![shortFormYPosition, mediumFormYPosition, longFormYPosition].contains(panFrame.origin.y) { + // if the container is already in the correct position, no need to adjust positioning + // (rotations & size changes cause positioning to be out of sync) + let yPosition = panFrame.origin.y - panFrame.height + frame.height + presentedView.frame.origin.y = max(yPosition, anchoredYPosition) + } + panContainerView.frame.origin.x = frame.origin.x + presentedViewController.view.frame = CGRect(origin: .zero, size: adjustedSize) + } + + /** + Adds a background color to the pan container view + in order to avoid a gap at the bottom + during initial view presentation in longForm (when view bounces) + */ + func adjustPanContainerBackgroundColor() { + panContainerView.backgroundColor = presentedViewController.view.backgroundColor + ?? presentable?.panScrollable?.backgroundColor + } + + /** + Adds the background view to the view hierarchy + & configures its layout constraints. + */ + func layoutBackgroundView(in containerView: UIView) { + containerView.addSubview(backgroundView) + backgroundView.translatesAutoresizingMaskIntoConstraints = false + backgroundView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true + backgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true + backgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true + backgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true + } + + /** + Calculates & stores the layout anchor points & options + */ + func configureViewLayout() { + + guard let layoutPresentable = presentedViewController as? PanModalPresentable.LayoutType + else { return } + + shortFormYPosition = layoutPresentable.shortFormYPos + mediumFormYPosition = layoutPresentable.mediumFormYPos + longFormYPosition = layoutPresentable.longFormYPos + anchorModalToLongForm = layoutPresentable.anchorModalToLongForm + extendsPanScrolling = layoutPresentable.allowsExtendedPanScrolling + + containerView?.isUserInteractionEnabled = layoutPresentable.isUserInteractionEnabled + } + + /** + Configures the scroll view insets + */ + func configureScrollViewInsets() { + + guard + let scrollView = presentable?.panScrollable, + !scrollView.isScrolling + else { return } + + /** + Disable vertical scroll indicator until we start to scroll + to avoid visual bugs + */ + scrollView.showsVerticalScrollIndicator = false + scrollView.scrollIndicatorInsets = presentable?.scrollIndicatorInsets ?? .zero + + /** + Set the appropriate contentInset as the configuration within this class + offsets it + */ + scrollView.contentInset.bottom = presentingViewController.bottomLayoutGuide.length + + /** + As we adjust the bounds during `handleScrollViewTopBounce` + we should assume that contentInsetAdjustmentBehavior will not be correct + */ + if #available(iOS 11.0, *) { + scrollView.contentInsetAdjustmentBehavior = .never + } + } + + func configureShadowIfNeeded() { + let type = presentable?.dimmedViewType ?? .opaque + + if case let .transparentWithShadow(shadow) = type { + containerView?.configureUIView(appearance: UIView.DefaultAppearance(shadow: shadow)) + } + } +} + +// MARK: - Pan Gesture Event Handler + +private extension PanModalPresentationController { + + /** + The designated function for handling pan gesture events + */ + @objc func didPanOnPresentedView(_ recognizer: UIPanGestureRecognizer) { + + guard + shouldRespond(to: recognizer), + let containerView = containerView + else { + recognizer.setTranslation(.zero, in: recognizer.view) + return + } + + switch recognizer.state { + case .began, .changed: + + /** + Respond accordingly to pan gesture translation + */ + respond(to: recognizer) + + /** + If presentedView is translated above the longForm threshold, treat as transition + */ + if presentedView.frame.origin.y == anchoredYPosition && extendsPanScrolling { + presentable?.willTransition(to: .longForm) + } + + default: + + /** + Use velocity sensitivity value to restrict snapping + */ + let velocity = recognizer.velocity(in: presentedView) + + if isVelocityWithinSensitivityRange(velocity.y) { + + /** + If velocity is within the sensitivity range, + transition to a presentation state or dismiss entirely. + + This allows the user to dismiss directly from long form + instead of going to the short form state first. + */ + if velocity.y < 0 { + transition(to: .longForm) + + } else if nearest(to: presentedView.frame.minY, inValues: [mediumFormYPosition, containerView.bounds.height]) == mediumFormYPosition + && presentedView.frame.minY < mediumFormYPosition { + transition(to: .mediumForm) + + } else if (nearest(to: presentedView.frame.minY, inValues: [longFormYPosition, containerView.bounds.height]) == longFormYPosition + && presentedView.frame.minY < shortFormYPosition) || allowsDragToDismiss == false { + transition(to: .shortForm) + + } else { + presentable?.onDragToDismiss?() + } + + } else { + + /** + The `containerView.bounds.height` is used to determine + how close the presented view is to the bottom of the screen + */ + let position = nearest(to: presentedView.frame.minY, inValues: [containerView.bounds.height, shortFormYPosition, mediumFormYPosition, longFormYPosition]) + + if position == longFormYPosition { + transition(to: .longForm) + + } else if position == mediumFormYPosition { + transition(to: .mediumForm) + + } else if position == shortFormYPosition || allowsDragToDismiss == false { + transition(to: .shortForm) + + } else { + presentable?.onDragToDismiss?() + } + } + } + } + + /** + Determine if the pan modal should respond to the gesture recognizer. + + If the pan modal is already being dragged & the delegate returns false, ignore until + the recognizer is back to it's original state (.began) + + ⚠️ This is the only time we should be cancelling the pan modal gesture recognizer + */ + func shouldRespond(to panGestureRecognizer: UIPanGestureRecognizer) -> Bool { + guard + presentable?.shouldRespond(to: panGestureRecognizer) == true || + !(panGestureRecognizer.state == .began || panGestureRecognizer.state == .cancelled) + else { + panGestureRecognizer.isEnabled = false + panGestureRecognizer.isEnabled = true + return false + } + return !shouldFail(panGestureRecognizer: panGestureRecognizer) + } + + /** + Communicate intentions to presentable and adjust subviews in containerView + */ + func respond(to panGestureRecognizer: UIPanGestureRecognizer) { + presentable?.willRespond(to: panGestureRecognizer) + + var yDisplacement = panGestureRecognizer.translation(in: presentedView).y + + /** + If the presentedView is not anchored to long form, reduce the rate of movement + above the threshold + */ + if presentedView.frame.origin.y < longFormYPosition { + yDisplacement /= 2.0 + } + adjust(toYPosition: presentedView.frame.origin.y + yDisplacement) + + panGestureRecognizer.setTranslation(.zero, in: presentedView) + } + + /** + Determines if we should fail the gesture recognizer based on certain conditions + + We fail the presented view's pan gesture recognizer if we are actively scrolling on the scroll view. + This allows the user to drag whole view controller from outside scrollView touch area. + + Unfortunately, cancelling a gestureRecognizer means that we lose the effect of transition scrolling + from one view to another in the same pan gesture so don't cancel + */ + func shouldFail(panGestureRecognizer: UIPanGestureRecognizer) -> Bool { + + /** + Allow api consumers to override the internal conditions & + decide if the pan gesture recognizer should be prioritized. + + ⚠️ This is the only time we should be cancelling the panScrollable recognizer, + for the purpose of ensuring we're no longer tracking the scrollView + */ + guard !shouldPrioritize(panGestureRecognizer: panGestureRecognizer) else { + presentable?.panScrollable?.panGestureRecognizer.isEnabled = false + presentable?.panScrollable?.panGestureRecognizer.isEnabled = true + return false + } + + guard + isPresentedViewAnchored, + let scrollView = presentable?.panScrollable, + scrollView.contentOffset.y > 0 + else { + return false + } + + let loc = panGestureRecognizer.location(in: presentedView) + return (scrollView.frame.contains(loc) || scrollView.isScrolling) + } + + /** + Determine if the presented view's panGestureRecognizer should be prioritized over + embedded scrollView's panGestureRecognizer. + */ + func shouldPrioritize(panGestureRecognizer: UIPanGestureRecognizer) -> Bool { + return panGestureRecognizer.state == .began && + presentable?.shouldPrioritize(panModalGestureRecognizer: panGestureRecognizer) == true + } + + /** + Check if the given velocity is within the sensitivity range + */ + func isVelocityWithinSensitivityRange(_ velocity: CGFloat) -> Bool { + return (abs(velocity) - (1000 * (1 - Constants.snapMovementSensitivity))) > 0 + } + + func snap(toYPosition yPos: CGFloat) { + PanModalAnimator.animate({ [weak self] in + self?.adjust(toYPosition: yPos) + self?.isPresentedViewAnimating = true + }, config: presentable) { [weak self] didComplete in + self?.isPresentedViewAnimating = !didComplete + } + } + + /** + Sets the y position of the presentedView & adjusts the backgroundView. + */ + func adjust(toYPosition yPos: CGFloat) { + presentedView.frame.origin.y = max(yPos, anchoredYPosition) + + let maxHeight = UIScreen.main.bounds.height - longFormYPosition + + backgroundView.dimState = .percent(1 - presentedView.frame.origin.y / maxHeight) + } + + /** + Finds the nearest value to a given number out of a given array of float values + + - Parameters: + - number: reference float we are trying to find the closest value to + - values: array of floats we would like to compare against + */ + func nearest(to number: CGFloat, inValues values: [CGFloat]) -> CGFloat { + guard let nearestVal = values.min(by: { abs(number - $0) < abs(number - $1) }) + else { return number } + return nearestVal + } +} + +// MARK: - UIScrollView Observer + +private extension PanModalPresentationController { + + /** + Creates & stores an observer on the given scroll view's content offset. + This allows us to track scrolling without overriding the scrollView delegate + */ + func observe(scrollView: UIScrollView?) { + scrollObserver?.invalidate() + scrollObserver = scrollView?.observe(\.contentOffset, options: .old) { [weak self] scrollView, change in + + /** + Incase we have a situation where we have two containerViews in the same presentation + */ + guard self?.containerView != nil + else { return } + + self?.didPanOnScrollView(scrollView, change: change) + } + } + + /** + Scroll view content offset change event handler + + Also when scrollView is scrolled to the top, we disable the scroll indicator + otherwise glitchy behaviour occurs + + This is also shown in Apple Maps (reverse engineering) + which allows us to seamlessly transition scrolling from the panContainerView to the scrollView + */ + func didPanOnScrollView(_ scrollView: UIScrollView, change: NSKeyValueObservedChange) { + + guard + !presentedViewController.isBeingDismissed, + !presentedViewController.isBeingPresented + else { return } + + if !isPresentedViewAnchored && scrollView.contentOffset.y > 0 { + + /** + Hold the scrollView in place if we're actively scrolling and not handling top bounce + */ + haltScrolling(scrollView) + + } else if scrollView.isScrolling || isPresentedViewAnimating { + + if isPresentedViewAnchored { + /** + While we're scrolling upwards on the scrollView, + store the last content offset position + */ + trackScrolling(scrollView) + } else { + /** + Keep scroll view in place while we're panning on main view + */ + haltScrolling(scrollView) + } + + } else if presentedViewController.view.isKind(of: UIScrollView.self) + && !isPresentedViewAnimating && scrollView.contentOffset.y <= 0 { + + /** + In the case where we drag down quickly on the scroll view and let go, + `handleScrollViewTopBounce` adds a nice elegant touch. + */ + handleScrollViewTopBounce(scrollView: scrollView, change: change) + } else { + trackScrolling(scrollView) + } + } + + /** + Halts the scroll of a given scroll view & anchors it at the `scrollViewYOffset` + */ + func haltScrolling(_ scrollView: UIScrollView) { + scrollView.setContentOffset(CGPoint(x: 0, y: scrollViewYOffset), animated: false) + scrollView.showsVerticalScrollIndicator = false + } + + /** + As the user scrolls, track & save the scroll view y offset. + This helps halt scrolling when we want to hold the scroll view in place. + */ + func trackScrolling(_ scrollView: UIScrollView) { + scrollViewYOffset = max(scrollView.contentOffset.y, 0) + scrollView.showsVerticalScrollIndicator = true + } + + /** + To ensure that the scroll transition between the scrollView & the modal + is completely seamless, we need to handle the case where content offset is negative. + + In this case, we follow the curve of the decelerating scroll view. + This gives the effect that the modal view and the scroll view are one view entirely. + + - Note: This works best where the view behind view controller is a UIScrollView. + So, for example, a UITableViewController. + */ + func handleScrollViewTopBounce(scrollView: UIScrollView, change: NSKeyValueObservedChange) { + + guard let oldYValue = change.oldValue?.y, scrollView.isDecelerating + else { return } + + let yOffset = scrollView.contentOffset.y + let presentedSize = containerView?.frame.size ?? .zero + + /** + Decrease the view bounds by the y offset so the scroll view stays in place + and we can still get updates on its content offset + */ + presentedView.bounds.size = CGSize(width: presentedSize.width, height: presentedSize.height + yOffset) + + if oldYValue > yOffset { + /** + Move the view in the opposite direction to the decreasing bounds + until half way through the deceleration so that it appears + as if we're transferring the scrollView drag momentum to the entire view + */ + presentedView.frame.origin.y = longFormYPosition - yOffset + } else { + scrollViewYOffset = 0 + snap(toYPosition: longFormYPosition) + } + + scrollView.showsVerticalScrollIndicator = false + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension PanModalPresentationController: UIGestureRecognizerDelegate { + + /** + Do not require any other gesture recognizers to fail + */ + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + /** + Allow simultaneous gesture recognizers only when the other gesture recognizer's view + is the pan scrollable view + */ + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return otherGestureRecognizer.view == presentable?.panScrollable + } +} + +// MARK: - UIBezierPath + +private extension PanModalPresentationController { + + /** + Draws top rounded corners on a given view + We have to set a custom path for corner rounding + because we render the dragIndicator outside of view bounds + */ + func addRoundedCorners(to view: UIView) { + let radius = presentable?.cornerRadius ?? 0 + let path = UIBezierPath(roundedRect: view.bounds, + byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize(width: radius, height: radius)) + + // Set path as a mask to display optional drag indicator view & rounded corners + let mask = CAShapeLayer() + mask.path = path.cgPath + view.layer.mask = mask + + // Improve performance by rasterizing the layer + view.layer.shouldRasterize = true + view.layer.rasterizationScale = UIScreen.main.scale + } + + /** + Draws a path around the drag indicator view + */ + func drawAroundDragIndicator(currentPath path: UIBezierPath, indicatorLeftEdgeXPos: CGFloat) { + + let totalIndicatorOffset = Constants.indicatorYOffset + Constants.dragIndicatorSize.height + + // Draw around drag indicator starting from the left + path.addLine(to: CGPoint(x: indicatorLeftEdgeXPos, y: path.currentPoint.y)) + path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y - totalIndicatorOffset)) + path.addLine(to: CGPoint(x: path.currentPoint.x + Constants.dragIndicatorSize.width, y: path.currentPoint.y)) + path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y + totalIndicatorOffset)) + } +} + +// MARK: - Helper Extensions + +private extension UIScrollView { + + /** + A flag to determine if a scroll view is scrolling + */ + var isScrolling: Bool { + return isDragging && !isDecelerating || isTracking + } +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Delegate/PanModalPresentationDelegate.swift b/TIBottomSheet/Sources/PanModal/Delegate/PanModalPresentationDelegate.swift new file mode 100644 index 00000000..24264b74 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Delegate/PanModalPresentationDelegate.swift @@ -0,0 +1,81 @@ +// +// PanModalPresentationDelegate.swift +// PanModal +// +// Copyright © 2019 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + The PanModalPresentationDelegate conforms to the various transition delegates + and vends the appropriate object for each transition controller requested. + + Usage: + ``` + viewController.modalPresentationStyle = .custom + viewController.transitioningDelegate = PanModalPresentationDelegate.default + ``` + */ +public class PanModalPresentationDelegate: NSObject { + + /** + Returns an instance of the delegate, retained for the duration of presentation + */ + public static var `default`: PanModalPresentationDelegate = { + return PanModalPresentationDelegate() + }() + +} + +extension PanModalPresentationDelegate: UIViewControllerTransitioningDelegate { + + /** + Returns a modal presentation animator configured for the presenting state + */ + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return PanModalPresentationAnimator(transitionStyle: .presentation) + } + + /** + Returns a modal presentation animator configured for the dismissing state + */ + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return PanModalPresentationAnimator(transitionStyle: .dismissal) + } + + /** + Returns a modal presentation controller to coordinate the transition from the presenting + view controller to the presented view controller. + + Changes in size class during presentation are handled via the adaptive presentation delegate + */ + public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + let controller = PanModalPresentationController(presentedViewController: presented, presenting: presenting) + controller.delegate = self + return controller + } + +} + +extension PanModalPresentationDelegate: UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate { + + /** + - Note: We do not adapt to size classes due to the introduction of the UIPresentationController + & deprecation of UIPopoverController (iOS 9), there is no way to have more than one + presentation controller in use during the same presentation + + This is essential when transitioning from .popover to .custom on iPad split view... unless a custom popover view is also implemented + (popover uses UIPopoverPresentationController & we use PanModalPresentationController) + */ + + /** + Dismisses the presented view controller + */ + public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/LICENSE b/TIBottomSheet/Sources/PanModal/LICENSE new file mode 100644 index 00000000..02bc46f1 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/LICENSE @@ -0,0 +1,19 @@ +Copyright © 2018 Tiny Speck, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/TIBottomSheet/Sources/PanModal/PanModal.h b/TIBottomSheet/Sources/PanModal/PanModal.h new file mode 100644 index 00000000..01451f52 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/PanModal.h @@ -0,0 +1,19 @@ +// +// PanModal.h +// PanModal +// +// Created by Tosin A on 3/13/19. +// Copyright © 2019 Detail. All rights reserved. +// + +#import + +//! Project version number for PanModal. +FOUNDATION_EXPORT double PanModalVersionNumber; + +//! Project version string for PanModal. +FOUNDATION_EXPORT const unsigned char PanModalVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/TIBottomSheet/Sources/PanModal/Presentable/PanModalHeight.swift b/TIBottomSheet/Sources/PanModal/Presentable/PanModalHeight.swift new file mode 100644 index 00000000..00654f4e --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presentable/PanModalHeight.swift @@ -0,0 +1,44 @@ +// +// PanModalHeight.swift +// PanModal +// +// Copyright © 2019 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + An enum that defines the possible states of the height of a pan modal container view + for a given presentation state (shortForm, longForm) + */ +public enum PanModalHeight: Equatable { + + /** + Sets the height to be the maximum height (+ topOffset) + */ + case maxHeight + + /** + Sets the height to be the max height with a specified top inset. + - Note: A value of 0 is equivalent to .maxHeight + */ + case maxHeightWithTopInset(CGFloat) + + /** + Sets the height to be the specified content height + */ + case contentHeight(CGFloat) + + /** + Sets the height to be the specified content height + & also ignores the bottomSafeAreaInset + */ + case contentHeightIgnoringSafeArea(CGFloat) + + /** + Sets the height to be the intrinsic content height + */ + case intrinsicHeight +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+Defaults.swift b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+Defaults.swift new file mode 100644 index 00000000..fc9edfa1 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+Defaults.swift @@ -0,0 +1,128 @@ +// +// PanModalPresentable+Defaults.swift +// PanModal +// +// Copyright © 2018 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + Default values for the PanModalPresentable. + */ +public extension PanModalPresentable where Self: UIViewController { + + var topOffset: CGFloat { + topLayoutOffset + } + + var shortFormHeight: PanModalHeight { + return longFormHeight + } + + var mediumFormHeight: PanModalHeight { + longFormHeight + } + + var longFormHeight: PanModalHeight { + + guard let scrollView = panScrollable + else { return .maxHeight } + + // called once during presentation and stored + scrollView.layoutIfNeeded() + return .contentHeight(scrollView.contentSize.height) + } + + var presentationDetents: [ModalViewPresentationDetent] { + [] + } + + var cornerRadius: CGFloat { + return 8.0 + } + + var springDamping: CGFloat { + return 0.8 + } + + var transitionDuration: Double { + return PanModalAnimator.Constants.defaultTransitionDuration + } + + var transitionAnimationOptions: UIView.AnimationOptions { + return [.curveEaseInOut, .allowUserInteraction, .beginFromCurrentState] + } + + var panModalBackgroundColor: UIColor { + return UIColor.black.withAlphaComponent(0.7) + } + + var scrollIndicatorInsets: UIEdgeInsets { + let top = shouldRoundTopCorners ? cornerRadius : 0 + return UIEdgeInsets(top: CGFloat(top), left: 0, bottom: bottomLayoutOffset, right: 0) + } + + var anchorModalToLongForm: Bool { + return true + } + + var allowsExtendedPanScrolling: Bool { + + guard let scrollView = panScrollable + else { return false } + + scrollView.layoutIfNeeded() + return scrollView.contentSize.height > (scrollView.frame.height - bottomLayoutOffset) + } + + var allowsDragToDismiss: Bool { + return true + } + + var allowsTapToDismiss: Bool { + return true + } + + var isUserInteractionEnabled: Bool { + return true + } + + var isHapticFeedbackEnabled: Bool { + return true + } + + var shouldRoundTopCorners: Bool { + return isPanModalPresented + } + + func shouldRespond(to panModalGestureRecognizer: UIPanGestureRecognizer) -> Bool { + return true + } + + func willRespond(to panModalGestureRecognizer: UIPanGestureRecognizer) { + + } + + func shouldTransition(to state: PanModalPresentationController.PresentationState) -> Bool { + return true + } + + func shouldPrioritize(panModalGestureRecognizer: UIPanGestureRecognizer) -> Bool { + return false + } + + func willTransition(to state: PanModalPresentationController.PresentationState) { + + } + + func panModalWillDismiss() { + + } + + func panModalDidDismiss() { + + } +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift new file mode 100644 index 00000000..0e93abe6 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift @@ -0,0 +1,126 @@ +// +// PanModalPresentable+LayoutHelpers.swift +// PanModal +// +// Copyright © 2018 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + ⚠️ [Internal Only] ⚠️ + Helper extensions that handle layout in the PanModalPresentationController + */ +extension PanModalPresentable where Self: UIViewController { + + /** + Cast the presentation controller to PanModalPresentationController + so we can access PanModalPresentationController properties and methods + */ + var presentedVC: PanModalPresentationController? { + return presentationController as? PanModalPresentationController + } + + /** + Length of the top layout guide of the presenting view controller. + Gives us the safe area inset from the top. + */ + var topLayoutOffset: CGFloat { + + guard let rootVC = rootViewController + else { return 0} + + if #available(iOS 11.0, *) { return rootVC.view.safeAreaInsets.top } else { return rootVC.topLayoutGuide.length } + } + + /** + Length of the bottom layout guide of the presenting view controller. + Gives us the safe area inset from the bottom. + */ + var bottomLayoutOffset: CGFloat { + + guard let rootVC = rootViewController + else { return 0} + + if #available(iOS 11.0, *) { return rootVC.view.safeAreaInsets.bottom } else { return rootVC.bottomLayoutGuide.length } + } + + /** + Returns the short form Y position + + - Note: If voiceover is on, the `longFormYPos` is returned. + We do not support short form when voiceover is on as it would make it difficult for user to navigate. + */ + var shortFormYPos: CGFloat { + + guard !UIAccessibility.isVoiceOverRunning + else { return longFormYPos } + + let shortFormYPos = topMargin(from: shortFormHeight) + topOffset + + // shortForm shouldn't exceed longForm + return max(shortFormYPos, longFormYPos) + } + + var mediumFormYPos: CGFloat { + let mediumFormYPos = topMargin(from: mediumFormHeight) + + return max(mediumFormYPos, longFormYPos) + } + + /** + Returns the long form Y position + + - Note: We cap this value to the max possible height + to ensure content is not rendered outside of the view bounds + */ + var longFormYPos: CGFloat { + return max(topMargin(from: longFormHeight), topMargin(from: .maxHeight)) + topOffset + } + + /** + Use the container view for relative positioning as this view's frame + is adjusted in PanModalPresentationController + */ + var bottomYPos: CGFloat { + + guard let container = presentedVC?.containerView + else { return view.bounds.height } + + return container.bounds.size.height - topOffset + } + + /** + Converts a given pan modal height value into a y position value + calculated from top of view + */ + func topMargin(from: PanModalHeight) -> CGFloat { + switch from { + case .maxHeight: + return 0.0 + case .maxHeightWithTopInset(let inset): + return inset + case .contentHeight(let height): + return bottomYPos - (height + bottomLayoutOffset) + case .contentHeightIgnoringSafeArea(let height): + return bottomYPos - height + case .intrinsicHeight: + view.layoutIfNeeded() + let targetSize = CGSize(width: (presentedVC?.containerView?.bounds ?? UIScreen.main.bounds).width, + height: UIView.layoutFittingCompressedSize.height) + let intrinsicHeight = view.systemLayoutSizeFitting(targetSize).height + return bottomYPos - (intrinsicHeight + bottomLayoutOffset) + } + } + + private var rootViewController: UIViewController? { + + guard let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication + else { return nil } + + return application.keyWindow?.rootViewController + } + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+UIViewController.swift b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+UIViewController.swift new file mode 100644 index 00000000..8de82fd5 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable+UIViewController.swift @@ -0,0 +1,63 @@ +// +// PanModalPresentable+UIViewController.swift +// PanModal +// +// Copyright © 2018 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + Extends PanModalPresentable with helper methods + when the conforming object is a UIViewController + */ +public extension PanModalPresentable where Self: UIViewController { + + typealias AnimationBlockType = () -> Void + typealias AnimationCompletionType = (Bool) -> Void + + /** + For Presentation, the object must be a UIViewController & confrom to the PanModalPresentable protocol. + */ + typealias LayoutType = UIViewController & PanModalPresentable + + /** + A function wrapper over the `transition(to state: PanModalPresentationController.PresentationState)` + function in the PanModalPresentationController. + */ + func panModalTransition(to state: PanModalPresentationController.PresentationState) { + presentedVC?.transition(to: state) + } + + /** + A function wrapper over the `setNeedsLayoutUpdate()` + function in the PanModalPresentationController. + + - Note: This should be called whenever any of the values for the PanModalPresentable protocol are changed. + */ + func panModalSetNeedsLayoutUpdate() { + presentedVC?.setNeedsLayoutUpdate() + } + + /** + Operations on the scroll view, such as content height changes, or when inserting/deleting rows can cause the pan modal to jump, + caused by the pan modal responding to content offset changes. + + To avoid this, you can call this method to perform scroll view updates, with scroll observation temporarily disabled. + */ + func panModalPerformUpdates(_ updates: () -> Void) { + presentedVC?.performUpdates(updates) + } + + /** + A function wrapper over the animate function in PanModalAnimator. + + This can be used for animation consistency on views within the presented view controller. + */ + func panModalAnimate(_ animationBlock: @escaping AnimationBlockType, _ completion: AnimationCompletionType? = nil) { + PanModalAnimator.animate(animationBlock, config: self, completion) + } + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable.swift b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable.swift new file mode 100644 index 00000000..aa008aed --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presentable/PanModalPresentable.swift @@ -0,0 +1,231 @@ +// +// PanModalPresentable.swift +// PanModal +// +// Copyright © 2017 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import TISwiftUtils +import UIKit + +/** + This is the configuration object for a view controller + that will be presented using the PanModal transition. + + Usage: + ``` + extension YourViewController: PanModalPresentable { + func shouldRoundTopCorners: Bool { return false } + } + ``` + */ +public protocol PanModalPresentable: AnyObject { + + /** + The scroll view embedded in the view controller. + Setting this value allows for seamless transition scrolling between the embedded scroll view + and the pan modal container view. + */ + var panScrollable: UIScrollView? { get } + + /** + The offset between the top of the screen and the top of the pan modal container view. + + Default value is the topLayoutGuide.length + 21.0. + */ + var topOffset: CGFloat { get } + + /** + The height of the pan modal container view + when in the shortForm presentation state. + + This value is capped to .max, if provided value exceeds the space available. + + Default value is the longFormHeight. + */ + var shortFormHeight: PanModalHeight { get } + + var mediumFormHeight: PanModalHeight { get } + + /** + The height of the pan modal container view + when in the longForm presentation state. + + This value is capped to .max, if provided value exceeds the space available. + + Default value is .max. + */ + var longFormHeight: PanModalHeight { get } + + var presentationDetents: [ModalViewPresentationDetent] { get } + + var dimmedViewType: DimmedView.AppearanceType { get } + /** + The corner radius used when `shouldRoundTopCorners` is enabled. + + Default Value is 8.0. + */ + var cornerRadius: CGFloat { get } + + /** + The springDamping value used to determine the amount of 'bounce' + seen when transitioning to short/long form. + + Default Value is 0.8. + */ + var springDamping: CGFloat { get } + + /** + The transitionDuration value is used to set the speed of animation during a transition, + including initial presentation. + + Default value is 0.5. + */ + var transitionDuration: Double { get } + + /** + The animation options used when performing animations on the PanModal, utilized mostly + during a transition. + + Default value is [.curveEaseInOut, .allowUserInteraction, .beginFromCurrentState]. + */ + var transitionAnimationOptions: UIView.AnimationOptions { get } + + /** + The background view color. + + - Note: This is only utilized at the very start of the transition. + + Default Value is black with alpha component 0.7. + */ + var panModalBackgroundColor: UIColor { get } + + /** + We configure the panScrollable's scrollIndicatorInsets interally so override this value + to set custom insets. + + - Note: Use `panModalSetNeedsLayoutUpdate()` when updating insets. + */ + var scrollIndicatorInsets: UIEdgeInsets { get } + + /** + A flag to determine if scrolling should be limited to the longFormHeight. + Return false to cap scrolling at .max height. + + Default value is true. + */ + var anchorModalToLongForm: Bool { get } + + /** + A flag to determine if scrolling should seamlessly transition from the pan modal container view to + the embedded scroll view once the scroll limit has been reached. + + Default value is false. Unless a scrollView is provided and the content height exceeds the longForm height. + */ + var allowsExtendedPanScrolling: Bool { get } + + /** + A flag to determine if dismissal should be initiated when swiping down on the presented view. + + Return false to fallback to the short form state instead of dismissing. + + Default value is true. + */ + var allowsDragToDismiss: Bool { get } + var onTapToDismiss: VoidClosure? { get } + var onDragToDismiss: VoidClosure? { get } + + /** + A flag to determine if dismissal should be initiated when tapping on the dimmed background view. + + Default value is true. + */ + var allowsTapToDismiss: Bool { get } + + /** + A flag to toggle user interactions on the container view. + + - Note: Return false to forward touches to the presentingViewController. + + Default is true. + */ + var isUserInteractionEnabled: Bool { get } + + /** + A flag to determine if haptic feedback should be enabled during presentation. + + Default value is true. + */ + var isHapticFeedbackEnabled: Bool { get } + + /** + A flag to determine if the top corners should be rounded. + + Default value is true. + */ + var shouldRoundTopCorners: Bool { get } + + + /** + Asks the delegate if the pan modal should respond to the pan modal gesture recognizer. + + Return false to disable movement on the pan modal but maintain gestures on the presented view. + + Default value is true. + */ + func shouldRespond(to panModalGestureRecognizer: UIPanGestureRecognizer) -> Bool + + /** + Notifies the delegate when the pan modal gesture recognizer state is either + `began` or `changed`. This method gives the delegate a chance to prepare + for the gesture recognizer state change. + + For example, when the pan modal view is about to scroll. + + Default value is an empty implementation. + */ + func willRespond(to panModalGestureRecognizer: UIPanGestureRecognizer) + + /** + Asks the delegate if the pan modal gesture recognizer should be prioritized. + + For example, you can use this to define a region + where you would like to restrict where the pan gesture can start. + + If false, then we rely solely on the internal conditions of when a pan gesture + should succeed or fail, such as, if we're actively scrolling on the scrollView. + + Default return value is false. + */ + func shouldPrioritize(panModalGestureRecognizer: UIPanGestureRecognizer) -> Bool + + /** + Asks the delegate if the pan modal should transition to a new state. + + Default value is true. + */ + func shouldTransition(to state: PanModalPresentationController.PresentationState) -> Bool + + /** + Notifies the delegate that the pan modal is about to transition to a new state. + + Default value is an empty implementation. + */ + func willTransition(to state: PanModalPresentationController.PresentationState) + + /** + Notifies the delegate that the pan modal is about to be dismissed. + + Default value is an empty implementation. + */ + func panModalWillDismiss() + + /** + Notifies the delegate after the pan modal is dismissed. + + Default value is an empty implementation. + */ + func panModalDidDismiss() +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presenter/PanModalPresenter.swift b/TIBottomSheet/Sources/PanModal/Presenter/PanModalPresenter.swift new file mode 100644 index 00000000..8763fdac --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presenter/PanModalPresenter.swift @@ -0,0 +1,38 @@ +// +// PanModalPresenter.swift +// PanModal +// +// Copyright © 2019 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + A protocol for objects that will present a view controller as a PanModal + + - Usage: + ``` + viewController.presentPanModal(viewControllerToPresent: presentingVC, + sourceView: presentingVC.view, + sourceRect: .zero) + ``` + */ +protocol PanModalPresenter: AnyObject { + + /** + A flag that returns true if the current presented view controller + is using the PanModalPresentationDelegate + */ + var isPanModalPresented: Bool { get } + + /** + Presents a view controller that conforms to the PanModalPresentable protocol + */ + func presentPanModal(_ viewControllerToPresent: PanModalPresentable.LayoutType, + sourceView: UIView?, + sourceRect: CGRect, + completion: (() -> Void)?) + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/Presenter/UIViewController+PanModalPresenter.swift b/TIBottomSheet/Sources/PanModal/Presenter/UIViewController+PanModalPresenter.swift new file mode 100644 index 00000000..b252d8f7 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/Presenter/UIViewController+PanModalPresenter.swift @@ -0,0 +1,66 @@ +// +// UIViewController+PanModalPresenterProtocol.swift +// PanModal +// +// Copyright © 2019 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + Extends the UIViewController to conform to the PanModalPresenter protocol + */ +extension UIViewController: PanModalPresenter { + + /** + A flag that returns true if the topmost view controller in the navigation stack + was presented using the custom PanModal transition + + - Warning: ⚠️ Calling `presentationController` in this function may cause a memory leak. ⚠️ + + In most cases, this check will be used early in the view lifecycle and unfortunately, + there's an Apple bug that causes multiple presentationControllers to be created if + the presentationController is referenced here and called too early resulting in + a strong reference to this view controller and in turn, creating a memory leak. + */ + public var isPanModalPresented: Bool { + return (transitioningDelegate as? PanModalPresentationDelegate) != nil + } + + /** + Configures a view controller for presentation using the PanModal transition + + - Parameters: + - viewControllerToPresent: The view controller to be presented + - sourceView: The view containing the anchor rectangle for the popover. + - sourceRect: The rectangle in the specified view in which to anchor the popover. + - completion: The block to execute after the presentation finishes. You may specify nil for this parameter. + + - Note: sourceView & sourceRect are only required for presentation on an iPad. + */ + public func presentPanModal(_ viewControllerToPresent: PanModalPresentable.LayoutType, + sourceView: UIView? = nil, + sourceRect: CGRect = .zero, + completion: (() -> Void)? = nil) { + + /** + Here, we deliberately do not check for size classes. More info in `PanModalPresentationDelegate` + */ + + if UIDevice.current.userInterfaceIdiom == .pad { + viewControllerToPresent.modalPresentationStyle = .popover + viewControllerToPresent.popoverPresentationController?.sourceRect = sourceRect + viewControllerToPresent.popoverPresentationController?.sourceView = sourceView ?? view + viewControllerToPresent.popoverPresentationController?.delegate = PanModalPresentationDelegate.default + } else { + viewControllerToPresent.modalPresentationStyle = .custom + viewControllerToPresent.modalPresentationCapturesStatusBarAppearance = true + viewControllerToPresent.transitioningDelegate = PanModalPresentationDelegate.default + } + + present(viewControllerToPresent, animated: true, completion: completion) + } + +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/View/DimmedView.swift b/TIBottomSheet/Sources/PanModal/View/DimmedView.swift new file mode 100644 index 00000000..8ca838d2 --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/View/DimmedView.swift @@ -0,0 +1,131 @@ +// +// DimmedView.swift +// PanModal +// +// Copyright © 2017 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import TISwiftUtils +import TIUIKitCore +import TIUIElements +import UIKit + +/** + A dim view for use as an overlay over content you want dimmed. + */ +public class DimmedView: BaseInitializableView { + + public enum AppearanceType { + + case transparent + case transparentWithShadow(UIViewShadow) + case opaque + + var isTransparent: Bool { + switch self { + case .transparent, .transparentWithShadow: + return true + + default: + return false + } + } + } + + /** + Represents the possible states of the dimmed view. + max, off or a percentage of dimAlpha. + */ + enum DimState { + case max + case off + case percent(CGFloat) + } + + // MARK: - Properties + + /** + The state of the dimmed view + */ + var dimState: DimState = .off { + didSet { + guard !appearanceType.isTransparent else { + alpha = .zero + return + } + + switch dimState { + case .max: + alpha = 1.0 + case .off: + alpha = 0.0 + case .percent(let percentage): + alpha = max(0.0, min(1.0, percentage)) + } + } + } + + weak var presentingController: UIViewController? + var appearanceType: AppearanceType + + /** + The closure to be executed when a tap occurs + */ + var didTap: VoidClosure? + + // MARK: - Initializers + + init(presentingController: UIViewController? = nil, + dimColor: UIColor = UIColor.black.withAlphaComponent(0.7), + appearanceType: AppearanceType) { + + self.presentingController = presentingController + self.appearanceType = appearanceType + + super.init(frame: .zero) + + alpha = 0.0 + backgroundColor = dimColor + } + + required public init?(coder aDecoder: NSCoder) { + fatalError() + } + + // MARK: - BaseInitializableView + + public override func bindViews() { + super.bindViews() + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + addGestureRecognizer(tapGesture) + } + + // MARK: - Overrides + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if appearanceType.isTransparent { + let subviews = presentingController?.view.subviews.reversed() ?? [] + + for subview in subviews { + if let hittedView = subview.hitTest(point, with: event) { + return hittedView + } + } + } + + return super.hitTest(point, with: event) + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + !appearanceType.isTransparent + } + + // MARK: - Event Handlers + + @objc private func didTapView() { + didTap?() + } +} +#endif diff --git a/TIBottomSheet/Sources/PanModal/View/PanContainerView.swift b/TIBottomSheet/Sources/PanModal/View/PanContainerView.swift new file mode 100644 index 00000000..f5c2892b --- /dev/null +++ b/TIBottomSheet/Sources/PanModal/View/PanContainerView.swift @@ -0,0 +1,44 @@ +// +// PanContainerView.swift +// PanModal +// +// Copyright © 2018 Tiny Speck, Inc. All rights reserved. +// + +#if os(iOS) +import UIKit + +/** + A view wrapper around the presented view in a PanModal transition. + + This allows us to make modifications to the presented view without + having to do those changes directly on the view + */ +class PanContainerView: UIView { + + init(presentedView: UIView, frame: CGRect) { + super.init(frame: frame) + addSubview(presentedView) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension UIView { + + /** + Convenience property for retrieving a PanContainerView instance + from the view hierachy + */ + var panContainerView: PanContainerView? { + return subviews.first(where: { view -> Bool in + view is PanContainerView + }) as? PanContainerView + } + +} +#endif diff --git a/TIUIElements/Sources/Appearance/UIView+Layout.swift b/TIUIElements/Sources/Appearance/UIView+Layout.swift new file mode 100644 index 00000000..e3ae3027 --- /dev/null +++ b/TIUIElements/Sources/Appearance/UIView+Layout.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Nikita Semenov on 08.06.2023. +// + +import Foundation diff --git a/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift b/TIUIElements/Sources/Views/Placeholder/Styles/BaseButtonStyle.swift similarity index 98% rename from TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift rename to TIUIElements/Sources/Views/Placeholder/Styles/BaseButtonStyle.swift index ea9e1fdb..7d9b3cc4 100644 --- a/TIUIElements/Sources/Views/Placeholder/Styles/PlaceholderButtonStyle.swift +++ b/TIUIElements/Sources/Views/Placeholder/Styles/BaseButtonStyle.swift @@ -24,7 +24,7 @@ import TIUIKitCore import UIKit -open class PlaceholderButtonStyle { +open class BaseButtonStyle { public var titles: UIControl.StateTitles public var images: UIControl.StateImages diff --git a/TIUIElements/Sources/Views/StatefulButton/StatefulButton+ApplyStyle.swift b/TIUIElements/Sources/Views/StatefulButton/StatefulButton+ApplyStyle.swift new file mode 100644 index 00000000..e3ae3027 --- /dev/null +++ b/TIUIElements/Sources/Views/StatefulButton/StatefulButton+ApplyStyle.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Nikita Semenov on 08.06.2023. +// + +import Foundation