An item boundary is a spacing boundary
SkillDev toolsA lazy list's `spacedBy` arrangement applies between every pair of items and compounds with each item's own edge padding, so blocks that must read as one unit belong in ONE item carrying its own tighter spacing rather than in three items relying on the list's gap. Covers why the visible gap appears in no single constant, why an item boundary is a spacing boundary, and what to do when the group is conditional. Use when a band of dead space opens above one block, when tightening the gap for one pair moves every other pair, or before splitting a header into separate lazy items.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the An item boundary is a spacing boundary skill
What this skill tells your AI
The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/lazy-item-grouping-beats-arrangement-gap/SKILL.md and read by ahel’s review.
A lazy list places nothing between its items unless its arrangement says so — and then it places the same thing between all of them. A group that has to read as one unit therefore goes in one item:
LazyColumn(verticalArrangement = Arrangement.spacedBy(SECTION_GAP)) { // SECTION_GAP = 32.dp
item {
Column { // header + navigator + count = ONE unit
PeriodHeader(...) // its own 24.dp bottom inset, inside the block
Spacer(Modifier.height(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
PeriodNavigator(...)
HeadlineCount(...)
}
}
}
item { FirstSection(...) }
item { SecondSection(...) }
}
As three items, the navigator sat SECTION_GAP below a header that already ends with its own 24dp
inset, so the space above it was 56dp — a number written nowhere. The arrangement is a property of
the list, not of an item: there is no per-item override to reach for, and lowering the constant
to fix this pair would pull every section on the page together with it.
Traps
The gap you see is a sum, and no line of source contains it. It is arrangement + the bottom padding of the block above + the top padding of the block below, and those three live in three
different files with three different owners — the list's constant, and each block's own idea of its
edges. Reading only the arrangement makes the rendered spacing look like a mystery; measure it, then
account for it as a sum before changing anything (step 3 lists the named constants that feed it).
Splitting into items is a performance instinct that costs layout control. One item per block is the right shape for a long list of rows, because the item is the unit of composition. It is the wrong shape for a handful of always-visible blocks at the top of a page: they are all in the viewport together, so nothing is saved, and the split has silently handed their spacing to a constant chosen for section-to-section distance. Put item boundaries where you want gaps.
One owner of inter-block space, never two. The two stable arrangements are: the list owns all of it and no block carries its own top padding, or each block owns its own and the list adds nothing. A shared section-header component that dropped its own top padding is what usually forces the switch to a list arrangement — and that fix is correct everywhere except the one block that also kept an internal inset, which is where the doubled gap comes from. Pick the owner per list and strip the other side.
A conditional group belongs in the same item, not in its own. When the trio only exists in one
layout branch, folding it into the item that holds the block it belongs to keeps the item count
identical in both branches — the item exists either way and its content differs. Giving the
condition its own item { } re-opens the arrangement gap above it, in whichever branch you did not
open while checking.
Two spacing systems on one screen is normal; knowing which is which is not optional. The wide
branch of a page like this often puts everything into a single item holding two Columns, each with
its own spacedBy — so the list's arrangement applies between two or three items while all the
visible spacing comes from the inner columns. A gap that will not respond to the list constant is
usually being set by an inner column, and vice versa.
An item that renders nothing still gets spaced. item { if (condition) Block() } is an item
whichever way the condition falls: it measures zero tall when the block is absent, and the list still
puts its gap on both sides of it — one dead double gap wherever a section is hidden, growing with
every hidden section. Put the condition around the item { } call instead of inside it, so the
absent block is an absent item.
Inside the merged item, use an arrangement too. Once the group is one item, its internal spacing
has the same choice to make, and writing it as per-child padding(top = …) recreates the two-owner
problem one level down — each child deciding its own distance from a neighbour it cannot see. A
Column(verticalArrangement = Arrangement.spacedBy(...)) plus, where one gap genuinely differs, a
single explicit Spacer, keeps every number in the group visible in one place.
contentPadding is not part of this and is the one thing spacedBy will not do. The arrangement
puts space between items only — never before the first or after the last. Space at the ends comes
from the list's contentPadding or from a trailing spacer item, and reaching for a bigger arrangement
value to buy room at the bottom moves every internal gap instead.
Merging items merges their keys. In a list that declares key = (and contentType) per item,
three items becoming one is a change to the key set, not only to the spacing: the merged block needs
its own stable key or the list loses the anchor it restores scroll position and item state from when
the data changes. Group blocks that belong together and live together — merging a pinned header
with a block that comes and goes buys a gap and costs an anchor.
Negative padding to cancel a doubled gap is a trap in a lazy list. It survives exactly as long as the two blocks stay adjacent; the next item inserted between them inherits a negative offset written for a neighbour it has never met. Merging the items removes the gap rather than cancelling it.
Verifying it
# 1. Every lazy list that delegates its inter-item space to an arrangement. Each hit is a list where
# the gap is uniform by construction — read what its items are, and whether any two of them are
# meant to read as one block.
grep -rn --include="*.kt" -A8 -E "Lazy(Column|Row|VerticalGrid|HorizontalGrid)\(" . | grep -v "/build/" \
| grep -E "Arrangement\.spacedBy"
# 2. Where the items are. The files at the top of this list are the ones whose layout is being
# decided by item boundaries — a high count next to a hit from step 1 is the shape this covers.
grep -rc --include="*.kt" -E "^ *item(sIndexed)? *\{" . | grep -v "/build/" | grep -v ":0" \
| sort -t: -k2 -rn | head -6
# 3. The named spacing constants that add up to what you measured on screen. Roll them up per file:
# the screen carrying the most of them is the one where a rendered gap is least likely to match
# any single constant.
grep -rn --include="*.kt" -E "^ *(private )?val [A-Z_]+ *= *[0-9]+(\.[0-9]+)?\.dp" . | grep -v "/build/" \
| cut -d: -f1 | sort | uniq -c | sort -rn | head -5
Then change the list's arrangement constant by a large, obvious amount and look at the whole page: every gap that moves is owned by the list, and every gap that does not is owned by a block. That one edit answers "which of the two is this gap" faster than reading either. Put it back, merge the group into one item, and check the other boundaries did not tighten with it — a merge removes one arrangement gap, so the block above the group now sits at the arrangement distance from the header rather than from the group's last row. Then open the other layout branch, if there is one, and repeat: the branch you did not edit is where the split items usually survive.
Related: lazy-scroll-helper-kit (the other lazy-list arithmetic worth getting right),
variant-layout-math-stays-in-the-variant (when the two branches each need their own spacing maths),
mosaic-arrangements-must-be-hole-free (the block inside such an item that must not leave gaps of
its own).
Signals
- GitHub stars
- 315
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
lazy-item-grouping-beats-arrangement-gap- Source
- github.com/maxrave-dev/kotlin-footguns