Merge pull request 'feat: detents api' (#1) from feature/detents_api into master

Reviewed-on: TouchInstinct/TIPanModal#1
This commit is contained in:
Nikita Semenov 2023-07-09 10:58:44 +03:00
commit 871fc46020
9 changed files with 279 additions and 92 deletions

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 }
}

View File

@ -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
}
}