Skip to content

Commit e8fdd20

Browse files
Adding peek support to PagedListLayout (#560)
- `PagedListLayout` now supports a peek value, which allows items to peek into view from the leading/top and trailing/bottom edges. - The peek can be disabled, uniform on both sides, or have a unique first item leading peek. - The layout's `PagingSize` now supports `inset(Peek)` and `fixed(CGFloat)` page sizes. - Replacing the `isPagingEnabled` boolean with a `PagingStyle` enum. This gives layouts the ability to disable paging, leverage `native` UIScrollView paging (where items are full width), or use `custom` paging (where items can be any width). - `ListScrollPositionInfo` now contains the percentage of visibility for each visible item. This allows clients to pick the most visible item for selection.
1 parent a59fe52 commit e8fdd20

23 files changed

+615
-122
lines changed

CHANGELOG.md

+13
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,27 @@
44

55
### Added
66

7+
- `PagedListLayout` now supports a peek value, which allows items to peek into view from the leading/top and trailing/bottom edges.
8+
- Adding a `mostVisibleItem` property to `ListScrollPositionInfo`.
9+
710
### Removed
811

912
### Changed
1013

14+
- Replacing the `isPagingEnabled` boolean with a `PageScrollingBehavior` enum. This gives layouts the ability to:
15+
- leverage `full` page scrolling, where items are full width (equivalent to `isPagingEnabled` being `true`)
16+
- use `peek` page scrolling, where items can be less than the full width and peek into view (this is new functionality)
17+
- disable page scrolling via `none` (equivalent to `isPagingEnabled` being `false`)
18+
- `ListScrollPositionInfo`'s `visibleItems` is now a set of `VisibleItem` models, which contains a `percentageVisible` property.
19+
1120
### Misc
1221

22+
- `PagedListLayout.pagingBehavior` is now configurable. This can be used to keep the items centered when peeking.
23+
1324
### Internal
1425

26+
- Replacing `PagedAppearance.PagingSize.view` with a `.inset(Peek)` case. This is used by `PagedListLayout` to lay out items with an edge peek.
27+
1528
# Past Releases
1629

1730
# [15.0.2] - 2025-04-02

Demo/Demo.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
397F72040272B0B12CAD9AEE /* Pods_Test_Targets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF78F98F7ED38C2DC8078110 /* Pods_Test_Targets.framework */; };
5656
8ECEBF6228B7E4C200ECEC56 /* CenterSnappingTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ECEBF6128B7E4C200ECEC56 /* CenterSnappingTableViewController.swift */; };
5757
B223A33A769988911ED5C0E1 /* Pods_Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C03134338F55F7FD12551D /* Pods_Demo.framework */; };
58+
CE03D84F2D88584E009F922B /* PeekingPagedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE03D84E2D88584E009F922B /* PeekingPagedViewController.swift */; };
5859
/* End PBXBuildFile section */
5960

6061
/* Begin PBXFileReference section */
@@ -113,6 +114,7 @@
113114
A1C03134338F55F7FD12551D /* Pods_Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; };
114115
C55FB4CC847A4E4F271441F7 /* Pods-Test Targets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Test Targets.debug.xcconfig"; path = "Target Support Files/Pods-Test Targets/Pods-Test Targets.debug.xcconfig"; sourceTree = "<group>"; };
115116
C9EC890C0D683F6A704AB9ED /* Pods-Demo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Demo.debug.xcconfig"; path = "Target Support Files/Pods-Demo/Pods-Demo.debug.xcconfig"; sourceTree = "<group>"; };
117+
CE03D84E2D88584E009F922B /* PeekingPagedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeekingPagedViewController.swift; sourceTree = "<group>"; };
116118
CF78F98F7ED38C2DC8078110 /* Pods_Test_Targets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Test_Targets.framework; sourceTree = BUILT_PRODUCTS_DIR; };
117119
E0E11D5AA6E2508505BEE4FC /* Pods-Test Targets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Test Targets.release.xcconfig"; path = "Target Support Files/Pods-Test Targets/Pods-Test Targets.release.xcconfig"; sourceTree = "<group>"; };
118120
/* End PBXFileReference section */
@@ -176,6 +178,7 @@
176178
0A7759D628FD923B00FD7C2A /* UpdateFuzzingViewController.swift */,
177179
0AD6767925423BE500A49315 /* MultiSelectViewController.swift */,
178180
0AC2A1952489F93E00779459 /* PagedViewController.swift */,
181+
CE03D84E2D88584E009F922B /* PeekingPagedViewController.swift */,
179182
0AA4D9B7248064A300CF95A5 /* ReorderingViewController.swift */,
180183
0A02B2F32675E14600B3501B /* PaymentTypesViewController.swift */,
181184
1F46FB6A26010F4900760961 /* RefreshControlOffsetAdjustmentViewController.swift */,
@@ -485,6 +488,7 @@
485488
2B1B39902A706B3D00D614F1 /* ChatDemoViewController.swift in Sources */,
486489
0A793B5824E4B53500850139 /* ManualSelectionManagementViewController.swift in Sources */,
487490
8ECEBF6228B7E4C200ECEC56 /* CenterSnappingTableViewController.swift in Sources */,
491+
CE03D84F2D88584E009F922B /* PeekingPagedViewController.swift in Sources */,
488492
0AC839A525EEAD110055CEF5 /* OnTapItemAnimationViewController.swift in Sources */,
489493
0AA4D9C9248064A300CF95A5 /* CollectionViewBasicDemoViewController.swift in Sources */,
490494
0AA4D9BC248064A300CF95A5 /* KeyboardTestingViewController.swift in Sources */,

