Find collection view section headers deliberately, not by luck - #1350
Draft
RoyalPineapple wants to merge 11 commits into
Draft
Find collection view section headers deliberately, not by luck#1350RoyalPineapple wants to merge 11 commits into
RoyalPineapple wants to merge 11 commits into
Conversation
RoyalPineapple
force-pushed
the
aodawa/scroll-to-supplementary-views
branch
2 times, most recently
from
August 13, 2026 12:24
0114101 to
196e2ab
Compare
RoyalPineapple
force-pushed
the
aodawa/scroll-to-supplementary-views
branch
3 times, most recently
from
August 13, 2026 13:47
65be172 to
5838821
Compare
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
force-pushed
the
aodawa/scroll-to-supplementary-views
branch
from
August 13, 2026 15:29
76a4dc9 to
2b1c566
Compare
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.
…erwise fail" This reverts commit cf0aa1e.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:]enumeratesnumberOfItemsInSectiononly: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:
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.
RGUIFormLayoutin ios-register defines two custom kinds and raisesInvalid supplementary view kindfor 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:
4.0.2baselineFixed by returning early when the view is already realised, which is what the pass did originally.
Test
CollectionViewControllernow vends a header and a footer.testWaitingForOffscreenSupplementaryViewasserts both the onscreen header and the footer below 200 items can be found.Verified on iOS 18.1:
Failed to find accessibility element with the label "Section Footer"after 10sCollectionViewTests,CollectionViewTests_ViewTestActor,TableViewTests,TableViewTests_ViewTestActor,OffscreenTests_ViewTestActorThe 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
UITableViewbranch has the same shape, enumeratingnumberOfRowsInSectiononly, 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.