Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

### Added

- Added item-aware content offset adjustment APIs, declarative auto-scroll support, and scroll-in-progress state for custom scrolling behaviors.
```swift
list.autoScrollAction = .pin(
.item(targetIdentifier),
itemPosition: .verticalContentOffsetAdjustment { info in
max(0.0, info.itemFrame.maxY - info.visibleContentFrame.maxY)
},
scrollInterruptionPolicy: .deferDuringUserScrolling
)
```
Use `.skipDuringUserScrolling` instead when the auto-scroll should be dropped rather than retried after the user scroll ends.

### Removed

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
//
// CustomAutoScrollingViewController.swift
// Demo
//
// Created by Square on 5/22/26.
//

import BlueprintUI
import BlueprintUICommonControls
import BlueprintUILists
import ListableUI
import UIKit


final class CustomAutoScrollingViewController : UIViewController
{
private let list = ListView()
private let footer = UIView()
private let footerTitle = UILabel()
private lazy var animationsButton : UIBarButtonItem = {
UIBarButtonItem(
title: self.animationsButtonTitle,
style: .plain,
target: self,
action: #selector(toggleAnimations)
)
}()

private var selectedRow = 24
private var expandedRows = Set<Int>()
private var hasPerformedInitialLayoutUpdate = false
private var animateAutoScroll = false
private var isSuppressingAutoScrollAnimation = false

override func loadView()
{
self.view = UIView()
self.view.backgroundColor = .white

self.list.translatesAutoresizingMaskIntoConstraints = false
self.footer.translatesAutoresizingMaskIntoConstraints = false

self.view.addSubview(self.list)
self.view.addSubview(self.footer)

NSLayoutConstraint.activate([
self.list.topAnchor.constraint(equalTo: self.view.topAnchor),
self.list.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.list.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.list.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),

self.footer.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.footer.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.footer.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
self.footer.heightAnchor.constraint(equalToConstant: 112.0),
])

self.configureFooter()
}

override func viewDidLoad()
{
super.viewDidLoad()

self.title = "Custom Auto Scrolling"
self.navigationItem.rightBarButtonItem = self.animationsButton
self.updateList()
}

override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()

guard self.hasPerformedInitialLayoutUpdate == false else {
return
}

self.hasPerformedInitialLayoutUpdate = true
self.isSuppressingAutoScrollAnimation = true
defer { self.isSuppressingAutoScrollAnimation = false }

UIView.performWithoutAnimation {
self.updateList()
}
}

override func viewSafeAreaInsetsDidChange()
{
super.viewSafeAreaInsetsDidChange()

self.updateList()
}

private func configureFooter()
{
self.footer.backgroundColor = .systemBackground
self.footer.layer.shadowColor = UIColor.black.cgColor
self.footer.layer.shadowOpacity = 0.18
self.footer.layer.shadowRadius = 8.0
self.footer.layer.shadowOffset = CGSize(width: 0.0, height: -2.0)

let previous = UIButton(type: .system)
previous.setTitle("Previous", for: .normal)
previous.addTarget(self, action: #selector(selectPreviousRow), for: .touchUpInside)

let next = UIButton(type: .system)
next.setTitle("Next", for: .normal)
next.addTarget(self, action: #selector(selectNextRow), for: .touchUpInside)

let toggleHeight = UIButton(type: .system)
toggleHeight.setTitle("Toggle Height", for: .normal)
toggleHeight.addTarget(self, action: #selector(toggleSelectedRowHeight), for: .touchUpInside)

self.footerTitle.font = .systemFont(ofSize: 16.0, weight: .semibold)
self.footerTitle.textAlignment = .center

let buttons = UIStackView(arrangedSubviews: [previous, next, toggleHeight])
buttons.axis = .horizontal
buttons.alignment = .center
buttons.distribution = .equalSpacing
buttons.spacing = 16.0

let stack = UIStackView(arrangedSubviews: [self.footerTitle, buttons])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 10.0

self.footer.addSubview(stack)

NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: self.footer.topAnchor, constant: 12.0),
stack.leadingAnchor.constraint(equalTo: self.footer.leadingAnchor, constant: 20.0),
stack.trailingAnchor.constraint(equalTo: self.footer.trailingAnchor, constant: -20.0),
])
}

@objc private func selectPreviousRow()
{
self.selectedRow = max(0, self.selectedRow - 1)
self.updateList()
}

@objc private func selectNextRow()
{
self.selectedRow = min(Self.rowCount - 1, self.selectedRow + 1)
self.updateList()
}

@objc private func toggleSelectedRowHeight()
{
if self.expandedRows.contains(self.selectedRow) {
self.expandedRows.remove(self.selectedRow)
} else {
self.expandedRows.insert(self.selectedRow)
}

self.updateList()
}

@objc private func toggleAnimations()
{
self.animateAutoScroll.toggle()
self.updateAnimationsButtonTitle()
print("Auto-scroll animations are \(self.animateAutoScroll ? "on" : "off").")
}

private func updateAnimationsButtonTitle()
{
self.animationsButton.title = self.animationsButtonTitle
}

