From 2b0f317f9699a438309ae1f316dfcae670d73f42 Mon Sep 17 00:00:00 2001 From: Mark Pospesel Date: Fri, 5 May 2023 11:43:01 +0200 Subject: [PATCH] [Issue-26] Customize animations (#28) * [Issue-26] Customize animations * Remove source code from README --- Package.swift | 2 +- README.md | 53 +++++-------------- .../Animation/Animation+BottomSheet.swift | 18 +++++++ .../Animation/BottomSheetAnimator.swift | 22 ++++++-- .../BottomSheetDismissAnimator.swift | 14 ++--- .../BottomSheetPresentAnimator.swift | 12 +++-- .../BottomSheetController+Appearance.swift | 28 ++++------ .../Animation+BottomSheetTests.swift | 33 ++++++++++++ .../Animation/BottomSheetAnimatorTests.swift | 18 +++++-- .../BottomSheetDismissAnimatorTests.swift | 9 +++- .../BottomSheetPresentAnimatorTests.swift | 9 +++- ...ottomSheetController+AppearanceTests.swift | 6 +-- 12 files changed, 143 insertions(+), 81 deletions(-) create mode 100644 Sources/YBottomSheet/Animation/Animation+BottomSheet.swift create mode 100644 Tests/YBottomSheetTests/Animation/Animation+BottomSheetTests.swift diff --git a/Package.swift b/Package.swift index d38d87e..f2be552 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( ), .package( url: "https://github.com/yml-org/YMatterType.git", - from: "1.6.0" + from: "1.7.0" ) ], targets: [ diff --git a/README.md b/README.md index c5fe2cc..5b70c18 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,6 @@ Usage ### Initializers The Bottom sheet controller can be initialized with either a title and a view or else with a view controller. -```swift -init( - title: String, - childView: UIView, - appearance: BottomSheetController.Appearance = .default -) - -init( - childController: UIViewController, - appearance: BottomSheetController.Appearance = .default -) -``` - When initializing with a view controller, the title is drawn from `UIViewController.title`. When the view controller is a `UINavigationController`, the header appearance options are ignored and the navigation controller's navigation bar is displayed as the sheet's header. In this situation, if you wish to have a close button, then that should be set using the view controller's `navigationItem.rightBarButtonItem` or `.leftBarButtonItem`. Both initializers include an appearance parameter that allows you to fully customize the sheet's appearance. You can also update the sheet's appearance at any time. @@ -84,33 +71,15 @@ final class ViewController: UIViewController { ### Customization `BottomSheetController` has an `appearance` property of type `Appearance`. -`Appearance` lets you customize the bottom sheet appearance. We can customize the appearance of the indicator view, the header view, dimmer color, animation etc. +`Appearance` lets you customize how the bottom sheet both appears and behaves. You can customize: -```swift -/// Determines the appearance of the bottom sheet. -public struct Appearance { - /// Appearance of the drag indicator. - public var indicatorAppearance: DragIndicatorView.Appearance? - /// Appearance of the sheet header view. - public var headerAppearance: SheetHeaderView.Appearance? - /// Bottom sheet layout properties such as corner radius. Default is `.default`. - public let layout: Layout - /// Bottom sheet's shadow. Default is `nil` (no shadow). - public let elevation: Elevation? - /// Dimmer view color. Default is 'UIColor.black.withAlphaComponent(0.5)'. - public let dimmerColor: UIColor? - /// Animation duration on bottom sheet. Default is `0.3`. - public let animationDuration: TimeInterval - /// Animation type during presenting. Default is `curveEaseIn`. - public let presentAnimationCurve: UIView.AnimationOptions - /// Animation type during dismissing. Default is `curveEaseOut`. - public let dismissAnimationCurve: UIView.AnimationOptions - /// (Optional) Minimum content view height. Default is `nil`. - /// - /// Only applicable for resizable sheets. `nil` means to use the content view's intrinsic height as the minimum. - public var minimumContentHeight: CGFloat? -} -``` +* drag indicator (whether you have one at all or what its size and color are) +* header (whether you have one at all or what its text color, typography, and optional close button image are) +* layout (corner radius and minimum, maximum, and ideal sizes for the sheet's contents) +* drop shadow (if any) +* dimmer color +* present animation +* dismiss animation **Update or customize appearance** @@ -133,8 +102,12 @@ sheet.appearance.elevation = Elevation( color: .black, opacity: 0.4 ) +sheet.appearance.presentAnimation = Animation( + duration: 0.4, + curve: .spring(damping: 0.6, velocity: 0.4) +) -// Present the sheet. +// Present the sheet with a spring animation. present(sheet, animated: true) ``` diff --git a/Sources/YBottomSheet/Animation/Animation+BottomSheet.swift b/Sources/YBottomSheet/Animation/Animation+BottomSheet.swift new file mode 100644 index 0000000..7c103c1 --- /dev/null +++ b/Sources/YBottomSheet/Animation/Animation+BottomSheet.swift @@ -0,0 +1,18 @@ +// +// Animation+BottomSheet.swift +// YBottomSheet +// +// Created by Mark Pospesel on 5/4/23. +// Copyright © 2023 Y Media Labs. All rights reserved. +// + +import YCoreUI + +/// Default animations for bottom sheets +public extension Animation { + /// Default animation for presenting a bottom sheet + static let defaultPresent = Animation(curve: .regular(options: .curveEaseIn)) + + /// Default animation for dismissing a bottom sheet + static let defaultDismiss = Animation(curve: .regular(options: .curveEaseOut)) +} diff --git a/Sources/YBottomSheet/Animation/BottomSheetAnimator.swift b/Sources/YBottomSheet/Animation/BottomSheetAnimator.swift index bf9077c..ab214eb 100644 --- a/Sources/YBottomSheet/Animation/BottomSheetAnimator.swift +++ b/Sources/YBottomSheet/Animation/BottomSheetAnimator.swift @@ -13,6 +13,14 @@ class BottomSheetAnimator: NSObject { /// Bottom sheet controller. let sheetViewController: BottomSheetController + enum Direction { + case present + case dismiss + } + + /// Animation direction (present or dismiss) + let direction: Direction + /// Override for isReduceMotionEnabled. Default is `nil`. /// /// For unit testing. When non-`nil` it will be returned instead of @@ -25,16 +33,24 @@ class BottomSheetAnimator: NSObject { } /// Initializes a bottom sheet animator. - /// - Parameter sheetViewController: the sheet being animated. - init(sheetViewController: BottomSheetController) { + /// - Parameters: + /// - sheetViewController: the sheet being animated. + /// - direction: animation direction + init(sheetViewController: BottomSheetController, direction: Direction) { self.sheetViewController = sheetViewController + self.direction = direction super.init() } } extension BottomSheetAnimator: UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { - sheetViewController.appearance.animationDuration + switch direction { + case .present: + return sheetViewController.appearance.presentAnimation.duration + case .dismiss: + return sheetViewController.appearance.dismissAnimation.duration + } } // Override this method and perform the animations diff --git a/Sources/YBottomSheet/Animation/BottomSheetDismissAnimator.swift b/Sources/YBottomSheet/Animation/BottomSheetDismissAnimator.swift index 652edbb..48e97f1 100644 --- a/Sources/YBottomSheet/Animation/BottomSheetDismissAnimator.swift +++ b/Sources/YBottomSheet/Animation/BottomSheetDismissAnimator.swift @@ -10,6 +10,12 @@ import UIKit /// Performs the sheet dismiss animation. class BottomSheetDismissAnimator: BottomSheetAnimator { + /// Initializes a bottom sheet animator. + /// - Parameter sheetViewController: the sheet being animated. + required init(sheetViewController: BottomSheetController) { + super.init(sheetViewController: sheetViewController, direction: .dismiss) + } + override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromViewController = transitionContext.viewController(forKey: .from), let toViewController = transitionContext.viewController(forKey: .to) else { @@ -31,12 +37,8 @@ class BottomSheetDismissAnimator: BottomSheetAnimator { ) { sheet.dimmerView.alpha = 0 } - - UIView.animate( - withDuration: duration, - delay: .zero, - options: [.beginFromCurrentState, sheet.appearance.dismissAnimationCurve] - ) { + + UIView.animate(with: sheet.appearance.dismissAnimation) { if self.isReduceMotionEnabled { sheet.sheetView.alpha = 0 } else { diff --git a/Sources/YBottomSheet/Animation/BottomSheetPresentAnimator.swift b/Sources/YBottomSheet/Animation/BottomSheetPresentAnimator.swift index 3cb329d..17276dd 100644 --- a/Sources/YBottomSheet/Animation/BottomSheetPresentAnimator.swift +++ b/Sources/YBottomSheet/Animation/BottomSheetPresentAnimator.swift @@ -10,6 +10,12 @@ import UIKit /// Performs the sheet present animation. class BottomSheetPresentAnimator: BottomSheetAnimator { + /// Initializes a bottom sheet animator. + /// - Parameter sheetViewController: the sheet being animated. + required init(sheetViewController: BottomSheetController) { + super.init(sheetViewController: sheetViewController, direction: .present) + } + override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toViewController = transitionContext.viewController(forKey: .to) else { transitionContext.completeTransition(false) @@ -44,11 +50,7 @@ class BottomSheetPresentAnimator: BottomSheetAnimator { sheet.dimmerView.alpha = 1 } - UIView.animate( - withDuration: duration, - delay: .zero, - options: [.beginFromCurrentState, sheet.appearance.presentAnimationCurve] - ) { + UIView.animate(with: sheet.appearance.presentAnimation) { if self.isReduceMotionEnabled { sheet.sheetView.alpha = 1 } else { diff --git a/Sources/YBottomSheet/BottomSheetController+Appearance.swift b/Sources/YBottomSheet/BottomSheetController+Appearance.swift index 216b73a..9f6dff2 100644 --- a/Sources/YBottomSheet/BottomSheetController+Appearance.swift +++ b/Sources/YBottomSheet/BottomSheetController+Appearance.swift @@ -22,13 +22,10 @@ extension BottomSheetController { public var elevation: Elevation? /// Dimmer view color. Default is 'UIColor.black.withAlphaComponent(0.5)'. public var dimmerColor: UIColor? - /// Animation duration on bottom sheet. Default is `0.3`. - public var animationDuration: TimeInterval - /// Animation type during presenting. Default is `curveEaseIn`. - public var presentAnimationCurve: UIView.AnimationOptions - /// Animation type during dismissing. Default is `curveEaseOut`. - public var dismissAnimationCurve: UIView.AnimationOptions - /// Whether the sheet can be dismissed by swiping down or tapping on the dimmer. Default is `true`. + /// Animation for presenting the bottom sheet. Default = `.defaultPresent`. + public var presentAnimation: Animation + /// Animation for dismissing the bottom sheet. Default = `.defaultDismiss`. + public var dismissAnimation: Animation /// /// The user can always dismiss the sheet from the close button if it is visible. public var isDismissAllowed: Bool @@ -43,11 +40,10 @@ extension BottomSheetController { /// - indicatorAppearance: appearance of the drag indicator or pass `nil` to hide. /// - headerAppearance: appearance of the sheet header view or pass `nil` to hide. /// - layout: bottom sheet layout properties such as corner radius. - /// - elevation: bottom sheet's shadow or pass `nil` to hide + /// - elevation: bottom sheet's shadow or pass `nil` to hide. /// - dimmerColor: dimmer view color or pass `nil` to hide. - /// - animationDuration: animation duration for bottom sheet. Default is `0.3`. - /// - presentAnimationCurve: animation type during presenting. - /// - dismissAnimationCurve: animation type during dismiss. + /// - presentAnimation: animation for presenting the bottom sheet. + /// - dismissAnimation: animation for dismissing the bottom sheet. /// - isDismissAllowed: whether the sheet can be dismissed by swiping down or tapping on the dimmer. public init( indicatorAppearance: DragIndicatorView.Appearance? = nil, @@ -55,9 +51,8 @@ extension BottomSheetController { layout: Layout = .default, elevation: Elevation? = nil, dimmerColor: UIColor? = .black.withAlphaComponent(0.5), - animationDuration: TimeInterval = 0.3, - presentAnimationCurve: UIView.AnimationOptions = .curveEaseIn, - dismissAnimationCurve: UIView.AnimationOptions = .curveEaseOut, + presentAnimation: Animation = .defaultPresent, + dismissAnimation: Animation = .defaultDismiss, isDismissAllowed: Bool = true ) { self.indicatorAppearance = indicatorAppearance @@ -65,9 +60,8 @@ extension BottomSheetController { self.layout = layout self.elevation = elevation self.dimmerColor = dimmerColor - self.animationDuration = animationDuration - self.presentAnimationCurve = presentAnimationCurve - self.dismissAnimationCurve = dismissAnimationCurve + self.presentAnimation = presentAnimation + self.dismissAnimation = dismissAnimation self.isDismissAllowed = isDismissAllowed } } diff --git a/Tests/YBottomSheetTests/Animation/Animation+BottomSheetTests.swift b/Tests/YBottomSheetTests/Animation/Animation+BottomSheetTests.swift new file mode 100644 index 0000000..965e9b5 --- /dev/null +++ b/Tests/YBottomSheetTests/Animation/Animation+BottomSheetTests.swift @@ -0,0 +1,33 @@ +// +// Animation+BottomSheetTests.swift +// YBottomSheet +// +// Created by Mark Pospesel on 5/4/23. +// Copyright © 2023 Y Media Labs. All rights reserved. +// + +import XCTest +import YCoreUI +@testable import YBottomSheet + +final class AnimationBottomSheetTests: XCTestCase { + func test_defaultPresent() { + // Given + let sut = Animation.defaultPresent + + // Then + XCTAssertEqual(sut.duration, 0.3) + XCTAssertEqual(sut.delay, 0.0) + XCTAssertEqual(sut.curve, .regular(options: .curveEaseIn)) + } + + func test_defaultDismiss() { + // Given + let sut = Animation.defaultDismiss + + // Then + XCTAssertEqual(sut.duration, 0.3) + XCTAssertEqual(sut.delay, 0.0) + XCTAssertEqual(sut.curve, .regular(options: .curveEaseOut)) + } +} diff --git a/Tests/YBottomSheetTests/Animation/BottomSheetAnimatorTests.swift b/Tests/YBottomSheetTests/Animation/BottomSheetAnimatorTests.swift index 3eb477b..39298f3 100644 --- a/Tests/YBottomSheetTests/Animation/BottomSheetAnimatorTests.swift +++ b/Tests/YBottomSheetTests/Animation/BottomSheetAnimatorTests.swift @@ -17,13 +17,22 @@ final class BottomSheetAnimatorTests: XCTestCase { XCTAssertEqual(sut.sheetViewController, sheetController) } - func test_duration() { + func test_presentDuration() { let main = UIViewController() let sheetController = BottomSheetController(title: "Bottom Sheet", childView: UIView()) - let sut = makeSUT(sheetViewController: sheetController) + let sut = makeSUT(sheetViewController: sheetController, direction: .present) + let context = MockAnimationContext(from: main, to: sheetController) + + XCTAssertEqual(sut.transitionDuration(using: context), sheetController.appearance.presentAnimation.duration) + } + + func test_dismissDuration() { + let main = UIViewController() + let sheetController = BottomSheetController(title: "Bottom Sheet", childView: UIView()) + let sut = makeSUT(sheetViewController: sheetController, direction: .dismiss) let context = MockAnimationContext(from: main, to: sheetController) - XCTAssertEqual(sut.transitionDuration(using: context), sheetController.appearance.animationDuration) + XCTAssertEqual(sut.transitionDuration(using: context), sheetController.appearance.dismissAnimation.duration) } func test_animate() { @@ -42,10 +51,11 @@ final class BottomSheetAnimatorTests: XCTestCase { private extension BottomSheetAnimatorTests { func makeSUT( sheetViewController: BottomSheetController, + direction: BottomSheetAnimator.Direction = .present, file: StaticString = #filePath, line: UInt = #line ) -> BottomSheetAnimator { - let sut = BottomSheetAnimator(sheetViewController: sheetViewController) + let sut = BottomSheetAnimator(sheetViewController: sheetViewController, direction: direction) trackForMemoryLeak(sut, file: file, line: line) return sut } diff --git a/Tests/YBottomSheetTests/Animation/BottomSheetDismissAnimatorTests.swift b/Tests/YBottomSheetTests/Animation/BottomSheetDismissAnimatorTests.swift index c37790c..d4c9b96 100644 --- a/Tests/YBottomSheetTests/Animation/BottomSheetDismissAnimatorTests.swift +++ b/Tests/YBottomSheetTests/Animation/BottomSheetDismissAnimatorTests.swift @@ -7,6 +7,7 @@ // import XCTest +import YCoreUI @testable import YBottomSheet final class BottomSheetDismissAnimatorTests: XCTestCase { @@ -14,6 +15,7 @@ final class BottomSheetDismissAnimatorTests: XCTestCase { let sheetController = makeSheet() let (sut, context) = try makeSUT(sheetViewController: sheetController, to: sheetController) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) XCTAssertTrue(sut is BottomSheetDismissAnimator) XCTAssertFalse(context.wasCompleteCalled) sut.animateTransition(using: context) @@ -32,6 +34,7 @@ final class BottomSheetDismissAnimatorTests: XCTestCase { isReduceMotionEnabled: false ) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) sut.animateTransition(using: context) // Wait for the run loop to tick (animate keyboard) @@ -49,6 +52,7 @@ final class BottomSheetDismissAnimatorTests: XCTestCase { isReduceMotionEnabled: true ) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) sut.animateTransition(using: context) // Wait for the run loop to tick (animate keyboard) @@ -62,6 +66,7 @@ final class BottomSheetDismissAnimatorTests: XCTestCase { let sheetController = makeSheet() let (sut, context) = try makeSUT(sheetViewController: sheetController, to: nil) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) XCTAssertFalse(context.wasCompleteCalled) sut.animateTransition(using: context) @@ -96,7 +101,9 @@ private extension BottomSheetDismissAnimatorTests { let sheet = BottomSheetController( title: "Bottom Sheet", childView: UIView(), - appearance: BottomSheetController.Appearance(animationDuration: 0.0) + appearance: BottomSheetController.Appearance( + dismissAnimation: Animation(duration: 0.0, curve: .regular(options: .curveEaseOut)) + ) ) trackForMemoryLeak(sheet) return sheet diff --git a/Tests/YBottomSheetTests/Animation/BottomSheetPresentAnimatorTests.swift b/Tests/YBottomSheetTests/Animation/BottomSheetPresentAnimatorTests.swift index 92ceb33..5ea7a99 100644 --- a/Tests/YBottomSheetTests/Animation/BottomSheetPresentAnimatorTests.swift +++ b/Tests/YBottomSheetTests/Animation/BottomSheetPresentAnimatorTests.swift @@ -7,6 +7,7 @@ // import XCTest +import YCoreUI @testable import YBottomSheet final class BottomSheetPresentAnimatorTests: XCTestCase { @@ -14,6 +15,7 @@ final class BottomSheetPresentAnimatorTests: XCTestCase { let sheetController = makeSheet() let (sut, context) = try makeSUT(sheetViewController: sheetController, to: sheetController) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) XCTAssertTrue(sut is BottomSheetPresentAnimator) XCTAssertFalse(context.wasCompleteCalled) sut.animateTransition(using: context) @@ -32,6 +34,7 @@ final class BottomSheetPresentAnimatorTests: XCTestCase { isReduceMotionEnabled: false ) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) sut.animateTransition(using: context) // Wait for the run loop to tick (animate keyboard) @@ -48,6 +51,7 @@ final class BottomSheetPresentAnimatorTests: XCTestCase { isReduceMotionEnabled: true ) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) sut.animateTransition(using: context) // Wait for the run loop to tick (animate keyboard) @@ -60,6 +64,7 @@ final class BottomSheetPresentAnimatorTests: XCTestCase { let sheetController = makeSheet() let (sut, context) = try makeSUT(sheetViewController: sheetController, to: nil) + XCTAssertEqual(sut.transitionDuration(using: context), 0.0) XCTAssertFalse(context.wasCompleteCalled) sut.animateTransition(using: context) @@ -98,7 +103,9 @@ private extension BottomSheetPresentAnimatorTests { let sheet = BottomSheetController( title: "Bottom Sheet", childView: UIView(), - appearance: BottomSheetController.Appearance(animationDuration: 0.0) + appearance: BottomSheetController.Appearance( + presentAnimation: Animation(duration: 0.0, curve: .regular(options: .curveEaseIn)) + ) ) trackForMemoryLeak(sheet) return sheet diff --git a/Tests/YBottomSheetTests/BottomSheetController+AppearanceTests.swift b/Tests/YBottomSheetTests/BottomSheetController+AppearanceTests.swift index 4d075b5..ff51c13 100644 --- a/Tests/YBottomSheetTests/BottomSheetController+AppearanceTests.swift +++ b/Tests/YBottomSheetTests/BottomSheetController+AppearanceTests.swift @@ -7,6 +7,7 @@ // import XCTest +import YCoreUI import YMatterType @testable import YBottomSheet @@ -19,9 +20,8 @@ final class BottomSheetControllerAppearanceTests: XCTestCase { XCTAssertEqual(sut.layout, .default) XCTAssertEqual(sut.elevation, nil) XCTAssertEqual(sut.dimmerColor, .black.withAlphaComponent(0.5)) - XCTAssertEqual(sut.animationDuration, 0.3) - XCTAssertEqual(sut.presentAnimationCurve, .curveEaseIn) - XCTAssertEqual(sut.dismissAnimationCurve, .curveEaseOut) + XCTAssertEqual(sut.presentAnimation, Animation(curve: .regular(options: .curveEaseIn))) + XCTAssertEqual(sut.dismissAnimation, Animation(curve: .regular(options: .curveEaseOut))) XCTAssertTrue(sut.isDismissAllowed) } }