From d28aab13a669b9cba2b09eea65d08b54df60ffc3 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Sun, 9 Jul 2023 10:57:15 +0300 Subject: [PATCH] feat: detents api --- .../contents.xcworkspacedata | 7 ++ .../PanModalPresentationController.swift | 104 ++++++++---------- .../ModalViewPresentationDetent.swift | 66 +++++++++++ .../PanModalPresentable+Defaults.swift | 34 ++++-- .../PanModalPresentable+LayoutHelpers.swift | 6 + .../Presentable/PanModalPresentable.swift | 21 ++-- PanModal/View/DimmedView.swift | 69 ++++++++++-- PanModal/View/ShadowConfigurator.swift | 31 ++++++ PanModal/View/UIView+ShadowConfigurator.swift | 33 ++++++ 9 files changed, 279 insertions(+), 92 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 PanModal/Presentable/ModalViewPresentationDetent.swift create mode 100644 PanModal/View/ShadowConfigurator.swift create mode 100644 PanModal/View/UIView+ShadowConfigurator.swift diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/PanModal/Controller/PanModalPresentationController.swift b/PanModal/Controller/PanModalPresentationController.swift index 11fb2a6..deb2a97 100644 --- a/PanModal/Controller/PanModalPresentationController.swift +++ b/PanModal/Controller/PanModalPresentationController.swift @@ -30,6 +30,7 @@ open class PanModalPresentationController: UIPresentationController { */ public enum PresentationState { case shortForm + case mediumForm case longForm } @@ -79,11 +80,17 @@ open class PanModalPresentationController: UIPresentationController { */ 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 */ @@ -106,16 +113,18 @@ open class PanModalPresentationController: UIPresentationController { */ private lazy var backgroundView: DimmedView = { let view: DimmedView + let type = presentable?.dimmedViewType ?? .opaque + if let color = presentable?.panModalBackgroundColor { - view = DimmedView(dimColor: color) + view = DimmedView(presentingController: presentingViewController, dimColor: color, appearanceType: type) } else { - view = DimmedView() + view = DimmedView(presentingController: presentingViewController, appearanceType: type) } - view.didTap = { [weak self] _ in - if self?.presentable?.allowsTapToDismiss == true { - self?.presentedViewController.dismiss(animated: true) - } + + view.didTap = { [weak self] in + self?.presentable?.onTapToDismiss?() } + return view }() @@ -129,16 +138,6 @@ open class PanModalPresentationController: UIPresentationController { return PanContainerView(presentedView: presentedViewController.view, frame: frame) }() - /** - Drag Indicator View - */ - private lazy var dragIndicatorView: UIView = { - let view = UIView() - view.backgroundColor = presentable?.dragIndicatorBackgroundColor - view.layer.cornerRadius = Constants.dragIndicatorSize.height / 2.0 - return view - }() - /** Override presented view to return the pan container wrapper */ @@ -180,6 +179,7 @@ open class PanModalPresentationController: UIPresentationController { layoutBackgroundView(in: containerView) layoutPresentedView(in: containerView) configureScrollViewInsets() + configureShadowIfNeeded() guard let coordinator = presentedViewController.transitionCoordinator else { backgroundView.dimState = .max @@ -187,7 +187,9 @@ open class PanModalPresentationController: UIPresentationController { } coordinator.animate(alongsideTransition: { [weak self] _ in - self?.backgroundView.dimState = .max + if let yPos = self?.shortFormYPosition { + self?.adjust(toYPosition: yPos) + } self?.presentedViewController.setNeedsStatusBarAppearanceUpdate() }) } @@ -211,7 +213,6 @@ open class PanModalPresentationController: UIPresentationController { so hiding it on view dismiss means avoiding visual bugs */ coordinator.animate(alongsideTransition: { [weak self] _ in - self?.dragIndicatorView.alpha = 0.0 self?.backgroundView.dimState = .off self?.presentingViewController.setNeedsStatusBarAppearanceUpdate() }) @@ -262,6 +263,10 @@ public extension PanModalPresentationController { switch state { case .shortForm: snap(toYPosition: shortFormYPosition) + + case .mediumForm: + snap(toYPosition: mediumFormYPosition) + case .longForm: snap(toYPosition: longFormYPosition) } @@ -347,10 +352,6 @@ private extension PanModalPresentationController { containerView.addSubview(presentedView) containerView.addGestureRecognizer(panGestureRecognizer) - if presentable.showDragIndicator { - addDragIndicatorView(to: presentedView) - } - if presentable.shouldRoundTopCorners { addRoundedCorners(to: presentedView) } @@ -371,7 +372,7 @@ private extension PanModalPresentationController { let panFrame = panContainerView.frame panContainerView.frame.size = frame.size - if ![shortFormYPosition, longFormYPosition].contains(panFrame.origin.y) { + 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 @@ -404,19 +405,6 @@ private extension PanModalPresentationController { backgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true } - /** - Adds the drag indicator view to the view hierarchy - & configures its layout constraints. - */ - func addDragIndicatorView(to view: UIView) { - view.addSubview(dragIndicatorView) - dragIndicatorView.translatesAutoresizingMaskIntoConstraints = false - dragIndicatorView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: -Constants.indicatorYOffset).isActive = true - dragIndicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - dragIndicatorView.widthAnchor.constraint(equalToConstant: Constants.dragIndicatorSize.width).isActive = true - dragIndicatorView.heightAnchor.constraint(equalToConstant: Constants.dragIndicatorSize.height).isActive = true - } - /** Calculates & stores the layout anchor points & options */ @@ -426,6 +414,7 @@ private extension PanModalPresentationController { else { return } shortFormYPosition = layoutPresentable.shortFormYPos + mediumFormYPosition = layoutPresentable.mediumFormYPos longFormYPosition = layoutPresentable.longFormYPos anchorModalToLongForm = layoutPresentable.anchorModalToLongForm extendsPanScrolling = layoutPresentable.allowsExtendedPanScrolling @@ -465,6 +454,13 @@ private extension PanModalPresentationController { } } + func configureShadowIfNeeded() { + let type = presentable?.dimmedViewType ?? .opaque + + if case let .transparentWithShadow(shadow) = type { + containerView?.configure(shadow: shadow) + } + } } // MARK: - Pan Gesture Event Handler @@ -518,12 +514,16 @@ private extension PanModalPresentationController { 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) || presentable?.allowsDragToDismiss == false { + && presentedView.frame.minY < shortFormYPosition) || allowsDragToDismiss == false { transition(to: .shortForm) } else { - presentedViewController.dismiss(animated: true) + presentable?.onDragToDismiss?() } } else { @@ -532,16 +532,19 @@ private extension PanModalPresentationController { 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, longFormYPosition]) + 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 || presentable?.allowsDragToDismiss == false { + } else if position == shortFormYPosition || allowsDragToDismiss == false { transition(to: .shortForm) } else { - presentedViewController.dismiss(animated: true) + presentable?.onDragToDismiss?() } } } @@ -653,19 +656,10 @@ private extension PanModalPresentationController { */ func adjust(toYPosition yPos: CGFloat) { presentedView.frame.origin.y = max(yPos, anchoredYPosition) - - guard presentedView.frame.origin.y > shortFormYPosition else { - backgroundView.dimState = .max - return - } - let yDisplacementFromShortForm = presentedView.frame.origin.y - shortFormYPosition + let maxHeight = UIScreen.main.bounds.height - longFormYPosition - /** - Once presentedView is translated below shortForm, calculate yPos relative to bottom of screen - and apply percentage to backgroundView alpha - */ - backgroundView.dimState = .percent(1.0 - (yDisplacementFromShortForm / presentedView.frame.height)) + backgroundView.dimState = .percent(1 - presentedView.frame.origin.y / maxHeight) } /** @@ -847,12 +841,6 @@ private extension PanModalPresentationController { byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: radius, height: radius)) - // Draw around the drag indicator view, if displayed - if presentable?.showDragIndicator == true { - let indicatorLeftEdgeXPos = view.bounds.width/2.0 - Constants.dragIndicatorSize.width/2.0 - drawAroundDragIndicator(currentPath: path, indicatorLeftEdgeXPos: indicatorLeftEdgeXPos) - } - // Set path as a mask to display optional drag indicator view & rounded corners let mask = CAShapeLayer() mask.path = path.cgPath diff --git a/PanModal/Presentable/ModalViewPresentationDetent.swift b/PanModal/Presentable/ModalViewPresentationDetent.swift new file mode 100644 index 0000000..c01ad4b --- /dev/null +++ b/PanModal/Presentable/ModalViewPresentationDetent.swift @@ -0,0 +1,66 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// 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. +// + +import Foundation + +public struct ModalViewPresentationDetent: Hashable { + + // MARK: - Default Values + + public static var headerOnly: ModalViewPresentationDetent { + ModalViewPresentationDetent(height: CGFloat(Int.min)) + } + + public static func height(_ height: CGFloat) -> ModalViewPresentationDetent { + ModalViewPresentationDetent(height: height) + } + + public static var maxHeight: ModalViewPresentationDetent { + ModalViewPresentationDetent(height: CGFloat(Int.max)) + } + + // MARK: - Public Properties + + public var height: CGFloat + + // MARK: - Internal Methods + + func panModalHeight(headerHeight: CGFloat = .zero) -> PanModalHeight { + if self == .headerOnly { + return .contentHeight(headerHeight) + } + + if self == .maxHeight { + return .maxHeight + } + + return .contentHeight(height) + } +} + +// MARK: - Comparable + +extension ModalViewPresentationDetent: Comparable { + public static func < (lhs: ModalViewPresentationDetent, rhs: ModalViewPresentationDetent) -> Bool { + lhs.height < rhs.height + } +} diff --git a/PanModal/Presentable/PanModalPresentable+Defaults.swift b/PanModal/Presentable/PanModalPresentable+Defaults.swift index 76a0679..dea4703 100644 --- a/PanModal/Presentable/PanModalPresentable+Defaults.swift +++ b/PanModal/Presentable/PanModalPresentable+Defaults.swift @@ -13,14 +13,30 @@ import UIKit */ public extension PanModalPresentable where Self: UIViewController { + var onTapToDismiss: (() -> Void)? { + { [weak self] in + self?.dismiss(animated: true) + } + } + + var onDragToDismiss: (() -> Void)? { + { [weak self] in + self?.dismiss(animated: true) + } + } + var topOffset: CGFloat { - return topLayoutOffset + 21.0 + topLayoutOffset } var shortFormHeight: PanModalHeight { return longFormHeight } + var mediumFormHeight: PanModalHeight { + longFormHeight + } + var longFormHeight: PanModalHeight { guard let scrollView = panScrollable @@ -31,6 +47,14 @@ public extension PanModalPresentable where Self: UIViewController { return .contentHeight(scrollView.contentSize.height) } + var dimmedViewType: DimmedView.AppearanceType { + .opaque + } + + var presentationDetents: [ModalViewPresentationDetent] { + [] + } + var cornerRadius: CGFloat { return 8.0 } @@ -51,10 +75,6 @@ public extension PanModalPresentable where Self: UIViewController { return UIColor.black.withAlphaComponent(0.7) } - var dragIndicatorBackgroundColor: UIColor { - return UIColor.lightGray - } - var scrollIndicatorInsets: UIEdgeInsets { let top = shouldRoundTopCorners ? cornerRadius : 0 return UIEdgeInsets(top: CGFloat(top), left: 0, bottom: bottomLayoutOffset, right: 0) @@ -93,10 +113,6 @@ public extension PanModalPresentable where Self: UIViewController { return isPanModalPresented } - var showDragIndicator: Bool { - return shouldRoundTopCorners - } - func shouldRespond(to panModalGestureRecognizer: UIPanGestureRecognizer) -> Bool { return true } diff --git a/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift b/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift index ff4805c..0e93abe 100644 --- a/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift +++ b/PanModal/Presentable/PanModalPresentable+LayoutHelpers.swift @@ -63,6 +63,12 @@ extension PanModalPresentable where Self: UIViewController { return max(shortFormYPos, longFormYPos) } + var mediumFormYPos: CGFloat { + let mediumFormYPos = topMargin(from: mediumFormHeight) + + return max(mediumFormYPos, longFormYPos) + } + /** Returns the long form Y position diff --git a/PanModal/Presentable/PanModalPresentable.swift b/PanModal/Presentable/PanModalPresentable.swift index 76c1501..780be91 100644 --- a/PanModal/Presentable/PanModalPresentable.swift +++ b/PanModal/Presentable/PanModalPresentable.swift @@ -45,6 +45,8 @@ public protocol PanModalPresentable: AnyObject { */ var shortFormHeight: PanModalHeight { get } + var mediumFormHeight: PanModalHeight { get } + /** The height of the pan modal container view when in the longForm presentation state. @@ -55,6 +57,9 @@ public protocol PanModalPresentable: AnyObject { */ var longFormHeight: PanModalHeight { get } + var presentationDetents: [ModalViewPresentationDetent] { get } + + var dimmedViewType: DimmedView.AppearanceType { get } /** The corner radius used when `shouldRoundTopCorners` is enabled. @@ -95,13 +100,6 @@ public protocol PanModalPresentable: AnyObject { */ var panModalBackgroundColor: UIColor { get } - /** - The drag indicator view color. - - Default value is light gray. - */ - var dragIndicatorBackgroundColor: UIColor { get } - /** We configure the panScrollable's scrollIndicatorInsets interally so override this value to set custom insets. @@ -134,6 +132,8 @@ public protocol PanModalPresentable: AnyObject { Default value is true. */ var allowsDragToDismiss: Bool { get } + var onTapToDismiss: (() -> Void)? { get } + var onDragToDismiss: (() -> Void)? { get } /** A flag to determine if dismissal should be initiated when tapping on the dimmed background view. @@ -165,13 +165,6 @@ public protocol PanModalPresentable: AnyObject { */ var shouldRoundTopCorners: Bool { get } - /** - A flag to determine if a drag indicator should be shown - above the pan modal container view. - - Default value is true. - */ - var showDragIndicator: Bool { get } /** Asks the delegate if the pan modal should respond to the pan modal gesture recognizer. diff --git a/PanModal/View/DimmedView.swift b/PanModal/View/DimmedView.swift index 6fdd1d8..b3a154d 100644 --- a/PanModal/View/DimmedView.swift +++ b/PanModal/View/DimmedView.swift @@ -13,6 +13,23 @@ import UIKit */ public class DimmedView: UIView { + public enum AppearanceType { + + case transparent + case transparentWithShadow(ShadowConfigurator) + 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. @@ -30,6 +47,11 @@ public class DimmedView: UIView { */ var dimState: DimState = .off { didSet { + guard !appearanceType.isTransparent else { + alpha = .zero + return + } + switch dimState { case .max: alpha = 1.0 @@ -41,36 +63,61 @@ public class DimmedView: UIView { } } + weak var presentingController: UIViewController? + var appearanceType: AppearanceType + /** The closure to be executed when a tap occurs */ - var didTap: ((_ recognizer: UIGestureRecognizer) -> Void)? - - /** - Tap gesture recognizer - */ - private lazy var tapGesture: UIGestureRecognizer = { - return UITapGestureRecognizer(target: self, action: #selector(didTapView)) - }() + var didTap: (() -> Void)? // MARK: - Initializers - init(dimColor: UIColor = UIColor.black.withAlphaComponent(0.7)) { + 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 + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapView)) addGestureRecognizer(tapGesture) } + @available(*, unavailable) required public init?(coder aDecoder: NSCoder) { fatalError() } + // 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?(tapGesture) + didTap?() } - } #endif diff --git a/PanModal/View/ShadowConfigurator.swift b/PanModal/View/ShadowConfigurator.swift new file mode 100644 index 0000000..c715a4f --- /dev/null +++ b/PanModal/View/ShadowConfigurator.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// 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. +// + +import class UIKit.UIColor +import CoreGraphics + +public protocol ShadowConfigurator { + var radius: CGFloat { get set } + var offset: CGSize { get set } + var color: UIColor { get set } + var opacity: Float { get set } +} diff --git a/PanModal/View/UIView+ShadowConfigurator.swift b/PanModal/View/UIView+ShadowConfigurator.swift new file mode 100644 index 0000000..b508657 --- /dev/null +++ b/PanModal/View/UIView+ShadowConfigurator.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// 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. +// + +import UIKit + +public extension UIView { + func configure(shadow: ShadowConfigurator) { + layer.shadowOpacity = shadow.opacity + layer.shadowOffset = shadow.offset + layer.shadowColor = shadow.color.cgColor + layer.shadowRadius = shadow.radius + clipsToBounds = false + } +}