Skip to content

Find collection view section headers deliberately, not by luck - #1350

Draft
RoyalPineapple wants to merge 11 commits into
masterfrom
aodawa/scroll-to-supplementary-views
Draft

Find collection view section headers deliberately, not by luck#1350
RoyalPineapple wants to merge 11 commits into
masterfrom
aodawa/scroll-to-supplementary-views

Conversation

@RoyalPineapple

@RoyalPineapple RoyalPineapple commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Collection view section headers are only found by luck today, and often that luck runs out. This makes the search ask for them.

Problem

The scroll search in -[UIView(KIFAdditions) accessibilityElementMatchingBlock:notHidden:disableScroll:] enumerates numberOfItemsInSection only:

for (NSUInteger section = 0; section < numberOfSections; section++) {
    for (NSUInteger item = 0; item < numberOfItems; item++) {

Supplementary views are not items, so nothing ever looks for one. A section header or footer that has not been scrolled near is never realised, and searching for it fails with a 10 second timeout even though it exists. A cell at the same scroll offset is found without trouble.

Whether a header is found at all comes down to whether scrolling toward some item happened to drag it into the viewport. That depends entirely on where the layout put the header, which the search never asks about and cannot infer.

This affects any collection view whose section headers carry accessibility labels. Lists built on frameworks that vend headers as supplementary views hit it for every offscreen section header.

The existing fixture registers no supplementary views, so no test covered this path.

Fix

Ask the layout which supplementary views exist and where they are, scroll straight to each one, and match it.

Scrolling directly is the part that matters. A header is only conceptually between sections; the layout decides where it actually goes. A horizontally scrolling layout puts it to the leading side rather than on top, and a custom layout can put it anywhere: pinned, inset, or somewhere with no relation to the items at all. Nothing about a supplementary view's position can be inferred from the items around it.

So both the position and the set of kinds come from the one object that knows them:

NSArray *allAttributes = [collectionView.collectionViewLayout layoutAttributesForElementsInRect:contentRect];
// ... filtered to UICollectionElementCategorySupplementaryView
[collectionView scrollRectToVisible:attributes.frame animated:NO];

Reading the kinds from the layout rather than naming them is deliberate, and the reason is in the review history below.

A view that is already realised was reachable by the ordinary subview search, so it is skipped. That mirrors how the item loop skips -indexPathsForVisibleItems, and it keeps supplementary views from jumping ahead of everything else in the search order.

Attributes with an empty frame are dropped, so a layout that vends nothing costs nothing. On a match the search returns a full re-search from the top with disableScroll:NO, the convention the item branch already uses. The helpers sit outside the @available(iOS 18, *) split, so there is a single code path on all supported versions.

Two bugs this went through, both caught downstream

The first version of this fix named the two UIKit kinds directly and called -layoutAttributesForSupplementaryViewOfKind:atIndexPath: for each section. Both bugs below came out of squareup/ios-register#157382, which pins this branch and runs a large KIF suite against it. Neither was visible in this repo's own tests.

Asking for a kind a layout does not vend is not a supported query. It is not a nil-returning lookup; layouts commonly assert. RGUIFormLayout in ios-register defines two custom kinds and raises Invalid supplementary view kind for anything else, which crashed the app under test in 95 of 100 failing tests across 30 unrelated suites, most with SIGSEGV. Fixed by reading the kinds from -layoutAttributesForElementsInRect: instead, which is how UIKit itself discovers them and works for any kind a layout defines.

Matching an already-realised view changes the search order. A realised supplementary view was already covered by the ordinary subview search; matching it here as well put every visible supplementary view ahead of the rest of the hierarchy. A search running while a screen was still settling could return one of them instead of the element the caller was waiting for. This showed up as a single flaky test:

KIF version Runs Failures
With the bad ordering 20 3
4.0.2 baseline 30 0

Fixed by returning early when the view is already realised, which is what the pass did originally.

Test

CollectionViewController now vends a header and a footer. testWaitingForOffscreenSupplementaryView asserts both the onscreen header and the footer below 200 items can be found.

Verified on iOS 18.1:

Run Result
New test, without the fix fails: Failed to find accessibility element with the label "Section Footer" after 10s
New test, with the fix passes in 1.5s
CollectionViewTests, CollectionViewTests_ViewTestActor, TableViewTests, TableViewTests_ViewTestActor, OffscreenTests_ViewTestActor 59 tests, 0 failures

The fixture change is deliberately additive (one section, one header, one footer) so the existing tests' index arithmetic is untouched. An earlier two-section version broke three existing tests; I confirmed that was the fixture and not the fix by reverting the fix and observing the same failures.

Pinning the table view's luck

The UITableView branch has the same shape, enumerating numberOfRowsInSection only, so I expected it to fail the same way. It does not, and the reason is the luck described above.

Its row loop calls -scrollRectToVisible: on every row in turn, whether or not that row is a candidate. Section headers and footers are interleaved with the rows, so walking a section scrolls the table through it and drags those views into the viewport. They are realised as collateral from scrolling toward the rows around them, without ever being searched for.

I expected the last section's footer to be the exception, since no rows follow it to scroll past. It is found too, in 4.8s.

So there is nothing to fix on the table view side. One commit adds a test anyway, because header and footer lookup there rests entirely on a side effect that nothing else pins. Making that row loop more selective, so it stops scrolling to rows it does not need, is an obvious future optimisation, and the last section's footer is the first thing it would break. The fixture had no footers, so the last section gains one.

@RoyalPineapple
RoyalPineapple force-pushed the aodawa/scroll-to-supplementary-views branch 2 times, most recently from 0114101 to 196e2ab Compare August 13, 2026 12:24
@RoyalPineapple RoyalPineapple changed the title Scroll to section headers and footers when searching Find collection view section headers deliberately, not by luck Aug 13, 2026
@RoyalPineapple
RoyalPineapple force-pushed the aodawa/scroll-to-supplementary-views branch 3 times, most recently from 65be172 to 5838821 Compare August 13, 2026 13:47
The scroll search in -[UIView(KIFAdditions)
accessibilityElementMatchingBlock:notHidden:disableScroll:] enumerates
numberOfItemsInSection only. Supplementary views are not items, so a
section header or footer that has not been scrolled near is never
realised and cannot be matched. Tests that wait for a header below the
fold time out even though the header exists.

Search each section's header before its items and its footer after them,
matching the order they appear on screen. Scroll to each one directly
using its layout attributes rather than relying on a neighbouring item,
because the layout decides where a supplementary view goes. A header is
only conceptually before its section: a horizontal layout puts it to the
side, and a custom layout can put it anywhere. Nothing about its position
follows from the items around it.

Views that are already visible were reachable by the ordinary subview
search, so skip them the same way the item enumeration skips visible
items.
The scroll search in -[UIView(KIFAdditions)
accessibilityElementMatchingBlock:notHidden:disableScroll:] enumerates
numberOfRowsInSection only, so it never looks for a section header or
footer directly. They are reachable anyway because that search scrolls to
each row in turn, which drags the views between and after them into view.

That makes header and footer lookup a side effect of row scrolling rather
than something the search asks for, and nothing pins it. Add a test so it
cannot regress unnoticed.

The last section's footer is the case with no rows after it to scroll
past, so it is the one most likely to break if that row scrolling ever
becomes more selective. The fixture had no footers, so the last section
gains one.
The search asked every collection view for the UIKit section header and
footer kinds. Asking a layout for a kind it does not vend is not a
supported query, and layouts commonly assert on it rather than returning
nil. RGUIFormLayout in ios-register defines its own two kinds and raises
"Invalid supplementary view kind", which crashed the app under test in 95
of 100 failing tests across 30 unrelated suites.

Collect the supplementary views from layoutAttributesForElementsInRect:
instead and filter to the supplementary category, which is how UIKit
discovers them and works for any kind the layout defines. Walking the
attributes also drops the assumption that a header precedes its section
and a footer follows it, which only holds for flow-like layouts.

Skip the scroll when the view is already realised, so an on-screen
supplementary view costs nothing.
The scroll pass matched a supplementary view that was already realised
instead of leaving it to the ordinary subview search that had already
covered it. That put every visible supplementary view ahead of the rest of
the hierarchy in the search order, so a search running while the screen was
still settling could match one of them rather than the element the caller
was waiting for.

Return early when the view is already realised, which is what the pass did
before and what the comment above it claimed.

Found by VariationReorderKIFTests in ios-register, which failed 3 times in
20 runs with this pass in place and 0 times in 30 runs without it.
Three comments explained the code by contrast with how it used to behave,
which a reader arriving at this file has no way to see. Say what the code
does and why instead.
@RoyalPineapple
RoyalPineapple force-pushed the aodawa/scroll-to-supplementary-views branch from 76a4dc9 to 2b1c566 Compare August 13, 2026 15:29
Deferring every realised supplementary view to the ordinary subview search
assumed realised meant reachable. A supplementary view taller than the space
left for it stays realised while most of it sits outside the viewport, so an
element inside the offscreen part is found but cannot be tapped, and nothing
ever scrolls it in.

Defer only when the view is realised and its frame lies fully within the
viewport. Anything else is scrolled to first, as an unrealised view already
was.
The supplementary view loop confirmed a match and then discarded it, returning
the result of an ordinary subview search over the collection view. That search
is the one that could not reach the supplementary view in the first place, so
it found nothing and the loop reported a match it could not produce.

The item enumeration below runs the same line, but only after scrolling the
cell into the viewport, which realises it and puts it within reach of that
search. Return the element the supplementary view search already found, and
scroll only to a view the layout places outside the viewport, restoring the
offset when it does not match.
Scrolling to a supplementary view to find out whether it matches moves the
collection view for every view the layout vends. A calendar lays out a
supplementary view per gridline, so a search for one label dragged it through
its whole content, and the offset it was restored to excluded the adjusted
content inset, leaving it a little further along after every miss until the
cell the caller was waiting on had been carried off the screen.

A realised supplementary view can be searched where it is. Return the element
found within one and leave the collection view where the caller left it.
A supplementary view the layout places beyond the viewport is not realised, so
it has to be scrolled to before it can be searched. A layout may vend thousands
of them, so the collection view has to be put back when the view does not
match, or a search for one label drags it through its whole content and carries
off the cell the caller was waiting on.

Restoring a rectangle derived from the content offset is not the same as
restoring the offset: deriving one drops the adjusted content inset, so a
calendar laying out a supplementary view per gridline moved by the inset on
every miss. Keep the offset and set it back.
Scrolling during a search is observable to the caller. A test that reads a
point off one view and then taps it expects the two to refer to the same
place, and moving the collection view in between leaves the point describing
somewhere else: a calendar that reports the position of its "10 AM" label and
then presses it created its event at another time.

Search the realised supplementary views where they are first. Anything
reachable without scrolling is found there, so only a search that would
otherwise fail goes on to scroll to the ones the layout places beyond the
viewport.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant