Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Added

- Added `ListStateObserver.onApproachingBottom(within:shouldPerform:_:)` to make end-of-list pagination easier to implement and test.
- Added `ListScrollPositionInfo.BottomThreshold`, `contentSize`, and `isApproachingBottom(within:)` to support approaching-bottom pagination observers.

### Removed

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//
// ApproachingBottomPaginationViewController.swift
// Demo
//
// Created by OpenAI Codex on 2026-04-24.
//

import ListableUI
import BlueprintUILists
import BlueprintUI
import BlueprintUICommonControls
import UIKit


final class ApproachingBottomPaginationViewController : ListViewController
{
private let actions = ListActions()

private let pageSize = 20
private let maxPageCount = 5

private var items : [DemoItem] = []
private var nextItemNumber = 1
private var loadedPageCount = 0
private var approachingBottomCallCount = 0
private var isLoadingNextPage = false

private var loadTask : Task<Void, Never>?

private var hasMorePages : Bool {
self.loadedPageCount < self.maxPageCount
}

deinit {
self.loadTask?.cancel()
}

override func viewDidLoad()
{
super.viewDidLoad()

self.title = "Approaching Bottom"

self.navigationItem.rightBarButtonItems = [
UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetDemo)),
UIBarButtonItem(title: "Bottom", style: .plain, target: self, action: #selector(scrollToBottom)),
]

self.reset()
}

override func configure(list : inout ListProperties)
{
list.appearance = .demoAppearance
list.layout = .demoLayout()
list.actions = self.actions

list.content.header = DemoHeader(
title: "Approaching Bottom Pagination",
detail: """
Uses `onApproachingBottom(within: .screens(1.0))` to load the next page as the list nears its rendered end.
Pages: \(loadedPageCount)/\(maxPageCount)
Observer Calls: \(approachingBottomCallCount)
Loading: \(isLoadingNextPage ? "Yes" : "No")
"""
)

list.stateObserver = ListStateObserver { observer in
observer.onApproachingBottom(
within: .screens(1.0),
shouldPerform: { [weak self] _ in
guard let self = self else { return false }
return self.isLoadingNextPage == false && self.hasMorePages
}
) { [weak self] _ in
self?.approachingBottomCallCount += 1
self?.loadNextPage()
}
}

list("items") { section in
for item in self.items {
section += item
}

if self.isLoadingNextPage {
section += PaginationLoadingItem(identifierValue: "loading-next-page")
} else if self.hasMorePages == false {
section.footer = DemoFooter(text: "Reached the end of the demo list.")
}
}
}

@objc private func resetDemo()
{
self.reset()
}

@objc private func scrollToBottom()
{
self.actions.scrolling.scrollToLastItem(animated: true)
}

private func reset()
{
self.loadTask?.cancel()
self.loadTask = nil

self.items = []
self.nextItemNumber = 1
self.loadedPageCount = 0
self.approachingBottomCallCount = 0
self.isLoadingNextPage = false

self.appendPage()
self.reload(animated: false)
}

private func loadNextPage()
{
guard self.isLoadingNextPage == false else { return }
guard self.hasMorePages else { return }

self.isLoadingNextPage = true
self.reload(animated: true)

self.loadTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)

guard let self = self else { return }
guard Task.isCancelled == false else { return }

self.isLoadingNextPage = false
self.appendPage()
self.reload(animated: true)
self.loadTask = nil
}
}

private func appendPage()
{
let end = self.nextItemNumber + self.pageSize

for itemNumber in self.nextItemNumber..<end {
self.items.append(DemoItem(text: "Item #\(itemNumber)"))
}

self.nextItemNumber = end
self.loadedPageCount += 1
}
}


fileprivate struct PaginationLoadingItem : BlueprintItemContent, Equatable
{
var identifierValue : String

func element(with info : ApplyItemContentInfo) -> Element
{
Row(alignment: .center, minimumSpacing: 10.0) {
PaginationActivityIndicatorElement()
Label(text: "Loading next page…") {
$0.font = .systemFont(ofSize: 17.0, weight: .medium)
$0.color = .darkGray
}
}
.inset(horizontal: 15.0, vertical: 13.0)
}

func backgroundElement(with info: ApplyItemContentInfo) -> Element?
{
Box(
backgroundColor: .white,
cornerStyle: .rounded(radius: 8.0)
)
}
}

fileprivate struct PaginationActivityIndicatorElement : UIViewElement {
func makeUIView() -> UIActivityIndicatorView
{
UIActivityIndicatorView(style: .medium)
}

func updateUIView(_ view: UIActivityIndicatorView, with context: UIViewElementContext)
{
if context.isMeasuring == false && view.isAnimating == false {
view.startAnimating()
}
}
}
8 changes: 8 additions & 0 deletions Development/Sources/Demos/DemosRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ public final class DemosRootViewController : ListViewController
self?.push(ListStateViewController())
}
)

Item(
DemoItem(text: "Approaching Bottom Pagination"),
selectionStyle: .selectable(),
onSelect: { _ in
self?.push(ApproachingBottomPaginationViewController())
}
)

Item(
DemoItem(text: "Itemization Editor"),
Expand Down
37 changes: 37 additions & 0 deletions ListableUI/Sources/ListScrollPositionInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
/// `safeAreaInsests` of the list view
public var safeAreaInsets: UIEdgeInsets

/// `contentSize` of the list view
public var contentSize: CGSize {
self.scrollViewState.contentSize
}

///
/// Used to retrieve the visible content edges for the list's content.
///
Expand Down Expand Up @@ -101,6 +106,38 @@
)
}

/// Controls how close to the bottom edge of a list a user must scroll before the
/// list is considered to be approaching the bottom.
public enum BottomThreshold : Equatable {
/// The list is approaching the bottom once the final rendered item is visible.
case lastItem

/// The list is approaching the bottom once the remaining vertical scroll distance
/// is less than or equal to the provided number of points.
case offset(CGFloat)

/// The list is approaching the bottom once the remaining vertical scroll distance
/// is less than or equal to the provided number of visible viewport heights.
case screens(CGFloat)
}

/// Returns whether the list is approaching the bottom for a given threshold.
public func isApproachingBottom(within threshold : BottomThreshold) -> Bool
{
switch threshold {
case .lastItem:
return isLastItemVisible

case let .offset(offset):
return bottomScrollOffset <= max(offset, 0.0)

case let .screens(screens):
let visibleHeight = max(bounds.height - safeAreaInsets.top - safeAreaInsets.bottom, 0.0)
let screenHeight = visibleHeight > 0.0 ? visibleHeight : bounds.height
return bottomScrollOffset <= screenHeight * max(screens, 0.0)
}
}

//
// MARK: Private
//
Expand Down Expand Up @@ -173,7 +210,7 @@
}
}

extension UIRectEdge : CustomDebugStringConvertible

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 16.2

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 16.2

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 17.2

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 17.2

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 15.4

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future

Check warning on line 213 in ListableUI/Sources/ListScrollPositionInfo.swift

View workflow job for this annotation

GitHub Actions / Build & Test All - iOS 15.4

extension declares a conformance of imported type 'UIRectEdge' to imported protocol 'CustomDebugStringConvertible'; this will not behave correctly if the owners of 'UIKit' introduce this conformance in the future
{
static func visibleScrollViewContentEdges(
bounds : CGRect,
Expand Down
Loading
Loading