private func updateList()
{
self.footerTitle.text = "Target row \(self.selectedRow + 1) stays above the fixed footer"

let selectedRow = self.selectedRow
let targetIdentifier = FooterAwarePinnedItem.identifier(with: selectedRow)

self.list.configure { list in
list.appearance = .demoAppearance
list.layout = .demoLayout
list.animation = .fast
list.scrollIndicatorInsets.bottom = self.scrollIndicatorBottomInset

list.autoScrollAction = .pin(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new API in action

.item(targetIdentifier),
itemPosition: .verticalContentOffsetAdjustment { [weak self] info in
self?.footerAwareScrollDelta(for: info) ?? 0.0
},
animated: self.animateAutoScroll && self.isSuppressingAutoScrollAnimation == false,
scrollInterruptionPolicy: .deferDuringUserScrolling,
shouldPerform: { _ in true }
)
Comment on lines +186 to +194
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should explore the initial animations when the view is presented. When opening the demo, the list animates to the selected row when viewDidLayoutSubviews() calls updateList(). It would be a great enhancement if we could find a way around this issue in the demo example. Also curious if this is an issue in the production use case.

Here's a recording with slow animations, taken from an iOS 17.5 simulator.

Simulator.Screen.Recording.-.UI.Test.-.2026-05-27.at.09.53.29.mov

While investigating, I flipped animations to enabled by default in AutoScrollingViewController3 but opening that demo's controller didn't perform the same initial scroll there. I don't think this is an issue in the other demos that showcase pin and scrollTo because they don't have the viewDidLayoutSubviews pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will investigate 👍🏼

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [Codex] Addressed in 67ade56. The demo now suppresses animation only for the first layout-driven updateList() pass when the screen is presented. Later row changes still honor the animation toggle.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, AI missed here; let me explore addressing this workaround within the library itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, on second though, I do think this maneuver should be done within the client app.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new behavior looks much smoother in the demo. And having this be a client app configuration tweak sounds good.


list += Section("rows") {
for row in 0..<Self.rowCount {
FooterAwarePinnedItem(
row: row,
isSelected: row == selectedRow,
isExpanded: self.expandedRows.contains(row)
)
}
}
}
}

private func footerAwareScrollDelta(for info: ListItemScrollPositionInfo) -> CGFloat
{
let topGap : CGFloat = 16.0
let footerGap : CGFloat = 16.0
let footerHeight = self.footer.bounds.height

let idealTop = info.visibleContentFrame.minY + topGap
let idealBottom = info.visibleContentFrame.maxY - footerHeight - footerGap

if info.itemFrame.height > idealBottom - idealTop {
return info.itemFrame.minY - idealTop
}

if info.itemFrame.minY < idealTop {
return info.itemFrame.minY - idealTop
}

if info.itemFrame.maxY > idealBottom {
return info.itemFrame.maxY - idealBottom
}

return 0.0
}

private var scrollIndicatorBottomInset : CGFloat {
max(0.0, self.footer.bounds.height - self.view.safeAreaInsets.bottom)
}

private var animationsButtonTitle : String {
"Animations: \(self.animateAutoScroll ? "On" : "Off")"
}

private static let rowCount = 50
}


private struct FooterAwarePinnedItem : BlueprintItemContent, Equatable
{
var row : Int
var isSelected : Bool
var isExpanded : Bool

var identifierValue : Int {
self.row
}

func element(with info : ApplyItemContentInfo) -> Element
{
let title = Label(text: "Row \(self.row + 1)") {
$0.font = .systemFont(ofSize: 17.0, weight: self.isSelected ? .semibold : .regular)
$0.color = self.isSelected ? .systemBlue : .label
}

let detail = Label(text: self.detailText) {
$0.font = .systemFont(ofSize: 14.0, weight: .regular)
$0.color = .secondaryLabel
}

let content = Column(alignment: .fill, minimumSpacing: 6.0) {
title
detail
}

var box = Box(
backgroundColor: self.isSelected ? UIColor.systemBlue.withAlphaComponent(0.08) : .white,
cornerStyle: .rounded(radius: 6.0),
wrapping: Inset(
uniformInset: 14.0,
wrapping: content
)
)

box.borderStyle = .solid(
color: self.isSelected ? .systemBlue : .white(0.9),
width: 2.0
)

return box
}

private var detailText : String {
if self.isExpanded {
return "Expanded row content demonstrates a layout update that re-runs declarative custom auto-scroll."
} else if self.isSelected {
return "Selected target row"
} else {
return "Regular row"
}
}
}
9 changes: 8 additions & 1 deletion Development/Sources/Demos/DemosRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ public final class DemosRootViewController : ListViewController
self?.push(PinAutoscrollingViewController())
}
)

Item(
DemoItem(text: "Custom Auto Scrolling (Footer-Aware Pin)"),
selectionStyle: .selectable(),
onSelect : { _ in
self?.push(CustomAutoScrollingViewController())
}
)

Item(
DemoItem(text: "scrollTo(item: ...) completion handler"),
Expand Down Expand Up @@ -426,4 +434,3 @@ public final class DemosRootViewController : ListViewController
}
}
}

Loading
Loading