Skip to content

Commit

Permalink
Merge pull request #6 from finn-no/feature/translate-with-models
Browse files Browse the repository at this point in the history
Replace translationState function with array of models
  • Loading branch information
shredlocker authored Nov 26, 2019
2 parents ccb90b6 + 457ef28 commit dec44ca
Show file tree
Hide file tree
Showing 7 changed files with 367 additions and 168 deletions.
8 changes: 8 additions & 0 deletions BottomSheet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -72,6 +74,8 @@
CF1539F42382F621001687C1 /* SpringAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpringAnimator.swift; sourceTree = "<group>"; };
CF1539F52382F621001687C1 /* BottomSheetView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetView.swift; sourceTree = "<group>"; };
CF46A3812385902100FFCB9F /* BottomSheetCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetCalculator.swift; sourceTree = "<group>"; };
DA0F4AFE238BDB7C002DE188 /* TranslationTarget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslationTarget.swift; sourceTree = "<group>"; };
DA11525323880010002E2F40 /* BottomSheetModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetModelTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -127,6 +131,7 @@
children = (
CF1539C02382F20E001687C1 /* BottomSheet.h */,
CF1539C12382F20E001687C1 /* Info.plist */,
DA0F4AFE238BDB7C002DE188 /* TranslationTarget.swift */,
CF46A3812385902100FFCB9F /* BottomSheetCalculator.swift */,
CF1539F12382F621001687C1 /* BottomSheetPresentationController.swift */,
CF1539F32382F621001687C1 /* BottomSheetTransitioningDelegate.swift */,
Expand All @@ -139,6 +144,7 @@
CF1539CA2382F20E001687C1 /* Tests */ = {
isa = PBXGroup;
children = (
DA11525323880010002E2F40 /* BottomSheetModelTests.swift */,
CF1539CB2382F20E001687C1 /* BottomSheetCalculatorTests.swift */,
CF1539CD2382F20E001687C1 /* Info.plist */,
);
Expand Down Expand Up @@ -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 */,
);
Expand All @@ -339,6 +346,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA11525523880010002E2F40 /* BottomSheetModelTests.swift in Sources */,
CF1539CC2382F20E001687C1 /* BottomSheetCalculatorTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
120 changes: 68 additions & 52 deletions Sources/BottomSheetCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
8 changes: 7 additions & 1 deletion Sources/BottomSheetPresentationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
81 changes: 53 additions & 28 deletions Sources/BottomSheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down Expand Up @@ -114,6 +115,7 @@ public final class BottomSheetView: UIView {
superview.layoutIfNeeded()
addGestureRecognizer(panGesture)

currentTargetOffsetIndex = targetIndex
updateTargetOffsets()
transition(to: targetIndex)
}
Expand All @@ -139,19 +141,27 @@ 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.
///
/// - 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)
Expand Down Expand Up @@ -192,63 +202,78 @@ 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))
}
}

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

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

Expand Down
Loading

0 comments on commit dec44ca

Please sign in to comment.