Skip to content

Commit

Permalink
[Issue 22] Fix bottom sheet height for navigation and table controlle…
Browse files Browse the repository at this point in the history
…rs (#27)

* Rename files

* Randomize test execution order

* New layout properties

* Use and test new layout height properties

* Add sizing for navigation controller and table views

* Don’t layout subviews when appearance changes

* Fix bug with clipped corners
  • Loading branch information
Mark Pospesel authored May 4, 2023
1 parent 9d0424b commit 7c61299
Show file tree
Hide file tree
Showing 19 changed files with 422 additions and 120 deletions.
3 changes: 2 additions & 1 deletion .swiftpm/xcode/xcshareddata/xcschemes/YBottomSheet.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "YBottomSheetTests"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class BottomSheetPresentAnimator: BottomSheetAnimator {
var sheetFrame = sheet.sheetView.frame
sheetFrame.origin.y = toFinalFrame.maxY + (sheet.appearance.elevation?.extent.top ?? 0)
sheet.sheetView.frame = sheetFrame
// lay out sheet's subviews prior to first appearance
sheet.sheetView.layoutIfNeeded()
sheet.updateShadow()
sheet.view.setNeedsLayout()
}

Expand Down
65 changes: 65 additions & 0 deletions Sources/YBottomSheet/BottomSheetController+Appearance+Layout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// BottomSheetController+Appearance+Layout.swift
// YBottomSheet
//
// Created by Dev Karan on 19/01/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import UIKit

extension BottomSheetController.Appearance {
/// A collection of layout properties for the `BottomSheetController`.
public struct Layout: Equatable {
/// Corner radius of bottom sheet view. Default is `16`.
public var cornerRadius: CGFloat

/// Minimum top offset of sheet from safe area top. Default is `44`.
///
/// The top of the sheet will not move beyond this gap from the top of the safe area.
public var minimumTopOffset: CGFloat

/// Maximum content height of sheet.
///
/// Only applicable for resizable sheets.
/// If `nil` a resizable sheet will be allowed to grow until it nearly fills the screen.
/// c.f. `minimumTopOffset`
public var maximumContentHeight: CGFloat?

/// Ideal content height of sheet.
///
/// Used to determine the initial size of the sheet.
/// If `nil`, the content's `instrinsicContentHeight` will be used.
public var idealContentHeight: CGFloat?

/// Minimum content height of sheet.
///
/// Only applicable for resizable sheets.
/// A resizable sheet will not be allowed to shrink its content below this value.
public var minimumContentHeight: CGFloat

/// Default layout.
public static let `default` = Layout()

// Initializes a bottom sheet layout.
/// - Parameters:
/// - cornerRadius: corner radius of bottom sheet view. Default is `16`.
/// - minimumTopOffset: minimum top offset. Default is `44`.
/// - maximumContentHeight: maximum content height of sheet. Default is `nil`.
/// - idealContentHeight: ideal content height of sheet. Default is `nil`.
/// - minimumContentHeight: minimum content height of sheet. Default is `88`.
public init(
cornerRadius: CGFloat = 16,
minimumTopOffset: CGFloat = 44,
maximumContentHeight: CGFloat? = nil,
idealContentHeight: CGFloat? = nil,
minimumContentHeight: CGFloat = 88
) {
self.cornerRadius = cornerRadius
self.minimumTopOffset = minimumTopOffset
self.maximumContentHeight = maximumContentHeight
self.idealContentHeight = idealContentHeight
self.minimumContentHeight = minimumContentHeight
}
}
}
14 changes: 0 additions & 14 deletions Sources/YBottomSheet/BottomSheetController+Appearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,6 @@ extension BottomSheetController {
public var presentAnimationCurve: UIView.AnimationOptions
/// Animation type during dismissing. Default is `curveEaseOut`.
public var dismissAnimationCurve: UIView.AnimationOptions
/// Minimum top offset of sheet from safe area top. Default is `44`.
///
/// The top of the sheet will not move beyond this gap from the top of the safe area.
public var minimumTopOffset: CGFloat
/// (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?
/// 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.
Expand All @@ -56,8 +48,6 @@ extension BottomSheetController {
/// - animationDuration: animation duration for bottom sheet. Default is `0.3`.
/// - presentAnimationCurve: animation type during presenting.
/// - dismissAnimationCurve: animation type during dismiss.
/// - minimumTopOffset: minimum top offset. Default is `44`
/// - 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,
Expand All @@ -68,8 +58,6 @@ extension BottomSheetController {
animationDuration: TimeInterval = 0.3,
presentAnimationCurve: UIView.AnimationOptions = .curveEaseIn,
dismissAnimationCurve: UIView.AnimationOptions = .curveEaseOut,
minimumTopOffset: CGFloat = 44,
minimumContentHeight: CGFloat? = nil,
isDismissAllowed: Bool = true
) {
self.indicatorAppearance = indicatorAppearance
Expand All @@ -80,8 +68,6 @@ extension BottomSheetController {
self.animationDuration = animationDuration
self.presentAnimationCurve = presentAnimationCurve
self.dismissAnimationCurve = dismissAnimationCurve
self.minimumTopOffset = minimumTopOffset
self.minimumContentHeight = minimumContentHeight
self.isDismissAllowed = isDismissAllowed
}
}
Expand Down
17 changes: 11 additions & 6 deletions Sources/YBottomSheet/BottomSheetController+build.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ private extension BottomSheetController {
if let backgroundColor = subview.backgroundColor,
backgroundColor.rgbaComponents.alpha == 1 {
// use the subview's background color for the sheet
sheetView.backgroundColor = backgroundColor
sheetContainerView.backgroundColor = backgroundColor
// but we have to set the subview's background to nil or else
// it will overflow the sheet and not be cropped by the corner radius.
subview.backgroundColor = nil
Expand Down Expand Up @@ -58,7 +58,8 @@ private extension BottomSheetController {
view.addSubview(dimmerView)
view.addSubview(dimmerTapView)
view.addSubview(sheetView)
sheetView.addSubview(stackView)
sheetView.addSubview(sheetContainerView)
sheetContainerView.addSubview(stackView)
stackView.addArrangedSubview(indicatorContainer)
stackView.addArrangedSubview(headerView)
stackView.addArrangedSubview(contentView)
Expand All @@ -68,13 +69,19 @@ private extension BottomSheetController {
dimmerView.constrainEdges()
dimmerTapView.constrainEdges(.notBottom)
dimmerTapView.constrain(.bottomAnchor, to: sheetView.topAnchor)
sheetContainerView.constrainEdges()

sheetView.constrainEdges(.notTop)
minimumTopOffsetAnchor = sheetView.constrain(
.topAnchor,
to: view.safeAreaLayoutGuide.topAnchor,
relatedBy: .greaterThanOrEqual
)
minimumContentHeightAnchor = contentView.constrain(
.heightAnchor,
relatedBy: .greaterThanOrEqual,
constant: appearance.layout.minimumContentHeight
)

indicatorView.constrain(.bottomAnchor, to: indicatorContainer.bottomAnchor)
indicatorTopAnchor = indicatorView.constrain(
Expand All @@ -83,11 +90,9 @@ private extension BottomSheetController {
)
indicatorView.constrainCenter(.x)

stackView.constrain(.topAnchor, to: sheetView.topAnchor)
stackView.constrainEdges(.top)
stackView.constrainEdges(.horizontal, to: view.safeAreaLayoutGuide)
stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide, relatedBy: .greaterThanOrEqual)
stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide, priority: Priorities.sheetContentHugging)

stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide)
contentView.constrainEdges(.horizontal)
headerView.constrainEdges(.horizontal)
}
Expand Down
27 changes: 0 additions & 27 deletions Sources/YBottomSheet/BottomSheetController.Appearance+Layout.swift

This file was deleted.

97 changes: 57 additions & 40 deletions Sources/YBottomSheet/BottomSheetController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,17 @@ import UIKit
public class BottomSheetController: UIViewController {
internal let content: Content
private var shadowSize: CGSize = .zero

internal var minimumTopOffsetAnchor: NSLayoutConstraint?
private var topAnchor: NSLayoutConstraint?
internal var indicatorTopAnchor: NSLayoutConstraint?
private var childHeightAnchor: NSLayoutConstraint?
private var maximumContentHeightAnchor: NSLayoutConstraint?
internal var idealContentHeightAnchor: NSLayoutConstraint?
internal var minimumContentHeightAnchor: NSLayoutConstraint?

private var panGesture: UIPanGestureRecognizer?
internal lazy var lastYOffset: CGFloat = { sheetView.frame.origin.y }()

/// Absolute minimum height of sheet content
///
/// Only used when `appearance.minimumContentHeight == nil`.
public var minimumContentHeight: CGFloat = 88 {
didSet {
updateChildView()
}
}

/// Minimum downward velocity beyond which we interpret a pan gesture as a downward swipe.
public var dismissThresholdVelocity: CGFloat = 1000

Expand All @@ -42,9 +37,12 @@ public class BottomSheetController: UIViewController {
/// Dimmer view.
let dimmerView = UIView()
/// Bottom sheet view.
let sheetView: UIView = {
let sheetView = UIView()
/// Bottom sheet container view.
let sheetContainerView: UIView = {
let view = UIView()
view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
view.clipsToBounds = true
view.backgroundColor = .systemBackground
return view
}()
Expand All @@ -55,11 +53,7 @@ public class BottomSheetController: UIViewController {
/// Bottom sheet header view.
public internal(set) var headerView: SheetHeaderView!
/// Holds the sheet's child content (view or view controller).
let contentView: UIView = {
let view = UIView()
view.clipsToBounds = true
return view
}()
let contentView = UIView()

/// Comprises the indicator view, the header view, and the content view.
let stackView: UIStackView = {
Expand Down Expand Up @@ -150,7 +144,7 @@ public class BottomSheetController: UIViewController {

guard shadowSize != sheetView.bounds.size else { return }
updateShadow()
shadowSize = contentView.bounds.size
shadowSize = sheetView.bounds.size
}

/// Performing the accessibility escape gesture dismisses the bottom sheet.
Expand All @@ -172,14 +166,13 @@ public class BottomSheetController: UIViewController {
internal extension BottomSheetController {
func updateViewAppearance() {
dimmerTapView.isAccessibilityElement = appearance.isDismissAllowed
sheetView.layer.cornerRadius = appearance.layout.cornerRadius
minimumTopOffsetAnchor?.constant = appearance.minimumTopOffset
sheetContainerView.layer.cornerRadius = appearance.layout.cornerRadius
minimumTopOffsetAnchor?.constant = appearance.layout.minimumTopOffset
updateShadow()
dimmerView.backgroundColor = appearance.dimmerColor
updateIndicatorView()
updateHeaderView()
updateChildView()
view.layoutIfNeeded()
}

func addGestures() {
Expand All @@ -189,6 +182,19 @@ internal extension BottomSheetController {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onDimmerTap))
dimmerTapView.addGestureRecognizer(tapGesture)
}

var childContentSize: CGSize {
switch content {
case .view(_, let view):
return view.layoutSize
case .controller(let viewController):
return viewController.layoutSize
}
}

func updateShadow() {
appearance.elevation?.apply(layer: sheetView.layer, cornerRadius: appearance.layout.cornerRadius)
}
}

private extension BottomSheetController {
Expand Down Expand Up @@ -226,33 +232,44 @@ private extension BottomSheetController {
func updateChildView() {
guard let childView = contentView.subviews.first else { return }

let height: CGFloat
let priority: UILayoutPriority

if let minimum = appearance.minimumContentHeight {
// If a minimum is specified, we make the sheet relatively easy to compress
// and enforce that specified minimum.
height = minimum
priority = Priorities.sheetCompressionResistanceLow
// Enforce maximum height (if any)
if let maximum = appearance.layout.maximumContentHeight {
if let maximumContentHeightAnchor = maximumContentHeightAnchor {
maximumContentHeightAnchor.constant = maximum
} else {
maximumContentHeightAnchor = childView.constrain(
.heightAnchor,
relatedBy: .lessThanOrEqual,
constant: maximum
)
}
} else {
// If no minimumContentHeight is specified, we make the sheet difficult
// to compress beyond intrinsicContentSize.height and enforce an absolute minimum.
height = minimumContentHeight // absolute minimum
priority = Priorities.sheetCompressionResistanceHigh
maximumContentHeightAnchor?.isActive = false
}

childView.setContentCompressionResistancePriority(priority, for: .vertical)
if let anchor = childHeightAnchor {
anchor.constant = height
// Enforce ideal height (if any)
// (otherwise sheet height defaults to childView.instrinsicContentSize.height)
let idealHeight = appearance.layout.idealContentHeight ?? childContentSize.height
if idealHeight > 0.0 {
if let idealContentHeightAnchor = idealContentHeightAnchor {
idealContentHeightAnchor.constant = idealHeight
} else {
idealContentHeightAnchor = childView.constrain(
.heightAnchor,
constant: idealHeight,
priority: Priorities.idealContentSize
)
}
} else {
childHeightAnchor = childView.constrain(.heightAnchor, relatedBy: .greaterThanOrEqual, constant: height)
idealContentHeightAnchor?.isActive = false
}

// Enforce minimum height
minimumContentHeightAnchor?.constant = appearance.layout.minimumContentHeight

childView.setContentCompressionResistancePriority(Priorities.sheetCompressionResistance, for: .vertical)
}

func updateShadow() {
appearance.elevation?.apply(layer: sheetView.layer)
}

func onDismiss() {
dismiss(animated: true)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// DragIndicatorView.Appearance+Layout.swift
// DragIndicatorView+Appearance+Layout.swift
// YBottomSheet
//
// Created by Dev Karan on 05/01/23.
Expand Down
5 changes: 2 additions & 3 deletions Sources/YBottomSheet/Enums/BottomSheetController+Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ internal extension BottomSheetController {
/// Priorities for various non-required constraints.
enum Priorities {
static let panGesture = UILayoutPriority(775)
static let sheetContentHugging = UILayoutPriority(751)
static let sheetCompressionResistanceLow = UILayoutPriority.defaultLow
static let sheetCompressionResistanceHigh = UILayoutPriority(800)
static let idealContentSize = UILayoutPriority(251)
static let sheetCompressionResistance = UILayoutPriority.defaultLow
}

/// Types of content that can populate a bottom sheet
Expand Down
Loading

0 comments on commit 7c61299

Please sign in to comment.