Demo/Sources/Demos/Demo Screens/AutoScrollingViewController.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ final class AutoScrollingViewController : UIViewController
7676
shouldPerform: { info in
7777
// Only scroll to the bottom if the bottom item is already visible.
7878
if let identifier = lastItem {
79-
return info.visibleItems.contains(identifier)
79+
return info.visibleItems.map(\.identifier).contains(identifier)
8080
} else {
8181
return false
8282
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import BlueprintUILists
2+
import BlueprintUICommonControls
3+
4+
5+
/// This demo showcases a `PagedListLayout` with peeking leading/top and trailing/bottom items.
6+
final class PeekingPagedViewController : UIViewController {
7+
8+
let blueprintView = BlueprintView()
9+
10+
let listActions = ListActions()
11+
12+
var isVertical : Bool = false
13+
14+
/// When `true`, the first item's leading peek is 0. When `false` the peek is uniform.
15+
var zeroLeadingPeek : Bool = false
16+
17+
override func loadView() {
18+
view = self.blueprintView
19+
navigationItem.rightBarButtonItems = [
20+
UIBarButtonItem(title: "Toggle Direction", style: .plain, target: self, action: #selector(toggleDirection)),
21+
UIBarButtonItem(title: "Toggle First Peek", style: .plain, target: self, action: #selector(toggleFirstPeek))
22+
]
23+
}
24+
25+
override func viewWillAppear(_ animated: Bool) {
26+
super.viewWillAppear(animated)
27+
28+
view.layoutIfNeeded()
29+
// Ensure the correct peek is used before the view appears.
30+
update()
31+
}
32+
33+
// When the size changes, update the demo peek.
34+
override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
35+
coordinator.animate { _ in
36+
// For demo purposes, reset the scroll position when the size changes.
37+
self.listActions.scrolling.scrollToTop()
38+
} completion: { _ in
39+
// Once the view is resized, update the peek/item size.
40+
self.update()
41+
}
42+
}
43+
44+
func update() {
45+
blueprintView.element = List { list in
46+
list.actions = listActions
47+
list.behavior.decelerationRate = .fast
48+
list.layout = .paged {
49+
$0.direction = isVertical ? .vertical : .horizontal
50+
$0.pagingBehavior = .firstVisibleItemCentered
51+
$0.peek = PagedAppearance.Peek(
52+
value: (isVertical ? view.bounds.height : view.bounds.width) / 6,
53+
firstItemConfiguration: zeroLeadingPeek ? .customLeading(0) : .uniform
54+
)
55+
}
56+
} sections: {
57+
Section("first") {
58+
DemoElement(color: .red)
59+
DemoElement(color: .orange)
60+
DemoElement(color: .yellow)
61+
DemoElement(color: .green)
62+
DemoElement(color: .blue)
63+
}
64+
}
65+
}
66+
67+
@objc func toggleFirstPeek() {
68+
zeroLeadingPeek.toggle()
69+
update()
70+
}
71+
72+
@objc func toggleDirection() {
73+
isVertical.toggle()
74+
update()
75+
}
76+
}
77+
78+
fileprivate struct DemoElement : BlueprintItemContent, Equatable {
79+
var identifierValue: UIColor {
80+
color
81+
}
82+
83+
var color : UIColor
84+
85+
func element(with info: ApplyItemContentInfo) -> Element {
86+
Box(backgroundColor: color)
87+
}
88+
}

Demo/Sources/Demos/DemosRootViewController.swift

+8
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ public final class DemosRootViewController : ListViewController
258258
self?.push(PagedViewController())
259259
}
260260
)
261+
262+
Item(
263+
DemoItem(text: "Peeking Paged Layout"),
264+
selectionStyle: .selectable(),
265+
onSelect : { _ in
266+
self?.push(PeekingPagedViewController())
267+
}
268+
)
261269

262270
Item(
263271
DemoItem(text: "Center-Snapping Table Layout"),

ListableUI/Sources/Behavior.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public struct Behavior : Equatable
3939
/// A Boolean value that determines whether the scroll view delays the handling of touch-down gestures.
4040
public var delaysContentTouches : Bool
4141

42-
/// Is paging enabled on the underlying scroll view.
43-
public var isPagingEnabled : Bool
42+
/// The page scrolling behavior of the underlying scroll view. When `.none`, no paging is performed.
43+
public var pageScrollingBehavior : PageScrollingBehavior
4444

4545
/// The rate at which scrolling decelerates.
4646
public var decelerationRate: DecelerationRate
@@ -59,7 +59,7 @@ public struct Behavior : Equatable
5959
underflow : Underflow = Underflow(),
6060
canCancelContentTouches : Bool = true,
6161
delaysContentTouches : Bool = true,
62-
isPagingEnabled : Bool = false,
62+
pageScrollingBehavior : PageScrollingBehavior = .none,
6363
decelerationRate : DecelerationRate = .normal,
6464
verticalLayoutGravity : VerticalLayoutGravity = .top
6565
) {
@@ -74,7 +74,7 @@ public struct Behavior : Equatable
7474

7575
self.canCancelContentTouches = canCancelContentTouches
7676
self.delaysContentTouches = delaysContentTouches
77-
self.isPagingEnabled = false
77+
self.pageScrollingBehavior = pageScrollingBehavior
7878
self.decelerationRate = decelerationRate
7979

8080
self.verticalLayoutGravity = verticalLayoutGravity
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Foundation
2+
3+
extension CGRect {
4+
5+
/// Returns the percentage from `0.0` to `1.0` that this rect overlaps `container`.
6+
func percentageVisible(inside container: CGRect) -> CGFloat {
7+
let overlap = intersection(container)
8+
let area = (width * height)
9+
guard area != 0 else { return 0 }
10+
return (overlap.width * overlap.height) / area
11+
}
12+
}

ListableUI/Sources/Layout/Flow/FlowListLayout.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public struct FlowAppearance : ListLayoutAppearance {
118118
/// The properties of the backing `UIScrollView`.
119119
public var scrollViewProperties: ListLayoutScrollViewProperties {
120120
.init(
121-
isPagingEnabled: false,
121+
pageScrollingBehavior: .none,
122122
contentInsetAdjustmentBehavior: .scrollableAxes,
123123
allowsBounceVertical: true,
124124
allowsBounceHorizontal: true,

ListableUI/Sources/Layout/ListLayout/ListLayout.swift

+72-25
Original file line numberDiff line numberDiff line change
@@ -459,18 +459,20 @@ extension AnyListLayout
459459
func onDidEndDraggingTargetContentOffset(
460460
for targetContentOffset : CGPoint,
461461
velocity : CGPoint,
462-
visibleContentSize: CGSize
462+
visibleContentFrame: CGRect
463463
) -> CGPoint?
464464
{
465465
guard self.pagingBehavior != .none else { return nil }
466-
466+
467467
guard let item = self.itemToScrollToOnDidEndDragging(
468468
after: targetContentOffset,
469-
velocity: velocity
469+
velocity: velocity,
470+
visibleContentFrame: visibleContentFrame
470471
) else {
471472
return nil
472473
}
473474

475+
let visibleContentSize = visibleContentFrame.size
474476
let padding = self.bounds?.padding ?? .zero
475477

476478
switch self.pagingBehavior {
@@ -493,39 +495,84 @@ extension AnyListLayout
493495

494496
func itemToScrollToOnDidEndDragging(
495497
after contentOffset : CGPoint,
496-
velocity : CGPoint
498+
velocity : CGPoint,
499+
visibleContentFrame: CGRect
497500
) -> ListLayoutContent.ContentItem?
498501
{
499-
let rect : CGRect = self.rectForFindingItemToScrollToOnDidEndDragging(
500-
after: contentOffset,
501-
velocity: velocity
502-
)
502+
let rect: CGRect
503+
if scrollViewProperties.pageScrollingBehavior == .peek {
504+
/// When peeking, the visible items are the only items considered for the page offest.
505+
rect = visibleContentFrame
506+
} else {
507+
rect = self.rectForFindingItemToScrollToOnDidEndDragging(
508+
after: contentOffset,
509+
velocity: velocity
510+
)
511+
}
503512

504513
let scrollDirection = ScrollVelocityDirection(direction.y(for: velocity))
505514

506515
let items = self.content.content(
507516
in: rect,
508517
alwaysIncludeOverscroll: false,
509518
includeUnpopulated: false
510-
).sorted { lhs, rhs in
511-
switch scrollDirection {
512-
case .forward:
513-
return direction.minY(for: lhs.defaultFrame) < direction.minY(for: rhs.defaultFrame)
514-
case .backward:
515-
return direction.maxY(for: lhs.defaultFrame) > direction.maxY(for: rhs.defaultFrame)
516-
}
517-
}
518-
519-
return items.first { item in
520-
let edge = direction.minY(for: item.defaultFrame)
521-
let offset = direction.y(for: contentOffset)
519+
)
520+
521+
if scrollViewProperties.pageScrollingBehavior == .peek {
522+
let mainAxisVelocity = direction.switch(
523+
vertical: { velocity.y },
524+
horizontal: { velocity.x }
525+
)
522526

523-
switch scrollDirection {
524-
case .forward:
525-
return edge >= offset
526-
case .backward:
527-
return edge <= offset
527+
if mainAxisVelocity == 0 {
528+
/// When the items are being held still with custom paging, bias the most visible item.
529+
return items
530+
.sorted { lhs, rhs in
531+
lhs.percentageVisible(inside: visibleContentFrame) > rhs.percentageVisible(inside: visibleContentFrame)
532+
}
533+
.first
534+
} else {
535+
return items
536+
/// Sort items in ascending order, based on their position along the primary axis.
537+
.sorted { lhs, rhs in
538+
direction.minY(for: lhs.defaultFrame) > direction.minY(for: rhs.defaultFrame)
539+
}
540+
/// Using the visible sorted items, return the first that has a minimum edge outside the target offset.
541+
.first { item in
542+
let edge = direction.minY(for: item.defaultFrame)
543+
let offset = direction.y(for: contentOffset)
544+
545+
switch scrollDirection {
546+
case .forward:
547+
return edge >= offset
548+
case .backward:
549+
return edge <= offset
550+
}
551+
}
528552
}
553+
} else {
554+
return items
555+
/// Sort items based on their position on the primary axis, in ascending order.
556+
.sorted { lhs, rhs in
557+
switch scrollDirection {
558+
case .forward:
559+
return direction.minY(for: lhs.defaultFrame) < direction.minY(for: rhs.defaultFrame)
560+
case .backward:
561+
return direction.maxY(for: lhs.defaultFrame) > direction.maxY(for: rhs.defaultFrame)
562+
}
563+
}
564+
/// Using the sorted items, return the first has has a min edge outside the offset.
565+
.first { item in
566+
let edge = direction.minY(for: item.defaultFrame)
567+
let offset = direction.y(for: contentOffset)
568+
569+
switch scrollDirection {
570+
case .forward:
571+
return edge >= offset
572+
case .backward:
573+
return edge <= offset
574+
}
575+
}
529576
}
530577
}
531578

ListableUI/Sources/Layout/ListLayout/ListLayoutContent.swift

+5
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,11 @@ extension ListLayoutContent
560560
case .supplementary(let supplementary, _): return supplementary.defaultFrame
561561
}
562562
}
563+
564+
/// Returns the percentage from `0.0` to `1.0` that this item overlaps `container`.
565+
func percentageVisible(inside container: CGRect) -> CGFloat {
566+
collectionViewLayoutAttributes.frame.percentageVisible(inside: container)
567+
}
563568
}
564569
}
565570

0 commit comments

Comments
 (0)