Skip to content

Commit ff2d4a4

Browse files
committed
Enable content scrolling in non-expanded states (#455)
The new `floatingPanel(_:shouldAllowToScroll)` delegate method allows the library user to determine whether the content scrolls or not in certain state. `Core.isScrollable(state:)` and `LayoutAdpter.offset(from:)` are added for this feature.
1 parent 62364eb commit ff2d4a4

File tree

6 files changed

+225
-33
lines changed

6 files changed

+225
-33
lines changed

Examples/Maps/Maps/MainViewController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ class SearchPanelPhoneDelegate: NSObject, FloatingPanelControllerDelegate, UIGes
156156
self.owner = owner
157157
}
158158

159+
func floatingPanel(_ fpc: FloatingPanelController, shouldAllowToScroll trackingScrollView: UIScrollView) -> Bool {
160+
return fpc.state == .full || fpc.state == .half
161+
}
162+
159163
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
160164
switch newCollection.verticalSizeClass {
161165
case .compact:

Examples/Samples/Sources/UseCases/UseCaseController.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,9 @@ extension UseCaseController {
322322
}
323323

324324
extension UseCaseController: FloatingPanelControllerDelegate {
325+
func floatingPanel(_ fpc: FloatingPanelController, shouldAllowToScroll trackingScrollView: UIScrollView) -> Bool {
326+
return fpc.state == .full || fpc.state == .half
327+
}
325328
func floatingPanel(_ vc: FloatingPanelController, contentOffsetForPinning trackingScrollView: UIScrollView) -> CGPoint {
326329
if useCase == .showNavigationController {
327330
// 148.0 is the SafeArea's top value for a navigation bar with a large title.

Sources/Controller.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ import os.log
9696
@objc(floatingPanel:contentOffsetForPinningScrollView:)
9797
optional
9898
func floatingPanel(_ fpc: FloatingPanelController, contentOffsetForPinning trackingScrollView: UIScrollView) -> CGPoint
99+
100+
/// Returns a Boolean value that determines whether the tracking scroll view should
101+
/// scroll or not
102+
///
103+
///
104+
/// If you return true, the scroll content scrolls when its scroll position is not
105+
/// at the top of the content. If the delegate doesn’t implement this method, its
106+
/// content can be scrolled only in the most expanded state.
107+
///
108+
/// Basically, the decision to scroll is based on the `state` property like the
109+
/// following code.
110+
/// ```swift
111+
/// func floatingPanel(_ fpc: FloatingPanelController, shouldAllowToScroll trackingScrollView: UIScrollView) -> Bool {
112+
/// return fpc.state == .full || fpc.state == .half
113+
/// }
114+
/// ```
115+
@objc(floatingPanel:shouldAllowToScroll:)
116+
optional
117+
func floatingPanel(_ fpc: FloatingPanelController, shouldAllowToScroll trackingScrollView: UIScrollView) -> Bool
99118
}
100119

101120
///
@@ -307,6 +326,13 @@ open class FloatingPanelController: UIViewController {
307326
}
308327
}
309328

329+
open override func viewDidAppear(_ animated: Bool) {
330+
super.viewDidAppear(animated)
331+
// Need to call this method just after the view appears, as the safe area is not
332+
// correctly set before this time, for example, `show(animated:completion:)`.
333+
floatingPanel.adjustScrollContentInsetIfNeeded()
334+
}
335+
310336
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
311337
super.viewWillTransition(to: size, with: coordinator)
312338

@@ -388,6 +414,7 @@ open class FloatingPanelController: UIViewController {
388414
}
389415

390416
floatingPanel.layoutAdapter.updateStaticConstraint()
417+
floatingPanel.adjustScrollContentInsetIfNeeded()
391418

392419
if let contentOffset = contentOffset {
393420
trackingScrollView?.contentOffset = contentOffset

Sources/Core.swift

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ class Core: NSObject, UIGestureRecognizerDelegate {
3737
private(set) var state: FloatingPanelState = .hidden {
3838
didSet {
3939
os_log(msg, log: devLog, type: .debug, "state changed: \(oldValue) -> \(state)")
40-
if let vc = ownerVC {
41-
vc.delegate?.floatingPanelDidChangeState?(vc)
40+
if let fpc = ownerVC {
41+
fpc.delegate?.floatingPanelDidChangeState?(fpc)
4242
}
4343
}
4444
}
@@ -120,7 +120,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
120120
completion?()
121121
return
122122
}
123-
if state != layoutAdapter.mostExpandedState {
123+
if !isScrollable(state: state) {
124124
lockScrollView()
125125
}
126126
tearDownActiveInteraction()
@@ -130,7 +130,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
130130
if animated {
131131
let updateScrollView: () -> Void = { [weak self] in
132132
guard let self = self else { return }
133-
if self.state == self.layoutAdapter.mostExpandedState, 0 == self.layoutAdapter.offsetFromMostExpandedAnchor {
133+
if self.isScrollable(state: self.state), 0 == self.layoutAdapter.offset(from: self.state) {
134134
self.unlockScrollView()
135135
} else {
136136
self.lockScrollView()
@@ -184,7 +184,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
184184
} else {
185185
self.state = to
186186
self.updateLayout(to: to)
187-
if self.state == self.layoutAdapter.mostExpandedState {
187+
if isScrollable(state: state) {
188188
self.unlockScrollView()
189189
} else {
190190
self.lockScrollView()
@@ -221,11 +221,14 @@ class Core: NSObject, UIGestureRecognizerDelegate {
221221
if let contentOffset = contentOffset {
222222
scrollView?.contentOffset = contentOffset
223223
}
224+
225+
adjustScrollContentInsetIfNeeded()
224226
}
225227

226228
private func updateLayout(to target: FloatingPanelState) {
227-
self.layoutAdapter.activateLayout(for: target, forceLayout: true)
228-
self.backdropView.alpha = self.getBackdropAlpha(for: target)
229+
layoutAdapter.activateLayout(for: target, forceLayout: true)
230+
backdropView.alpha = getBackdropAlpha(for: target)
231+
adjustScrollContentInsetIfNeeded()
229232
}
230233

231234
private func getBackdropAlpha(for target: FloatingPanelState) -> CGFloat {
@@ -326,7 +329,9 @@ class Core: NSObject, UIGestureRecognizerDelegate {
326329
if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) {
327330
return false
328331
}
329-
guard state == layoutAdapter.mostExpandedState else { return false }
332+
333+
guard isScrollable(state: state) else { return false }
334+
330335
// The condition where offset > 0 must not be included here. Because it will stop recognizing
331336
// the panel pan gesture if a user starts scrolling content from an offset greater than 0.
332337
return allowScrollPanGesture(of: scrollView) { offset in offset <= scrollBounceThreshold }
@@ -390,29 +395,41 @@ class Core: NSObject, UIGestureRecognizerDelegate {
390395
"""
391396
)
392397

393-
let offsetDiff = value(of: scrollView.contentOffset - contentOffsetForPinning(of: scrollView))
398+
let baseOffset = contentOffsetForPinning(of: scrollView)
399+
let offsetDiff = value(of: scrollView.contentOffset - baseOffset)
394400

395401
if insideMostExpandedAnchor {
396-
// Scroll offset pinning
397-
if state == layoutAdapter.mostExpandedState {
402+
// Prevent scrolling if needed
403+
if isScrollable(state: state) {
398404
if interactionInProgress {
399405
os_log(msg, log: devLog, type: .debug, "settle offset -- \(value(of: initialScrollOffset))")
406+
// Return content offset to initial offset to prevent scrolling
400407
stopScrolling(at: initialScrollOffset)
401408
} else {
402-
if surfaceView.grabberAreaContains(location) {
409+
if surfaceView.grabberAreaContains(initialLocation) {
403410
// Preserve the current content offset in moving from full.
404411
stopScrolling(at: initialScrollOffset)
405412
}
413+
/// When the scroll offset is at the pinned offset and a panel is moved, the content
414+
/// must be fixed at the pinned position without scrolling. According to the scroll
415+
/// pan gesture behavior, the content might have already scrolled a bit by the time
416+
/// this handler is called. Thus `initialScrollOffset` property is used here.
417+
if value(of: initialScrollOffset - baseOffset) == 0.0 {
418+
stopScrolling(at: initialScrollOffset)
419+
}
406420
}
407421
} else {
422+
// Return content offset to initial offset to prevent scrolling
408423
stopScrolling(at: initialScrollOffset)
409424
}
410425

411426
// Hide a scroll indicator at the non-top in dragging.
412427
if interactionInProgress {
413428
lockScrollView()
414429
} else {
415-
if state == layoutAdapter.mostExpandedState, self.transitionAnimator == nil {
430+
// Put back the scroll indicator and bounce of tracking scroll view
431+
// for scrollable states, not most expanded state.
432+
if isScrollable(state: state), self.transitionAnimator == nil {
416433
switch layoutAdapter.position {
417434
case .top, .left:
418435
if offsetDiff < 0 && velocity > 0 {
@@ -426,6 +443,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
426443
}
427444
}
428445
} else {
446+
// Here handles seamless scrolling at the most expanded position
429447
if interactionInProgress {
430448
// Show a scroll indicator at the top in dragging.
431449
switch layoutAdapter.position {
@@ -440,14 +458,14 @@ class Core: NSObject, UIGestureRecognizerDelegate {
440458
return
441459
}
442460
}
443-
if state == layoutAdapter.mostExpandedState {
461+
if isScrollable(state: state) {
444462
// Adjust a small gap of the scroll offset just after swiping down starts in the grabber area.
445463
if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation) {
446464
stopScrolling(at: initialScrollOffset)
447465
}
448466
}
449467
} else {
450-
if state == layoutAdapter.mostExpandedState {
468+
if isScrollable(state: state) {
451469
let allowScroll = allowScrollPanGesture(of: scrollView) { offset in
452470
offset <= scrollBounceThreshold || 0 < offset
453471
}
@@ -570,9 +588,9 @@ class Core: NSObject, UIGestureRecognizerDelegate {
570588
}
571589

572590
guard
573-
state == layoutAdapter.mostExpandedState, // When not top most(i.e. .full), don't scroll.
574-
interactionInProgress == false, // When interaction already in progress, don't scroll.
575-
0 == layoutAdapter.offsetFromMostExpandedAnchor
591+
isScrollable(state: state), // When not top most(i.e. .full), don't scroll.
592+
interactionInProgress == false, // When interaction already in progress, don't scroll.
593+
0 == layoutAdapter.offset(from: state)
576594
else {
577595
return false
578596
}
@@ -603,14 +621,14 @@ class Core: NSObject, UIGestureRecognizerDelegate {
603621
if offset < 0.0 {
604622
return true
605623
}
606-
if velocity >= 0 {
624+
if velocity >= 0, offset > 0.0 {
607625
return true
608626
}
609627
case .bottom, .right:
610628
if offset > 0.0 {
611629
return true
612630
}
613-
if velocity <= 0 {
631+
if velocity <= 0, offset < 0.0 {
614632
return true
615633
}
616634
}
@@ -632,13 +650,8 @@ class Core: NSObject, UIGestureRecognizerDelegate {
632650
os_log(msg, log: devLog, type: .debug, "panningBegan -- location = \(value(of: location))")
633651

634652
guard let scrollView = scrollView else { return }
635-
if state == layoutAdapter.mostExpandedState {
636-
if surfaceView.grabberAreaContains(location) {
637-
initialScrollOffset = scrollView.contentOffset
638-
}
639-
} else {
640-
initialScrollOffset = scrollView.contentOffset
641-
}
653+
654+
initialScrollOffset = scrollView.contentOffset
642655
}
643656

644657
private func panningChange(with translation: CGPoint) {
@@ -775,7 +788,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
775788
var offset: CGPoint = .zero
776789

777790
initialSurfaceLocation = layoutAdapter.surfaceLocation
778-
if state == layoutAdapter.mostExpandedState, let scrollView = scrollView {
791+
if isScrollable(state: state), let scrollView = scrollView {
779792
let scrollFrame = scrollView.convert(scrollView.bounds, to: nil)
780793
let touchStartingPoint = surfaceView.convert(initialLocation, to: nil)
781794

@@ -859,7 +872,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
859872
interactionInProgress = false
860873

861874
// Prevent to keep a scroll view indicator visible at the half/tip position
862-
if state != layoutAdapter.mostExpandedState {
875+
if !isScrollable(state: state) {
863876
lockScrollView()
864877
}
865878

@@ -949,7 +962,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
949962
""")
950963

951964
if tryUnlockScroll {
952-
if (state == layoutAdapter.mostExpandedState && 0 == layoutAdapter.offsetFromMostExpandedAnchor)
965+
if (isScrollable(state: state) && 0 == layoutAdapter.offset(from: state))
953966
|| shouldLooselyLockScrollView {
954967
unlockScrollView()
955968
}
@@ -1040,7 +1053,7 @@ class Core: NSObject, UIGestureRecognizerDelegate {
10401053
return
10411054
}
10421055
let contentOffset = scrollView.contentOffset.y
1043-
guard contentOffset < 0, layoutAdapter.position == .bottom, state == layoutAdapter.mostExpandedState else {
1056+
guard contentOffset < 0, layoutAdapter.position == .bottom, isScrollable(state: state) else {
10441057
if surfaceView.transform != .identity {
10451058
surfaceView.transform = .identity
10461059
scrollView.transform = .identity
@@ -1149,6 +1162,48 @@ class Core: NSObject, UIGestureRecognizerDelegate {
11491162
}
11501163
return condition(offset)
11511164
}
1165+
1166+
func isScrollable(state: FloatingPanelState) -> Bool {
1167+
guard let scrollView = scrollView else { return false }
1168+
if let fpc = ownerVC, let result = fpc.delegate?.floatingPanel?(fpc, shouldAllowToScroll: scrollView) {
1169+
return result
1170+
}
1171+
return state == layoutAdapter.mostExpandedState
1172+
}
1173+
1174+
/// Adjust content inset of the tracking scroll view if the controller's
1175+
/// `contentInsetAdjustmentBehavior` is `.always` and its `contentMode` is `.static`.
1176+
/// if its content is scrollable, the content might not be fully visible on `.half`
1177+
/// state, for example. Therefore the content inset needs to adjust to display the
1178+
/// full content.
1179+
func adjustScrollContentInsetIfNeeded() {
1180+
guard
1181+
let fpc = ownerVC,
1182+
let scrollView = scrollView,
1183+
fpc.contentInsetAdjustmentBehavior == .always
1184+
else { return }
1185+
1186+
switch fpc.contentMode {
1187+
case .static:
1188+
var inset = scrollView.safeAreaInsets
1189+
let offset = layoutAdapter.offsetFromMostExpandedAnchor
1190+
if offset < 0 {
1191+
switch layoutAdapter.position {
1192+
case .top:
1193+
inset.top = -offset + scrollView.safeAreaInsets.top
1194+
case .bottom:
1195+
inset.bottom = -offset + scrollView.safeAreaInsets.bottom
1196+
case .left:
1197+
inset.left = -offset + scrollView.safeAreaInsets.left
1198+
case .right:
1199+
inset.left = -offset + scrollView.safeAreaInsets.right
1200+
}
1201+
}
1202+
scrollView.contentInset = inset
1203+
case .fitToBounds:
1204+
scrollView.contentInset = scrollView.safeAreaInsets
1205+
}
1206+
}
11521207
}
11531208

11541209
/// A gesture recognizer that looks for panning (dragging) gestures in a panel.

Sources/Layout.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,16 @@ class LayoutAdapter {
266266
}
267267

268268
var offsetFromMostExpandedAnchor: CGFloat {
269+
return offset(from: mostExpandedState)
270+
}
271+
272+
func offset(from state: FloatingPanelState) -> CGFloat {
269273
let offset: CGFloat
270274
switch position {
271275
case .top, .left:
272-
offset = edgePosition(surfaceView.presentationFrame) - position(for: mostExpandedState)
276+
offset = edgePosition(surfaceView.presentationFrame) - position(for: state)
273277
case .bottom, .right:
274-
offset = position(for: mostExpandedState) - edgePosition(surfaceView.presentationFrame)
278+
offset = position(for: state) - edgePosition(surfaceView.presentationFrame)
275279
}
276280
return offset.rounded(by: surfaceView.fp_displayScale)
277281
}

0 commit comments

Comments
 (0)