diff --git a/Sources/YBottomSheet/BottomSheetController+Animation.swift b/Sources/YBottomSheet/BottomSheetController+Animation.swift new file mode 100644 index 0000000..c9349de --- /dev/null +++ b/Sources/YBottomSheet/BottomSheetController+Animation.swift @@ -0,0 +1,27 @@ +// +// BottomSheetController+Animation.swift +// YBottomSheet +// +// Created by Dev Karan on 22/03/23. +// Copyright © 2023 Y Media Labs. All rights reserved. +// + +import UIKit + +extension BottomSheetController: UIViewControllerTransitioningDelegate { + /// Returns the animator for presenting a bottom sheet + public func animationController( + forPresented presented: UIViewController, + presenting: UIViewController, + source: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + BottomSheetPresentAnimator(sheetViewController: self) + } + + /// Returns the animator for dismissing a bottom sheet + public func animationController( + forDismissed dismissed: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + BottomSheetDismissAnimator(sheetViewController: self) + } +} diff --git a/Sources/YBottomSheet/BottomSheetController+Appearance.swift b/Sources/YBottomSheet/BottomSheetController+Appearance.swift index 159e97b..e78cf1a 100644 --- a/Sources/YBottomSheet/BottomSheetController+Appearance.swift +++ b/Sources/YBottomSheet/BottomSheetController+Appearance.swift @@ -32,6 +32,10 @@ extension BottomSheetController { /// /// Only applicable for resizable sheets. `nil` means to use the content view's intrinsic height as the minimum. public var minimumContentHeight: CGFloat? + /// Whether the sheet can be dismissed by swiping down or tapping on the dimmer. Default is `true`. + /// + /// The user can always dismiss the sheet from the close button if it is visible. + public var isDismissAllowed: Bool /// Default appearance (fixed size sheet) public static let `default` = Appearance() @@ -49,6 +53,7 @@ extension BottomSheetController { /// - presentAnimationCurve: Animaiton during presenting. /// - dismissAnimationCurve: Animation during dismiss. /// - minimumContentHeight: Optional) Minimum content view height. + /// - isDismissAllowed: Whether the sheet can be dismissed by swiping down or tapping on the dimmer. public init( indicatorAppearance: DragIndicatorView.Appearance? = nil, headerAppearance: SheetHeaderView.Appearance? = .default, @@ -58,7 +63,8 @@ extension BottomSheetController { animationDuration: TimeInterval = 0.3, presentAnimationCurve: UIView.AnimationOptions = .curveEaseIn, dismissAnimationCurve: UIView.AnimationOptions = .curveEaseOut, - minimumContentHeight: CGFloat? = nil + minimumContentHeight: CGFloat? = nil, + isDismissAllowed: Bool = true ) { self.indicatorAppearance = indicatorAppearance self.headerAppearance = headerAppearance @@ -69,6 +75,7 @@ extension BottomSheetController { self.presentAnimationCurve = presentAnimationCurve self.dismissAnimationCurve = dismissAnimationCurve self.minimumContentHeight = minimumContentHeight + self.isDismissAllowed = isDismissAllowed } } } diff --git a/Sources/YBottomSheet/BottomSheetController.swift b/Sources/YBottomSheet/BottomSheetController.swift index 68b847c..f9f08c7 100644 --- a/Sources/YBottomSheet/BottomSheetController.swift +++ b/Sources/YBottomSheet/BottomSheetController.swift @@ -149,6 +149,15 @@ public class BottomSheetController: UIViewController { didDismiss() return true } + + /// Dismisses the bottom sheet if allowed. + /// + /// This method is not called when the header's close button is tapped. + func didDismiss() { + if appearance.isDismissAllowed { + onDismiss() + } + } } private extension BottomSheetController { @@ -308,7 +317,8 @@ private extension BottomSheetController { extension BottomSheetController: SheetHeaderViewDelegate { @objc - func didDismiss() { + func didTapCloseButton() { + // Directly dismiss the sheet without considering `isDismissAllowed`. onDismiss() } } @@ -352,24 +362,6 @@ private extension BottomSheetController { } } -extension BottomSheetController: UIViewControllerTransitioningDelegate { - /// Returns the animator for presenting a bottom sheet - public func animationController( - forPresented presented: UIViewController, - presenting: UIViewController, - source: UIViewController - ) -> UIViewControllerAnimatedTransitioning? { - BottomSheetPresentAnimator(sheetViewController: self) - } - - /// Returns the animator for dismissing a bottom sheet - public func animationController( - forDismissed dismissed: UIViewController - ) -> UIViewControllerAnimatedTransitioning? { - BottomSheetDismissAnimator(sheetViewController: self) - } -} - // Methods for unit testing internal extension BottomSheetController { @objc @@ -391,7 +383,7 @@ internal extension BottomSheetController { } @objc - func simulateDismiss() { - didDismiss() + func simulateTapCloseButton() { + didTapCloseButton() } } diff --git a/Sources/YBottomSheet/Protocols/SheetHeaderViewDelegate.swift b/Sources/YBottomSheet/Protocols/SheetHeaderViewDelegate.swift index c9d2cff..cc16f53 100644 --- a/Sources/YBottomSheet/Protocols/SheetHeaderViewDelegate.swift +++ b/Sources/YBottomSheet/Protocols/SheetHeaderViewDelegate.swift @@ -9,5 +9,5 @@ import Foundation internal protocol SheetHeaderViewDelegate: AnyObject { - func didDismiss() + func didTapCloseButton() } diff --git a/Sources/YBottomSheet/SheetHeaderView/SheetHeaderView.swift b/Sources/YBottomSheet/SheetHeaderView/SheetHeaderView.swift index d040d47..a46d0f8 100644 --- a/Sources/YBottomSheet/SheetHeaderView/SheetHeaderView.swift +++ b/Sources/YBottomSheet/SheetHeaderView/SheetHeaderView.swift @@ -51,7 +51,7 @@ open class SheetHeaderView: UIView { required public init?(coder: NSCoder) { nil } @objc private func closeButtonAction() { - delegate?.didDismiss() + delegate?.didTapCloseButton() } // For unit testing diff --git a/Tests/YBottomSheetTests/BottomSheetControllerTests.swift b/Tests/YBottomSheetTests/BottomSheetControllerTests.swift index f86a3dc..5bfbd35 100644 --- a/Tests/YBottomSheetTests/BottomSheetControllerTests.swift +++ b/Tests/YBottomSheetTests/BottomSheetControllerTests.swift @@ -13,6 +13,7 @@ import YMatterType // OK to have lots of test cases // swiftlint:disable file_length +// swiftlint:disable type_body_length final class BottomSheetControllerTests: XCTestCase { var window: UIWindow! @@ -130,40 +131,6 @@ final class BottomSheetControllerTests: XCTestCase { XCTAssertTrue(sut.indicatorContainer.isHidden) XCTAssertFalse(sut.isResizable) } - - func test_onDimmer() { - let sut = SpyBottomSheetController(title: "", childView: UIView()) - - XCTAssertFalse(sut.onDimmerTapped) - XCTAssertFalse(sut.isDismissed) - - sut.simulateOnDimmerTap() - - XCTAssertTrue(sut.onDimmerTapped) - XCTAssertTrue(sut.isDismissed) - } - - func test_onSwipeDown() { - let sut = SpyBottomSheetController(title: "", childView: UIView()) - - XCTAssertFalse(sut.onSwipeDown) - XCTAssertFalse(sut.isDismissed) - - sut.simulateOnSwipeDown() - - XCTAssertTrue(sut.onSwipeDown) - XCTAssertTrue(sut.isDismissed) - } - - func test_onDidDismiss() { - let sut = SpyBottomSheetController(title: "", childView: UIView()) - - XCTAssertFalse(sut.isDismissed) - - sut.simulateDismiss() - - XCTAssertTrue(sut.isDismissed) - } func test_dragging_worksIfResizable() { let sut = SpyBottomSheetController(title: "", childView: ChildView(), appearance: .defaultResizable) @@ -322,6 +289,60 @@ final class BottomSheetControllerTests: XCTestCase { let sut = makeSUT(viewController: UINavigationController(rootViewController: UIViewController())) XCTAssertFalse(sut.hasHeader) } + + func test_onDimmer() { + let sut = SpyBottomSheetController(title: "", childView: UIView()) + + XCTAssertFalse(sut.onDimmerTapped) + XCTAssertFalse(sut.isDismissed) + + sut.simulateOnDimmerTap() + + XCTAssertTrue(sut.onDimmerTapped) + XCTAssertTrue(sut.isDismissed) + } + + func test_onSwipeDown() { + let sut = SpyBottomSheetController(title: "", childView: UIView()) + + XCTAssertFalse(sut.onSwipeDown) + XCTAssertFalse(sut.isDismissed) + + sut.simulateOnSwipeDown() + + XCTAssertTrue(sut.onSwipeDown) + XCTAssertTrue(sut.isDismissed) + } + + func test_dismissOnCloseButtonTapped() { + let sut = SpyBottomSheetController(title: "", childView: UIView()) + + XCTAssertFalse(sut.isDismissed) + + sut.simulateTapCloseButton() + + XCTAssertTrue(sut.isDismissed) + } + + func test_forbidDismiss() { + let sut = SpyBottomSheetController(title: "", childView: UIView()) + sut.appearance.isDismissAllowed = false + + XCTAssertFalse(sut.onSwipeDown) + XCTAssertFalse(sut.onDimmerTapped) + XCTAssertFalse(sut.isDismissed) + + sut.simulateOnDimmerTap() + sut.simulateOnSwipeDown() + _ = sut.accessibilityPerformEscape() + + XCTAssertFalse(sut.onSwipeDown) + XCTAssertFalse(sut.onDimmerTapped) + + // tap close button always dismisses + sut.simulateTapCloseButton() + XCTAssertTrue(sut.isDismissed) + } } private extension BottomSheetControllerTests { @@ -367,20 +388,31 @@ final class SpyBottomSheetController: BottomSheetController { var onSwipeDown = false var onDimmerTapped = false var onDragging = false + + override func simulateTapCloseButton() { + super.simulateTapCloseButton() + isDismissed = true + } override func didDismiss() { super.didDismiss() - isDismissed = true + if appearance.isDismissAllowed { + isDismissed = true + } } override func simulateOnSwipeDown() { super.simulateOnSwipeDown() - onSwipeDown = true + if appearance.isDismissAllowed { + onSwipeDown = true + } } override func simulateOnDimmerTap() { super.simulateOnDimmerTap() - onDimmerTapped = true + if appearance.isDismissAllowed { + onDimmerTapped = true + } } @discardableResult diff --git a/Tests/YBottomSheetTests/SheetHeaderView/SheetHeaderViewTests.swift b/Tests/YBottomSheetTests/SheetHeaderView/SheetHeaderViewTests.swift index 7ccf500..3ee3d56 100644 --- a/Tests/YBottomSheetTests/SheetHeaderView/SheetHeaderViewTests.swift +++ b/Tests/YBottomSheetTests/SheetHeaderView/SheetHeaderViewTests.swift @@ -91,7 +91,7 @@ private extension SheetHeaderViewTests { } extension SheetHeaderViewTests: SheetHeaderViewDelegate { - func didDismiss() { + func didTapCloseButton() { isDismissed = true } }