Skip to content
Open
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@

### Fixed

- Fixed a scroll completion handler being stranded when an `animated: true` scroll was requested inside a `UIView.performWithoutAnimation` block. The suppressed scroll never produced a `scrollViewDidEndScrollingAnimation(_:)` callback, so the handler it was queued for was never reported.

### Added

- Added `ScrollAnimation`, which lets a programmatic scroll run over a specific duration. `UIScrollView` provides no way to configure its built-in animation, so previously a scroll was either instant or ran at a fixed system speed.
```swift
list.scrollToSection(
with: sectionIdentifier,
scrollPosition: ScrollPosition(position: .top),
animation: .duration(0.4)
)
```
Every `animated: Bool` scrolling method on `ListView` and `ListActions.Scrolling` now has an `animation: ScrollAnimation` counterpart. `animated: true` and `false` remain equivalent to `.system` and `.none`, so existing callers are unaffected.

A duration is honored even when the target has not been laid out yet — the scroll is deferred until the presentation state catches up, and the animation wraps that deferred content offset change rather than the initial request. The list advances its content offset on each frame, the same as it does for the system animation, so content stays laid out for the length of the scroll.

A `.duration` scroll is interrupted by the same things that interrupt the scroll view's own animation — the user taking hold of the list, or another scroll replacing it — and reports its completion handler either way. `ListActions.Scrolling.cancelScrollAnimation()` stops one explicitly.

- Added `completion` handlers to `scrollToTop(...)` and `scrollToLastItem(...)`, which previously had no way to report when they finished.

