What does an orthogonally scrolling compositional section measure and scroll, and how does a pinned header behave?
Predict first
What frames and content size does the carousel section produce?
import UIKit
final class DS: NSObject, UICollectionViewDataSource {
func numberOfSections(in cv: UICollectionView) -> Int { 2 }
func collectionView(_ cv: UICollectionView, numberOfItemsInSection s: Int) -> Int { s == 0 ? 2 : 3 }
func collectionView(_ cv: UICollectionView, cellForItemAt ip: IndexPath) -> UICollectionViewCell { cv.dequeueReusableCell(withReuseIdentifier: "c", for: ip) }
func collectionView(_ cv: UICollectionView, viewForSupplementaryElementOfKind k: String, at ip: IndexPath) -> UICollectionReusableView { cv.dequeueReusableSupplementaryView(ofKind: k, withReuseIdentifier: "h", for: ip) }
}
let layout = UICollectionViewCompositionalLayout { idx, _ in
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
if idx == 0 {
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(100)), subitems: [item])
return NSCollectionLayoutSection(group: group)
}
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.8), heightDimension: .absolute(80)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
section.interGroupSpacing = 10
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(30)), elementKind: "hdr", alignment: .top)
header.pinToVisibleBounds = true
section.boundarySupplementaryItems = [header]
return section
}
let cv = UICollectionView(frame: CGRect(x: 0, y: 0, width: 400, height: 800), collectionViewLayout: layout)
cv.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "c")
cv.register(UICollectionReusableView.self, forSupplementaryViewOfKind: "hdr", withReuseIdentifier: "h")
let ds = DS()
cv.dataSource = ds
cv.layoutIfNeeded()
let all = layout.layoutAttributesForElements(in: CGRect(x: 0, y: 0, width: 400, height: 2000))!
for a in all.filter({ $0.indexPath.section == 1 }).sorted(by: { ($0.indexPath.item, $0.representedElementKind ?? "") < ($1.indexPath.item, $1.representedElementKind ?? "") }) {
print(a.indexPath.item, a.representedElementKind ?? "cell", a.frame)
}
print(cv.contentSize)
- 0 cell (0.0, 230.0, 320.0, 80.0) / 0 hdr (0.0, 200.0, 400.0, 30.0) / 1 cell (330.0, 230.0, 320.0, 80.0) / (400.0, 310.0)
- 0 cell (0.0, 210.0, 400.0, 80.0) / … / (400.0, 310.0)
- … / (1050.0, 310.0)
Mental model. An orthogonal section is a conveyor belt built into a shop shelf: the shelf (the outer collection) only goes up and down, while the belt has its own motor and its own position.
An orthogonalScrollingBehavior section is a horizontal scroller embedded in a vertical collection; the section's groups are laid out along the cross axis, and the section scrolls inside its own container. Group widths are fractions of the collection's container: with .groupPaging and a group of fractionalWidth(0.8) in a 400-point collection, each group is 320 wide, and with interGroupSpacing of 10 the second group starts at x 330 (the check printed 0 and 330 with a width of 320 for both). The content height of the section is just the group height plus supplementaries (80 + 30 = 110), and cv.contentSize is (400, 310), not wider: horizontal travel happens inside the section, so the collection's own contentSize.width and contentOffset.x do not change and you observe it through visibleItemsInvalidationHandler (offset, environment) rather than through delegate scroll callbacks.
A NSCollectionLayoutBoundarySupplementaryItem with pinToVisibleBounds = true becomes a sticky header: its frame is (0, 200, 400, 30) at rest, directly above the group, and it stays at the top as the section scrolls vertically. Section insets shrink the width the fractions use (Q246), and the items in an orthogonal section still use the normal cell registration and diffable data source. Pitfalls: layoutAttributesForElements returns only attributes in the requested rect (the third group at x 660 was not returned for a 400-wide rect), nested vertical scrolling inside an orthogonal section is not possible, and inside a UIScrollView you cannot simply observe per-section offsets without the handler. Estimated heights inside orthogonal groups are supported, but mixing them with fractional heights causes surprising sizes, so test with Dynamic Type.
Groups are 0.8 of the collection width, spaced 10 apart, and the horizontal travel does not enlarge contentSize.
Common traps
- Reading the outer
contentOffset.xto track a carousel. It never changes; usevisibleItemsInvalidationHandler. - Expecting
layoutAttributesForElementsto return every item. It returns only items intersecting the rect.
They might follow up with
- How do I do page dots? Compute the page from the handler's offset and the group width.
- Can headers pin horizontally? The boundary item pins along the vertical scroll of the outer view.