diff --git a/BottomSheet.xcodeproj/project.pbxproj b/BottomSheet.xcodeproj/project.pbxproj index fe9a108..fa8a788 100644 --- a/BottomSheet.xcodeproj/project.pbxproj +++ b/BottomSheet.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ CF153A022382F88E001687C1 /* BottomSheet.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CF1539BD2382F20E001687C1 /* BottomSheet.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CF153A062382F938001687C1 /* DemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1539E12382F3F6001687C1 /* DemoViewController.swift */; }; CF46A383238593AD00FFCB9F /* BottomSheetCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF46A3812385902100FFCB9F /* BottomSheetCalculator.swift */; }; + DA0F4B00238BDB85002DE188 /* TranslationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0F4AFE238BDB7C002DE188 /* TranslationTarget.swift */; }; + DA11525523880010002E2F40 /* BottomSheetModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA11525323880010002E2F40 /* BottomSheetModelTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,6 +74,8 @@ CF1539F42382F621001687C1 /* SpringAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpringAnimator.swift; sourceTree = ""; }; CF1539F52382F621001687C1 /* BottomSheetView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetView.swift; sourceTree = ""; }; CF46A3812385902100FFCB9F /* BottomSheetCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetCalculator.swift; sourceTree = ""; }; + DA0F4AFE238BDB7C002DE188 /* TranslationTarget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslationTarget.swift; sourceTree = ""; }; + DA11525323880010002E2F40 /* BottomSheetModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetModelTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -127,6 +131,7 @@ children = ( CF1539C02382F20E001687C1 /* BottomSheet.h */, CF1539C12382F20E001687C1 /* Info.plist */, + DA0F4AFE238BDB7C002DE188 /* TranslationTarget.swift */, CF46A3812385902100FFCB9F /* BottomSheetCalculator.swift */, CF1539F12382F621001687C1 /* BottomSheetPresentationController.swift */, CF1539F32382F621001687C1 /* BottomSheetTransitioningDelegate.swift */, @@ -139,6 +144,7 @@ CF1539CA2382F20E001687C1 /* Tests */ = { isa = PBXGroup; children = ( + DA11525323880010002E2F40 /* BottomSheetModelTests.swift */, CF1539CB2382F20E001687C1 /* BottomSheetCalculatorTests.swift */, CF1539CD2382F20E001687C1 /* Info.plist */, ); @@ -330,6 +336,7 @@ CF1539FB2382F62A001687C1 /* BottomSheetPresentationController.swift in Sources */, CF46A383238593AD00FFCB9F /* BottomSheetCalculator.swift in Sources */, CF1539FC2382F62A001687C1 /* BottomSheetTransitioningDelegate.swift in Sources */, + DA0F4B00238BDB85002DE188 /* TranslationTarget.swift in Sources */, CF1539FF2382F62A001687C1 /* SpringAnimator.swift in Sources */, CF1539FD2382F62A001687C1 /* BottomSheetView.swift in Sources */, ); @@ -339,6 +346,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA11525523880010002E2F40 /* BottomSheetModelTests.swift in Sources */, CF1539CC2382F20E001687C1 /* BottomSheetCalculatorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/BottomSheetCalculator.swift b/Sources/BottomSheetCalculator.swift index 9285e29..a1b900b 100644 --- a/Sources/BottomSheetCalculator.swift +++ b/Sources/BottomSheetCalculator.swift @@ -30,68 +30,84 @@ struct BottomSheetCalculator { return max(superview.frame.height - makeTargetHeight(), handleHeight) } - /// Calculates bottom and top thresholds for the given target offsets. + /// Creates the translation targets of a BottomSheetView based on an array of target offsets and the current target offset + /// /// - Parameters: - /// - contentView: the content view of the bottom sheet + /// - targetOffsets: array containing the different target offsets a BottomSheetView can transition between + /// - currentTargetIndex: index of the current target offset of the BottomSheetView /// - superview: the bottom sheet container view - /// - height: preferred height for the content view - static func thresholds(for targetOffsets: [CGFloat], in superview: UIView) -> [CGFloat] { + /// - isDismissable: flag specifying whether the last translation target should dismiss the BottomSheetView + static func createTranslationTargets( + for targetOffsets: [CGFloat], + at currentTargetIndex: Int, + in superview: UIView, + isDismissible: Bool + ) -> [TranslationTarget] { guard !targetOffsets.isEmpty else { return [] } - let maxThreshold: CGFloat = 75 - let targetOffsets = [0] + targetOffsets + [superview.frame.height] + let minOffset = targetOffsets.last ?? 0 + let maxOffset = targetOffsets.first ?? 0 - return zip(targetOffsets.dropFirst(), targetOffsets).map { - min(abs(($0 - $1) * 0.25), maxThreshold) + // Thresholds is how long you need to translate from one translation target to another + // Calculates the threshold between two offsets + func threshold(_ offsetA: CGFloat, _ offsetB: CGFloat) -> CGFloat { + let maxThreshold: CGFloat = 75 + return min(abs(offsetB - offsetA) * 0.25, maxThreshold) } - } + // If the BottomSheetView is dismissible we want the user to translate a certain amount before transitioning to the dismiss translation target + // If not, make it stop at the smallest target height by setting the first threshold to zero. + let lowestThreshold = isDismissible ? threshold(superview.frame.height, maxOffset) : 0 + // We add a zero threshold at the end to make the BottomSheetView stop at its biggest height. + let highestThreshold: CGFloat = 0 + // Calculate all the offsets in between + let thresholds = [lowestThreshold] + zip(targetOffsets.dropFirst(), targetOffsets).map { threshold($0, $1) } + [highestThreshold] + + // Calculate lower bounds + let lowerOffsets = targetOffsets[currentTargetIndex...] + let lowerThresholds = thresholds[(currentTargetIndex + 1)...] + let lowerBounds = zip(lowerOffsets, lowerThresholds).map(-) + + // Calculate upper bounds + let upperOffsets = targetOffsets[...currentTargetIndex] + let upperThresholds = thresholds[...currentTargetIndex] + let upperBounds = zip(upperOffsets, upperThresholds).map(+) + + let bounds = upperBounds + lowerBounds + + // Target used to control offsets bigger than or equal to maxOffset + let bottomTarget = LimitTarget( + targetOffset: isDismissible ? superview.frame.height : maxOffset, + bound: bounds.first ?? maxOffset, + behavior: isDismissible ? .linear : .stop, + isDismissible: isDismissible, + compare: >= + ) - static func translationState( - from source: CGFloat, - to destination: CGFloat, - targetOffsets: [CGFloat], - thresholds: [CGFloat], - currentTargetOffsetIndex: Int - ) -> TranslationState? { - guard currentTargetOffsetIndex >= 0 && currentTargetOffsetIndex < targetOffsets.count else { return nil } - guard thresholds.count == targetOffsets.count + 1 else { return nil } - - let currentTargetOffset = targetOffsets[currentTargetOffsetIndex] - let lowerBound = currentTargetOffset - thresholds[currentTargetOffsetIndex] - let upperBound = currentTargetOffset + thresholds[currentTargetOffsetIndex + 1] - let currentArea = lowerBound ... upperBound - - if currentArea.contains(destination) { - // Within the area of the current target offset, allow dragging. - return TranslationState(nextOffset: destination, targetOffset: currentTargetOffset, isDismissible: false) - } else if destination < currentTargetOffset { - let targetOffset = targetOffsets.first(where: { $0 < destination }) - // Above the area of the current target offset, allow dragging if the next target offset is found. - return TranslationState( - nextOffset: targetOffset == nil ? source : destination, - targetOffset: targetOffset ?? currentTargetOffset, + var upperBound = bounds.first ?? 0 + var targets: [TranslationTarget] = [bottomTarget] + + for (targetOffset, lowerBound) in zip(targetOffsets, bounds.dropFirst()) { + let target = RangeTarget( + targetOffset: targetOffset, + range: lowerBound ..< upperBound, isDismissible: false ) - } else { - let targetOffset = targetOffsets.first(where: { $0 > destination }) - // Below the area of the current target offset, - // allow dragging and set as dismissable if the next target offset is not found. - return TranslationState( - nextOffset: destination, - targetOffset: targetOffset ?? currentTargetOffset, - isDismissible: targetOffset == nil - ) + + targets.append(target) + upperBound = lowerBound } - } -} -// MARK: - Helper types + // Target used to control offsets smaller than minOffset + let topTarget = LimitTarget( + targetOffset: minOffset, + bound: minOffset, + behavior: .rubberBand(radius: threshold(0, minOffset)), + isDismissible: false, + compare: < + ) + + targets.append(topTarget) -struct TranslationState { - /// The offset to be set for the current pan gesture translation. - let nextOffset: CGFloat - /// The offset to be set when the pan gesture ended, cancelled or failed. - let targetOffset: CGFloat - /// A flag indicating whether the view is ready to be dismissed. - let isDismissible: Bool + return targets + } } diff --git a/Sources/BottomSheetPresentationController.swift b/Sources/BottomSheetPresentationController.swift index c506f22..e092aff 100644 --- a/Sources/BottomSheetPresentationController.swift +++ b/Sources/BottomSheetPresentationController.swift @@ -40,7 +40,13 @@ final class BottomSheetPresentationController: UIPresentationController { override func presentationTransitionWillBegin() { guard let presentedView = presentedView else { return } - bottomSheetView = BottomSheetView(contentView: presentedView, targetHeights: targetHeights) + + bottomSheetView = BottomSheetView( + contentView: presentedView, + targetHeights: targetHeights, + isDismissible: true + ) + bottomSheetView?.delegate = self bottomSheetView?.isDimViewHidden = false } diff --git a/Sources/BottomSheetView.swift b/Sources/BottomSheetView.swift index 1815dfa..3d9a8e3 100644 --- a/Sources/BottomSheetView.swift +++ b/Sources/BottomSheetView.swift @@ -28,15 +28,15 @@ public final class BottomSheetView: UIView { // MARK: - Private properties + private let isDismissable: Bool private let contentView: UIView - private let targetHeights: [CGFloat] private var topConstraint: NSLayoutConstraint! + private let targetHeights: [CGFloat] private var targetOffsets = [CGFloat]() - private var targetThresholds = [CGFloat]() private var currentTargetOffsetIndex: Int = 0 - private var currentTargetOffset: CGFloat { - return targetOffsets[currentTargetOffsetIndex] - } + + private var initialOffset: CGFloat? + private var translationTargets = [TranslationTarget]() private lazy var panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(panGesture:))) private lazy var springAnimator = SpringAnimator(dampingRatio: 0.8, frequencyResponse: 0.4) @@ -60,9 +60,10 @@ public final class BottomSheetView: UIView { // MARK: - Init - public init(contentView: UIView, targetHeights: [CGFloat]) { + public init(contentView: UIView, targetHeights: [CGFloat], isDismissible: Bool = false) { self.contentView = contentView self.targetHeights = targetHeights.isEmpty ? [.bottomSheetAutomatic] : targetHeights + self.isDismissable = isDismissible super.init(frame: .zero) setup() } @@ -114,6 +115,7 @@ public final class BottomSheetView: UIView { superview.layoutIfNeeded() addGestureRecognizer(panGesture) + currentTargetOffsetIndex = targetIndex updateTargetOffsets() transition(to: targetIndex) } @@ -139,7 +141,12 @@ public final class BottomSheetView: UIView { /// Call this method e.g. when orientation change is detected. public func reset() { updateTargetOffsets() - animate(to: currentTargetOffset) + + guard let maxOffset = targetHeights.max() else { + return + } + + animate(to: maxOffset) } /// Animates bottom sheet view to the given height. @@ -147,11 +154,14 @@ public final class BottomSheetView: UIView { /// - Parameters: /// - index: the index of the target height public func transition(to index: Int) { - guard index >= 0 && index < targetHeights.count else { + guard targetHeights.indices.contains(index) else { assertionFailure("Provided index is out of bounds of the array with target heights.") return } - guard let superview = superview else { return } + + guard let superview = superview else { + return + } let offset = BottomSheetCalculator.offset(for: contentView, in: superview, height: targetHeights[index]) animate(to: offset) @@ -192,19 +202,19 @@ public final class BottomSheetView: UIView { // MARK: - Animations - private func animate(to offset: CGFloat) { + private func animate(to offset: CGFloat, with initialVelocity: CGPoint = .zero) { if let index = targetOffsets.firstIndex(of: offset) { currentTargetOffsetIndex = index } springAnimator.fromPosition = CGPoint(x: 0, y: topConstraint.constant) springAnimator.toPosition = CGPoint(x: 0, y: offset) - springAnimator.initialVelocity = .zero + springAnimator.initialVelocity = initialVelocity springAnimator.startAnimation() } private func updateDimViewAlpha(for offset: CGFloat) { - if let superview = superview, let maxOffset = targetOffsets.last { + if let superview = superview, let maxOffset = targetOffsets.max() { dimView.alpha = min(1, (superview.frame.height - offset) / (superview.frame.height - maxOffset)) } } @@ -212,31 +222,35 @@ public final class BottomSheetView: UIView { // MARK: - UIPanGestureRecognizer @objc private func handlePan(panGesture: UIPanGestureRecognizer) { - guard let state = BottomSheetCalculator.translationState( - from: topConstraint.constant, - to: topConstraint.constant + panGesture.translation(in: superview).y, - targetOffsets: targetOffsets, - thresholds: targetThresholds, - currentTargetOffsetIndex: currentTargetOffsetIndex - ) else { + initialOffset = initialOffset ?? topConstraint.constant + let translation = panGesture.translation(in: superview) + let location = initialOffset! + translation.y + + guard let translationTarget = translationTargets.first(where: { $0.contains(offset: location) }) else { return } switch panGesture.state { case .began: springAnimator.pauseAnimation() + + case .changed: + updateDimViewAlpha(for: location) + topConstraint.constant = translationTarget.nextOffset(for: location) + case .ended, .cancelled, .failed: - animate(to: state.targetOffset) - if state.isDismissible { + initialOffset = nil + + if translationTarget.isDismissible { delegate?.bottomSheetViewDidReachDismissArea(self) + } else { + animate(to: translationTarget.targetOffset) + createTranslationTargets() } + default: break } - - topConstraint.constant = state.nextOffset - updateDimViewAlpha(for: state.nextOffset) - panGesture.setTranslation(.zero, in: superview) } // MARK: - Offset calculation @@ -244,11 +258,22 @@ public final class BottomSheetView: UIView { private func updateTargetOffsets() { guard let superview = superview else { return } - targetOffsets = targetHeights.map({ + targetOffsets = targetHeights.map { BottomSheetCalculator.offset(for: contentView, in: superview, height: $0) - }).sorted() + }.sorted(by: >) + + createTranslationTargets() + } + + private func createTranslationTargets() { + guard let superview = superview else { return } - targetThresholds = BottomSheetCalculator.thresholds(for: targetOffsets, in: superview) + translationTargets = BottomSheetCalculator.createTranslationTargets( + for: targetOffsets, + at: currentTargetOffsetIndex, + in: superview, + isDismissible: isDismissable + ) } } diff --git a/Sources/TranslationTarget.swift b/Sources/TranslationTarget.swift new file mode 100644 index 0000000..5fdaf6d --- /dev/null +++ b/Sources/TranslationTarget.swift @@ -0,0 +1,105 @@ +// +// Copyright © 2019 FINN.no. All rights reserved. +// + +import CoreGraphics + +/// Model defining a certain area of a BottomSheetView. +/// +protocol TranslationTarget { + + /// An offset which a BottomSheetView can transition to + /// + var targetOffset: CGFloat { get } + + /// Flag specifying whether a BottomSheetView should be dismissed. + /// This should only be used when presented by a presentation controller + /// + var isDismissible: Bool { get } + + /// BottomSheetView will find the model which contains the current translation offset + /// and transition to its target offset when its gesture ends. + /// + /// - Parameters: + /// - offset: some offset. E.g. a pan gestures translation, a table view's contentOffset. + /// + /// Return true if a BottomSheetView should transition to this target offset. + /// + func contains(offset: CGFloat) -> Bool + + /// This method is called when a BottomSheetView's pan gesture changes. + /// + /// - Parameters: + /// - offset: some offset. E.g. a pan gesture's translation, a table views contentOffset. + /// + /// BottomSheetView calls this method to set the constant of its constraint + /// Use this method to alter the panning movement of a BottomSheetView. E.g. make it bounce, or stick to a value. + /// + func nextOffset(for offset: CGFloat) -> CGFloat +} + +/// Defines the behavior of the translation +enum TranslationBehavior { + case linear + case rubberBand(radius: CGFloat) + case stop +} + +/// RangeTarget has an upper and a lower bound defining a range around its target offset +/// +struct RangeTarget: TranslationTarget { + let targetOffset: CGFloat + let range: Range + let isDismissible: Bool + + func contains(offset: CGFloat) -> Bool { + range.contains(offset) + } + + func nextOffset(for offset: CGFloat) -> CGFloat { + offset + } +} + +/// LimitTarget will compare the offset against its bound +/// +/// A lower limit model will stop a BottomSheetView translating below its lowest target offset +/// +/// let lowerLimit = LimitModel( +/// targetOffset: offset, +/// bound: offset, +/// behavior: .stop, +/// isDismissable: false, +/// compare: < +/// ) +/// +struct LimitTarget: TranslationTarget { + let targetOffset: CGFloat + let bound: CGFloat + let behavior: TranslationBehavior + let isDismissible: Bool + let compare: (CGFloat, CGFloat) -> Bool + + func contains(offset: CGFloat) -> Bool { + compare(offset, bound) + } + + func nextOffset(for offset: CGFloat) -> CGFloat { + switch behavior { + case .linear: + return offset + case .rubberBand(let radius): + let distance = offset - bound + let newOffset = radius * (1 - exp(-abs(distance) / radius)) + + if distance < 0 { + return bound - newOffset + } else { + return bound + newOffset + } + + case .stop: + return bound + } + } +} diff --git a/Tests/BottomSheetCalculatorTests.swift b/Tests/BottomSheetCalculatorTests.swift index 325708e..ad74e23 100644 --- a/Tests/BottomSheetCalculatorTests.swift +++ b/Tests/BottomSheetCalculatorTests.swift @@ -26,105 +26,69 @@ final class BottomSheetCalculatorTests: XCTestCase { XCTAssertEqual(BottomSheetCalculator.offset(for: view, in: superview, height: 0), 400) // Superview height } - func testThresholdsWithEmptyTargetOffsets() { - XCTAssertTrue(BottomSheetCalculator.thresholds(for: [], in: superview).isEmpty) + func testLayoutWithEmptyOffsets() { + XCTAssertTrue(BottomSheetCalculator.createTranslationTargets(for: [], at: 0, in: superview, isDismissible: false).isEmpty) } - func testThresholdsWithSingleTargetOffset() { - XCTAssertEqual(BottomSheetCalculator.thresholds(for: [100], in: superview), [25, 75]) - } + func testLayoutModelsWithSingleOffset() { + let models = BottomSheetCalculator.createTranslationTargets(for: [500], at: 0, in: superview, isDismissible: false) - func testThresholdsWithMultipleTargetOffset() { - XCTAssertEqual(BottomSheetCalculator.thresholds(for: [56, 200], in: superview), [14, 36, 50]) - XCTAssertEqual(BottomSheetCalculator.thresholds(for: [100, 250, 500], in: superview), [25.0, 37.5, 62.5, 25.0]) + XCTAssertEqual(models.count, 3) + XCTAssertTrue(models[0] is LimitTarget) + XCTAssertTrue(models[1] is RangeTarget) + XCTAssertTrue(models[2] is LimitTarget) } - func testThresholdsWithBigDistanceBetweenTargetOffsets() { - XCTAssertEqual(BottomSheetCalculator.thresholds(for: [100, 1000], in: superview), [25.0, 75.0, 75.0]) - } + func testLayoutModelsWithMultipleOffsets() { + let models = BottomSheetCalculator.createTranslationTargets(for: [700, 300, 100], at: 0, in: superview, isDismissible: false) - func testTranslationStateWithinCurrentArea() { - let targetOffsets: [CGFloat] = [56, 200] - let thresholds: [CGFloat] = [36, 36, 36] - - let state = BottomSheetCalculator.translationState( - from: 60, - to: 70, - targetOffsets: targetOffsets, - thresholds: thresholds, - currentTargetOffsetIndex: 0 - ) - - XCTAssertEqual(state?.nextOffset, 70) - XCTAssertEqual(state?.targetOffset, 56) - XCTAssertEqual(state?.isDismissible, false) + XCTAssertEqual(models.count, 5) + XCTAssertTrue(models.first is LimitTarget) + XCTAssertTrue(models.last is LimitTarget) } - func testTranslationStateToAreaBelow() { - let targetOffsets: [CGFloat] = [56, 200] - let thresholds: [CGFloat] = [36, 36, 36] - - let state = BottomSheetCalculator.translationState( - from: 60, - to: 120, - targetOffsets: targetOffsets, - thresholds: thresholds, - currentTargetOffsetIndex: 0 - ) - - XCTAssertEqual(state?.nextOffset, 120) - XCTAssertEqual(state?.targetOffset, 200) - XCTAssertEqual(state?.isDismissible, false) - } + func testLayoutModelsWhenContainingOffset() { + let models = BottomSheetCalculator.createTranslationTargets(for: [700, 300, 100], at: 0, in: superview, isDismissible: false) + + XCTAssertTrue(models[0].contains(offset: 800)) + XCTAssertTrue(models[1].contains(offset: 690)) + XCTAssertTrue(models[2].contains(offset: 300)) + XCTAssertTrue(models[3].contains(offset: 100)) + XCTAssertTrue(models[4].contains(offset: 20)) - func testTranslationStateWhenThereIsNoAreaBelow() { - let targetOffsets: [CGFloat] = [56, 200] - let thresholds: [CGFloat] = [36, 36, 36] - - let state = BottomSheetCalculator.translationState( - from: 200, - to: 250, - targetOffsets: targetOffsets, - thresholds: thresholds, - currentTargetOffsetIndex: 1 - ) - - XCTAssertEqual(state?.nextOffset, 250) - XCTAssertEqual(state?.targetOffset, 200) - XCTAssertEqual(state?.isDismissible, true) } - func testTranslationStateToAreaAbove() { - let targetOffsets: [CGFloat] = [56, 200] - let thresholds: [CGFloat] = [36, 36, 36] - - let state = BottomSheetCalculator.translationState( - from: 190, - to: 140, - targetOffsets: targetOffsets, - thresholds: thresholds, - currentTargetOffsetIndex: 1 - ) - - XCTAssertEqual(state?.nextOffset, 140) - XCTAssertEqual(state?.targetOffset, 56) - XCTAssertEqual(state?.isDismissible, false) + func testLayoutModelsWhenNotContainingOffset() { + let models = BottomSheetCalculator.createTranslationTargets(for: [700, 300, 100], at: 0, in: superview, isDismissible: false) + XCTAssertFalse(models[0].contains(offset: 600)) + XCTAssertFalse(models[1].contains(offset: 300)) + XCTAssertFalse(models[2].contains(offset: 100)) + XCTAssertFalse(models[3].contains(offset: 20)) + XCTAssertFalse(models[4].contains(offset: 100)) } - func testTranslationStateWhenThereIsNoAreaAbove() { - let targetOffsets: [CGFloat] = [56, 200] - let thresholds: [CGFloat] = [36, 36, 36] - - let state = BottomSheetCalculator.translationState( - from: 56, - to: 10, - targetOffsets: targetOffsets, - thresholds: thresholds, - currentTargetOffsetIndex: 0 - ) - - XCTAssertEqual(state?.nextOffset, 56) - XCTAssertEqual(state?.targetOffset, 56) - XCTAssertEqual(state?.isDismissible, false) + func testLayoutThresholds() { + let models = BottomSheetCalculator.createTranslationTargets(for: [700, 600, 400], at: 1, in: superview, isDismissible: false) + + guard let firstModel = models[1] as? RangeTarget else { + return + } + + XCTAssertEqual(firstModel.range.lowerBound, 600 + 25) + XCTAssertEqual(firstModel.range.upperBound, 700 - 0) + + guard let secondModel = models[2] as? RangeTarget else { + return + } + + XCTAssertEqual(secondModel.range.lowerBound, 600 - 50) + XCTAssertEqual(secondModel.range.upperBound, 600 + 25) + + guard let thirdModel = models[3] as? RangeTarget else { + return + } + + XCTAssertEqual(thirdModel.range.lowerBound, 400 - 0) + XCTAssertEqual(thirdModel.range.upperBound, 600 - 50) } } diff --git a/Tests/BottomSheetModelTests.swift b/Tests/BottomSheetModelTests.swift new file mode 100644 index 0000000..3d8bd43 --- /dev/null +++ b/Tests/BottomSheetModelTests.swift @@ -0,0 +1,75 @@ +// +// Copyright © 2019 FINN.no. All rights reserved. +// + +import XCTest +@testable import BottomSheet + +final class BottomSheetModelTests: XCTestCase { + + func testRangeTarget() { + let rangeModel = RangeTarget( + targetOffset: 500, + range: 300 ..< 600, + isDismissible: false + ) + + XCTAssertFalse(rangeModel.contains(offset: 200)) + XCTAssertTrue(rangeModel.contains(offset: 400)) + XCTAssertFalse(rangeModel.contains(offset: 700)) + XCTAssertEqual(rangeModel.nextOffset(for: 400), 400) + } + + func testLowerLimitTargetWithStopBehaviour() { + let targetOffset: CGFloat = 200 + + let lowerLimitModel = LimitTarget( + targetOffset: targetOffset, + bound: targetOffset, + behavior: .stop, + isDismissible: false, + compare: < + ) + + XCTAssertTrue(lowerLimitModel.contains(offset: 100)) + XCTAssertFalse(lowerLimitModel.contains(offset: 300)) + XCTAssertEqual(lowerLimitModel.nextOffset(for: 100), targetOffset) + } + + func testLowerLimitTargetWithRubberBandBehaviour() { + let bound: CGFloat = 300 + let radius: CGFloat = 75 + + let lowerLimitModel = LimitTarget( + targetOffset: bound, + bound: bound, + behavior: .rubberBand(radius: radius), + isDismissible: false, + compare: < + ) + + XCTAssertTrue(lowerLimitModel.contains(offset: 200)) + XCTAssertFalse(lowerLimitModel.contains(offset: 500)) + + let offset: CGFloat = 200 + let distance = offset - bound + let nextOffset = radius * (1 - exp(-abs(distance) / radius)) + XCTAssertEqual(lowerLimitModel.nextOffset(for: offset), bound - nextOffset) + } + + func testUpperLimitTargetWithLinearBehaviour() { + let targetOffset: CGFloat = 700 + + let upperLimitModel = LimitTarget( + targetOffset: targetOffset, + bound: 700, + behavior: .linear, + isDismissible: false, + compare: >= + ) + + XCTAssertFalse(upperLimitModel.contains(offset: 300)) + XCTAssertTrue(upperLimitModel.contains(offset: 800)) + XCTAssertEqual(upperLimitModel.nextOffset(for: 800), 800) + } +}