### Removed

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class ScrollCompletionHandlerViewController : UIViewController {

fileprivate var sections: [Section] { [] }

fileprivate var animateScroll: Bool = true
fileprivate var scrollAnimation: ScrollAnimation = .system

fileprivate var scrollPosition: ScrollPosition.Position = .top

fileprivate var ifAlreadyVisible: ScrollPosition.IfAlreadyVisible = .scrollToPosition
Expand All @@ -37,10 +37,6 @@ class ScrollCompletionHandlerViewController : UIViewController {
UIBarButtonItem(title: "Axis", style: .plain, target: self, action: #selector(toggleDirection))
}()

fileprivate lazy var animationsButton: UIBarButtonItem = {
UIBarButtonItem(title: "Toggle Animations", style: .plain, target: self, action: #selector(toggleAnimations))
}()

override func viewDidLoad() {
super.viewDidLoad()
let stackView = UIStackView(arrangedSubviews: [list, settingsPanel])
Expand Down Expand Up @@ -73,11 +69,6 @@ class ScrollCompletionHandlerViewController : UIViewController {
assertionFailure("Override in subclasses.")
}

@objc func toggleAnimations() {
animateScroll.toggle()
print("Scroll animations are \(animateScroll ? "on" : "off").")
}

@objc func toggleDirection() {
if layoutDirection == .horizontal {
layoutDirection = .vertical
Expand All @@ -88,7 +79,7 @@ class ScrollCompletionHandlerViewController : UIViewController {
}

fileprivate var settingsControls: [UIView] {
[selectionPanel, alreadyVisiblePanel, positionPanel]
[selectionPanel, alreadyVisiblePanel, positionPanel, animationPanel]
}

/// This view contains all the configurable scroll settings.
Expand Down Expand Up @@ -147,13 +138,47 @@ class ScrollCompletionHandlerViewController : UIViewController {
return titledView(control, title: "If Visbile")
}()

/// The label and segmented control for selecting the scroll animation. The durations
/// are deliberately slow enough to watch, since the point of `.duration` is to scroll
/// at a speed the system animation does not offer.
lazy var animationPanel: UIView = {
let control = UISegmentedControl(
items: [
UIAction(title: "None") { [weak self] _ in
self?.scrollAnimation = .none
},
UIAction(title: "System") { [weak self] _ in
self?.scrollAnimation = .system
},
UIAction(title: "1s") { [weak self] _ in
self?.scrollAnimation = .duration(1)
},
UIAction(title: "3s") { [weak self] _ in
self?.scrollAnimation = .duration(3)
},
]
)
control.selectedSegmentIndex = 1
return titledView(control, title: "Animation")
}()

/// The label and segmented control for selecting the scrolled item/section.
/// Override in subclasses.
fileprivate var selectionPanel: UIView {
assertionFailure("Override in subclasses.")
return UIView()
}

/// Prints the items that were on screen when the scroll finished.
fileprivate var printScrollCompletion: ListView.ScrollCompletion {
{ changes in
let sortedItems = changes.positionInfo.visibleItems
.map { "\($0.identifier) "}
.sorted()
print("Scroll completion: \(sortedItems)")
}
}

/// A helper to add a label before `view`.
fileprivate func titledView(_ view: UIView, title: String) -> UIView {
let label = UILabel()
Expand Down Expand Up @@ -206,38 +231,40 @@ class ScrollToItemCompletionHandlerViewController: ScrollCompletionHandlerViewCo
super.viewDidLoad()
// TODO: Fully add support for programmatic scrolling in horizontal layouts.
// The axisButton is used in this demo because there are no section headers.
navigationItem.rightBarButtonItems = [scrollButton, animationsButton, axisButton]
navigationItem.rightBarButtonItems = [scrollButton, axisButton]
}

override func performScroll() {
list.scrollTo(
item: scrolledItem,
position: ScrollPosition(
position: scrollPosition,
ifAlreadyVisible: ifAlreadyVisible
),
animated: animateScroll,
completion: { changes in
let sortedItems = changes.positionInfo.visibleItems
.map { "\($0.identifier) "}
.sorted()
print("Scroll completion: \(sortedItems)")
}
animation: scrollAnimation,
completion: printScrollCompletion
)
}
}

/// A demo for showcasing scrolling to a particular section.
class ScrollToSectionCompletionHandlerViewController: ScrollCompletionHandlerViewController {


/// How many sections the list has. Subclasses can raise this to move the far sections
/// out of the initially laid out content.
fileprivate var sectionCount: Int { 3 }

/// How many items each section has.
fileprivate var itemsPerSection: Int { 101 }

override var sections: [Section] { _sections }

private lazy var _sections: [Section] = {
(0...2).map { sectionIndex in
(0..<sectionCount).map { sectionIndex in
Section(
"Section \(sectionIndex)",
items: {
(0...100).map { itemIndex in
(0..<self.itemsPerSection).map { itemIndex in
Item(SimpleScrollItem(text: "Section \(sectionIndex) - Item \(itemIndex)"))
}
},
Expand All @@ -250,27 +277,27 @@ class ScrollToSectionCompletionHandlerViewController: ScrollCompletionHandlerVie
)
}
}()

override var selectionPanel: UIView { sectionSegmentedControl }


/// The first, middle and last sections, so that the control always offers a target
/// beyond the initially laid out content.
private var selectableSectionIndexes: [Int] {
[0, sectionCount / 2, sectionCount - 1]
}

private lazy var sectionSegmentedControl: UIView = {
let control = UISegmentedControl(
items: [
UIAction(title: "0") { [weak self] _ in
self?.scrolledSection = Section.identifier(with: "Section 0")
},
UIAction(title: "1") { [weak self] _ in
self?.scrolledSection = Section.identifier(with: "Section 1")
},
UIAction(title: "2") { [weak self] _ in
self?.scrolledSection = Section.identifier(with: "Section 2")
items: selectableSectionIndexes.map { sectionIndex in
UIAction(title: "\(sectionIndex)") { [weak self] _ in
self?.scrolledSection = Section.identifier(with: "Section \(sectionIndex)")
}
]
}
)
control.selectedSegmentIndex = 1
return titledView(control, title: "Section")
}()

private var sectionPosition: SectionPosition = .top

override var settingsControls: [UIView] {
Expand All @@ -294,15 +321,17 @@ class ScrollToSectionCompletionHandlerViewController: ScrollCompletionHandlerVie
return titledView(control, title: "Supp. View")
}()

private var scrolledSection: Section.Identifier = Section.identifier(with: "Section 1")

fileprivate lazy var scrolledSection: Section.Identifier = Section.identifier(
with: "Section \(sectionCount / 2)"
)

override func viewDidLoad() {
super.viewDidLoad()
// TODO: Fully add support for programmatic scrolling in horizontal layouts.
// Until then, the axisButton is not used in this demo.
navigationItem.rightBarButtonItems = [scrollButton, animationsButton]
navigationItem.rightBarButtonItems = [scrollButton]
}

override func performScroll() {
list.scrollToSection(
with: scrolledSection,
Expand All @@ -311,17 +340,26 @@ class ScrollToSectionCompletionHandlerViewController: ScrollCompletionHandlerVie
position: scrollPosition,
ifAlreadyVisible: ifAlreadyVisible
),
animated: animateScroll,
completion: { changes in
let sortedItems = changes.positionInfo.visibleItems
.map { "\($0.identifier) "}
.sorted()
print("Scroll completion: \(sortedItems)")
}
animation: scrollAnimation,
completion: printScrollCompletion
)
}
}

/// A demo for showcasing scrolling to a section that is too far away to have been laid out
/// yet. The list defers the content offset change until its presentation state catches up
/// with the target, so this is the path where a requested animation is easiest to lose.
///
/// Pick a long `.duration` and the last section to see it: the scroll should ease all the way
/// there, rather than hard-jumping and then easing over the last screenful.
final class ScrollToOffscreenSectionCompletionHandlerViewController : ScrollToSectionCompletionHandlerViewController {

/// Enough sections that the far ones are nowhere near the initially laid out content.
override var sectionCount: Int { 40 }

override var itemsPerSection: Int { 21 }
}

struct SimpleScrollItem : BlueprintItemContent, Equatable {
var text : String

Expand Down
8 changes: 8 additions & 0 deletions Development/Sources/Demos/DemosRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ public final class DemosRootViewController : ListViewController
}
)

Item(
DemoItem(text: "scrollToSection(...) to an offscreen section"),
selectionStyle: .selectable(),
onSelect : { _ in
self?.push(ScrollToOffscreenSectionCompletionHandlerViewController())
}
)

Item(
DemoItem(text: "List State & State Reader"),
selectionStyle: .selectable(),
Expand Down
Loading
Loading