Skip to content

Commit 7c61299

Browse files
author
Mark Pospesel
authored
[Issue 22] Fix bottom sheet height for navigation and table controllers (#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
1 parent 9d0424b commit 7c61299

19 files changed

+422
-120
lines changed

.swiftpm/xcode/xcshareddata/xcschemes/YBottomSheet.xcscheme

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
codeCoverageEnabled = "YES">
5959
<Testables>
6060
<TestableReference
61-
skipped = "NO">
61+
skipped = "NO"
62+
testExecutionOrdering = "random">
6263
<BuildableReference
6364
BuildableIdentifier = "primary"
6465
BlueprintIdentifier = "YBottomSheetTests"

Sources/YBottomSheet/Animation/BottomSheetPresentAnimator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class BottomSheetPresentAnimator: BottomSheetAnimator {
2828
var sheetFrame = sheet.sheetView.frame
2929
sheetFrame.origin.y = toFinalFrame.maxY + (sheet.appearance.elevation?.extent.top ?? 0)
3030
sheet.sheetView.frame = sheetFrame
31+
// lay out sheet's subviews prior to first appearance
32+
sheet.sheetView.layoutIfNeeded()
33+
sheet.updateShadow()
3134
sheet.view.setNeedsLayout()
3235
}
3336

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//
2+
// BottomSheetController+Appearance+Layout.swift
3+
// YBottomSheet
4+
//
5+
// Created by Dev Karan on 19/01/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import UIKit
10+
11+
extension BottomSheetController.Appearance {
12+
/// A collection of layout properties for the `BottomSheetController`.
13+
public struct Layout: Equatable {
14+
/// Corner radius of bottom sheet view. Default is `16`.
15+
public var cornerRadius: CGFloat
16+
17+
/// Minimum top offset of sheet from safe area top. Default is `44`.
18+
///
19+
/// The top of the sheet will not move beyond this gap from the top of the safe area.
20+
public var minimumTopOffset: CGFloat
21+
22+
/// Maximum content height of sheet.
23+
///
24+
/// Only applicable for resizable sheets.
25+
/// If `nil` a resizable sheet will be allowed to grow until it nearly fills the screen.
26+
/// c.f. `minimumTopOffset`
27+
public var maximumContentHeight: CGFloat?
28+
29+
/// Ideal content height of sheet.
30+
///
31+
/// Used to determine the initial size of the sheet.
32+
/// If `nil`, the content's `instrinsicContentHeight` will be used.
33+
public var idealContentHeight: CGFloat?
34+
35+
/// Minimum content height of sheet.
36+
///
37+
/// Only applicable for resizable sheets.
38+
/// A resizable sheet will not be allowed to shrink its content below this value.
39+
public var minimumContentHeight: CGFloat
40+
41+
/// Default layout.
42+
public static let `default` = Layout()
43+
44+
// Initializes a bottom sheet layout.
45+
/// - Parameters:
46+
/// - cornerRadius: corner radius of bottom sheet view. Default is `16`.
47+
/// - minimumTopOffset: minimum top offset. Default is `44`.
48+
/// - maximumContentHeight: maximum content height of sheet. Default is `nil`.
49+
/// - idealContentHeight: ideal content height of sheet. Default is `nil`.
50+
/// - minimumContentHeight: minimum content height of sheet. Default is `88`.
51+
public init(
52+
cornerRadius: CGFloat = 16,
53+
minimumTopOffset: CGFloat = 44,
54+
maximumContentHeight: CGFloat? = nil,
55+
idealContentHeight: CGFloat? = nil,
56+
minimumContentHeight: CGFloat = 88
57+
) {
58+
self.cornerRadius = cornerRadius
59+
self.minimumTopOffset = minimumTopOffset
60+
self.maximumContentHeight = maximumContentHeight
61+
self.idealContentHeight = idealContentHeight
62+
self.minimumContentHeight = minimumContentHeight
63+
}
64+
}
65+
}

Sources/YBottomSheet/BottomSheetController+Appearance.swift

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ extension BottomSheetController {
2828
public var presentAnimationCurve: UIView.AnimationOptions
2929
/// Animation type during dismissing. Default is `curveEaseOut`.
3030
public var dismissAnimationCurve: UIView.AnimationOptions
31-
/// Minimum top offset of sheet from safe area top. Default is `44`.
32-
///
33-
/// The top of the sheet will not move beyond this gap from the top of the safe area.
34-
public var minimumTopOffset: CGFloat
35-
/// (Optional) Minimum content view height. Default is `nil`.
36-
///
37-
/// Only applicable for resizable sheets. `nil` means to use the content view's intrinsic height as the minimum.
38-
public var minimumContentHeight: CGFloat?
3931
/// Whether the sheet can be dismissed by swiping down or tapping on the dimmer. Default is `true`.
4032
///
4133
/// The user can always dismiss the sheet from the close button if it is visible.
@@ -56,8 +48,6 @@ extension BottomSheetController {
5648
/// - animationDuration: animation duration for bottom sheet. Default is `0.3`.
5749
/// - presentAnimationCurve: animation type during presenting.
5850
/// - dismissAnimationCurve: animation type during dismiss.
59-
/// - minimumTopOffset: minimum top offset. Default is `44`
60-
/// - minimumContentHeight: (optional) minimum content view height.
6151
/// - isDismissAllowed: whether the sheet can be dismissed by swiping down or tapping on the dimmer.
6252
public init(
6353
indicatorAppearance: DragIndicatorView.Appearance? = nil,
@@ -68,8 +58,6 @@ extension BottomSheetController {
6858
animationDuration: TimeInterval = 0.3,
6959
presentAnimationCurve: UIView.AnimationOptions = .curveEaseIn,
7060
dismissAnimationCurve: UIView.AnimationOptions = .curveEaseOut,
71-
minimumTopOffset: CGFloat = 44,
72-
minimumContentHeight: CGFloat? = nil,
7361
isDismissAllowed: Bool = true
7462
) {
7563
self.indicatorAppearance = indicatorAppearance
@@ -80,8 +68,6 @@ extension BottomSheetController {
8068
self.animationDuration = animationDuration
8169
self.presentAnimationCurve = presentAnimationCurve
8270
self.dismissAnimationCurve = dismissAnimationCurve
83-
self.minimumTopOffset = minimumTopOffset
84-
self.minimumContentHeight = minimumContentHeight
8571
self.isDismissAllowed = isDismissAllowed
8672
}
8773
}

Sources/YBottomSheet/BottomSheetController+build.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ private extension BottomSheetController {
2727
if let backgroundColor = subview.backgroundColor,
2828
backgroundColor.rgbaComponents.alpha == 1 {
2929
// use the subview's background color for the sheet
30-
sheetView.backgroundColor = backgroundColor
30+
sheetContainerView.backgroundColor = backgroundColor
3131
// but we have to set the subview's background to nil or else
3232
// it will overflow the sheet and not be cropped by the corner radius.
3333
subview.backgroundColor = nil
@@ -58,7 +58,8 @@ private extension BottomSheetController {
5858
view.addSubview(dimmerView)
5959
view.addSubview(dimmerTapView)
6060
view.addSubview(sheetView)
61-
sheetView.addSubview(stackView)
61+
sheetView.addSubview(sheetContainerView)
62+
sheetContainerView.addSubview(stackView)
6263
stackView.addArrangedSubview(indicatorContainer)
6364
stackView.addArrangedSubview(headerView)
6465
stackView.addArrangedSubview(contentView)
@@ -68,13 +69,19 @@ private extension BottomSheetController {
6869
dimmerView.constrainEdges()
6970
dimmerTapView.constrainEdges(.notBottom)
7071
dimmerTapView.constrain(.bottomAnchor, to: sheetView.topAnchor)
72+
sheetContainerView.constrainEdges()
7173

7274
sheetView.constrainEdges(.notTop)
7375
minimumTopOffsetAnchor = sheetView.constrain(
7476
.topAnchor,
7577
to: view.safeAreaLayoutGuide.topAnchor,
7678
relatedBy: .greaterThanOrEqual
7779
)
80+
minimumContentHeightAnchor = contentView.constrain(
81+
.heightAnchor,
82+
relatedBy: .greaterThanOrEqual,
83+
constant: appearance.layout.minimumContentHeight
84+
)
7885

7986
indicatorView.constrain(.bottomAnchor, to: indicatorContainer.bottomAnchor)
8087
indicatorTopAnchor = indicatorView.constrain(
@@ -83,11 +90,9 @@ private extension BottomSheetController {
8390
)
8491
indicatorView.constrainCenter(.x)
8592

86-
stackView.constrain(.topAnchor, to: sheetView.topAnchor)
93+
stackView.constrainEdges(.top)
8794
stackView.constrainEdges(.horizontal, to: view.safeAreaLayoutGuide)
88-
stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide, relatedBy: .greaterThanOrEqual)
89-
stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide, priority: Priorities.sheetContentHugging)
90-
95+
stackView.constrainEdges(.bottom, to: view.safeAreaLayoutGuide)
9196
contentView.constrainEdges(.horizontal)
9297
headerView.constrainEdges(.horizontal)
9398
}

Sources/YBottomSheet/BottomSheetController.Appearance+Layout.swift

Lines changed: 0 additions & 27 deletions
This file was deleted.

Sources/YBottomSheet/BottomSheetController.swift

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,17 @@ import UIKit
1212
public class BottomSheetController: UIViewController {
1313
internal let content: Content
1414
private var shadowSize: CGSize = .zero
15+
1516
internal var minimumTopOffsetAnchor: NSLayoutConstraint?
1617
private var topAnchor: NSLayoutConstraint?
1718
internal var indicatorTopAnchor: NSLayoutConstraint?
18-
private var childHeightAnchor: NSLayoutConstraint?
19+
private var maximumContentHeightAnchor: NSLayoutConstraint?
20+
internal var idealContentHeightAnchor: NSLayoutConstraint?
21+
internal var minimumContentHeightAnchor: NSLayoutConstraint?
22+
1923
private var panGesture: UIPanGestureRecognizer?
2024
internal lazy var lastYOffset: CGFloat = { sheetView.frame.origin.y }()
2125

22-
/// Absolute minimum height of sheet content
23-
///
24-
/// Only used when `appearance.minimumContentHeight == nil`.
25-
public var minimumContentHeight: CGFloat = 88 {
26-
didSet {
27-
updateChildView()
28-
}
29-
}
30-
3126
/// Minimum downward velocity beyond which we interpret a pan gesture as a downward swipe.
3227
public var dismissThresholdVelocity: CGFloat = 1000
3328

@@ -42,9 +37,12 @@ public class BottomSheetController: UIViewController {
4237
/// Dimmer view.
4338
let dimmerView = UIView()
4439
/// Bottom sheet view.
45-
let sheetView: UIView = {
40+
let sheetView = UIView()
41+
/// Bottom sheet container view.
42+
let sheetContainerView: UIView = {
4643
let view = UIView()
4744
view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
45+
view.clipsToBounds = true
4846
view.backgroundColor = .systemBackground
4947
return view
5048
}()
@@ -55,11 +53,7 @@ public class BottomSheetController: UIViewController {
5553
/// Bottom sheet header view.
5654
public internal(set) var headerView: SheetHeaderView!
5755
/// Holds the sheet's child content (view or view controller).
58-
let contentView: UIView = {
59-
let view = UIView()
60-
view.clipsToBounds = true
61-
return view
62-
}()
56+
let contentView = UIView()
6357

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

151145
guard shadowSize != sheetView.bounds.size else { return }
152146
updateShadow()
153-
shadowSize = contentView.bounds.size
147+
shadowSize = sheetView.bounds.size
154148
}
155149

156150
/// Performing the accessibility escape gesture dismisses the bottom sheet.
@@ -172,14 +166,13 @@ public class BottomSheetController: UIViewController {
172166
internal extension BottomSheetController {
173167
func updateViewAppearance() {
174168
dimmerTapView.isAccessibilityElement = appearance.isDismissAllowed
175-
sheetView.layer.cornerRadius = appearance.layout.cornerRadius
176-
minimumTopOffsetAnchor?.constant = appearance.minimumTopOffset
169+
sheetContainerView.layer.cornerRadius = appearance.layout.cornerRadius
170+
minimumTopOffsetAnchor?.constant = appearance.layout.minimumTopOffset
177171
updateShadow()
178172
dimmerView.backgroundColor = appearance.dimmerColor
179173
updateIndicatorView()
180174
updateHeaderView()
181175
updateChildView()
182-
view.layoutIfNeeded()
183176
}
184177

185178
func addGestures() {
@@ -189,6 +182,19 @@ internal extension BottomSheetController {
189182
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onDimmerTap))
190183
dimmerTapView.addGestureRecognizer(tapGesture)
191184
}
185+
186+
var childContentSize: CGSize {
187+
switch content {
188+
case .view(_, let view):
189+
return view.layoutSize
190+
case .controller(let viewController):
191+
return viewController.layoutSize
192+
}
193+
}
194+
195+
func updateShadow() {
196+
appearance.elevation?.apply(layer: sheetView.layer, cornerRadius: appearance.layout.cornerRadius)
197+
}
192198
}
193199

194200
private extension BottomSheetController {
@@ -226,33 +232,44 @@ private extension BottomSheetController {
226232
func updateChildView() {
227233
guard let childView = contentView.subviews.first else { return }
228234

229-
let height: CGFloat
230-
let priority: UILayoutPriority
231-
232-
if let minimum = appearance.minimumContentHeight {
233-
// If a minimum is specified, we make the sheet relatively easy to compress
234-
// and enforce that specified minimum.
235-
height = minimum
236-
priority = Priorities.sheetCompressionResistanceLow
235+
// Enforce maximum height (if any)
236+
if let maximum = appearance.layout.maximumContentHeight {
237+
if let maximumContentHeightAnchor = maximumContentHeightAnchor {
238+
maximumContentHeightAnchor.constant = maximum
239+
} else {
240+
maximumContentHeightAnchor = childView.constrain(
241+
.heightAnchor,
242+
relatedBy: .lessThanOrEqual,
243+
constant: maximum
244+
)
245+
}
237246
} else {
238-
// If no minimumContentHeight is specified, we make the sheet difficult
239-
// to compress beyond intrinsicContentSize.height and enforce an absolute minimum.
240-
height = minimumContentHeight // absolute minimum
241-
priority = Priorities.sheetCompressionResistanceHigh
247+
maximumContentHeightAnchor?.isActive = false
242248
}
243249

244-
childView.setContentCompressionResistancePriority(priority, for: .vertical)
245-
if let anchor = childHeightAnchor {
246-
anchor.constant = height
250+
// Enforce ideal height (if any)
251+
// (otherwise sheet height defaults to childView.instrinsicContentSize.height)
252+
let idealHeight = appearance.layout.idealContentHeight ?? childContentSize.height
253+
if idealHeight > 0.0 {
254+
if let idealContentHeightAnchor = idealContentHeightAnchor {
255+
idealContentHeightAnchor.constant = idealHeight
256+
} else {
257+
idealContentHeightAnchor = childView.constrain(
258+
.heightAnchor,
259+
constant: idealHeight,
260+
priority: Priorities.idealContentSize
261+
)
262+
}
247263
} else {
248-
childHeightAnchor = childView.constrain(.heightAnchor, relatedBy: .greaterThanOrEqual, constant: height)
264+
idealContentHeightAnchor?.isActive = false
249265
}
266+
267+
// Enforce minimum height
268+
minimumContentHeightAnchor?.constant = appearance.layout.minimumContentHeight
269+
270+
childView.setContentCompressionResistancePriority(Priorities.sheetCompressionResistance, for: .vertical)
250271
}
251272

252-
func updateShadow() {
253-
appearance.elevation?.apply(layer: sheetView.layer)
254-
}
255-
256273
func onDismiss() {
257274
dismiss(animated: true)
258275
}

Sources/YBottomSheet/DragIndicatorView/DragIndicatorView.Appearance+Layout.swift renamed to Sources/YBottomSheet/DragIndicatorView/DragIndicatorView+Appearance+Layout.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// DragIndicatorView.Appearance+Layout.swift
2+
// DragIndicatorView+Appearance+Layout.swift
33
// YBottomSheet
44
//
55
// Created by Dev Karan on 05/01/23.

Sources/YBottomSheet/Enums/BottomSheetController+Enums.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ internal extension BottomSheetController {
1212
/// Priorities for various non-required constraints.
1313
enum Priorities {
1414
static let panGesture = UILayoutPriority(775)
15-
static let sheetContentHugging = UILayoutPriority(751)
16-
static let sheetCompressionResistanceLow = UILayoutPriority.defaultLow
17-
static let sheetCompressionResistanceHigh = UILayoutPriority(800)
15+
static let idealContentSize = UILayoutPriority(251)
16+
static let sheetCompressionResistance = UILayoutPriority.defaultLow
1817
}
1918

2019
/// Types of content that can populate a bottom sheet

0 commit comments

Comments
 (0)