Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body read store.selection in three places — isSelected, the drag replica's count, and the context menu's styleTarget — and Observation invalidates every reader of the property, past the equatable gate entirely. The band overlay stayed cheap, which is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind. Now LaneView and TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems takes its target as a deferred closure; TrashLaneRowView gains the same treatment plus the Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A growing band costs the selection's own running size; the real board's crossing fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a separate lane-level finding recorded in RENDER-INSTRUMENTATION.md. Also: select() gains defaultsSoleMember — the marquee's explicit nils never avoided the sole-member default, so a one-card band acquired a selectionHead and could scroll the lane out from under its own drag. MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
This commit is contained in:
@@ -327,7 +327,18 @@ struct TrashLaneView: View {
|
||||
}
|
||||
|
||||
private var scrollableCards: some View {
|
||||
ScrollView(.vertical) {
|
||||
// **The trash-side selection, read once for the whole column — and this is a new subscription,
|
||||
// deliberately.** `LaneView` was already reading the selection for its header, so hoisting it
|
||||
// there was free; this body was not, and now is. The trade is the point: one column body per
|
||||
// selection change, in place of one body per *row* in it, which is what every face and row
|
||||
// here cost while each read `store.selection` for itself (`CardFaceView.isSelected`,
|
||||
// RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A trash holds one board's
|
||||
// deletions, so even the bad side of that trade is small — but the shape is what matters, and
|
||||
// the shape is now O(1) bodies rather than O(rows).
|
||||
let selection = store.selection
|
||||
let selectedIDs = selection.container == .trash ? selection.ids : []
|
||||
let selectedCount = max(1, selectedIDs.count)
|
||||
return ScrollView(.vertical) {
|
||||
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
|
||||
// one width unit, so its masonry is a single column — but it is the *same* layout the
|
||||
// lanes use, which is what makes the drag's make-room reflow read as positional slides
|
||||
@@ -348,7 +359,9 @@ struct TrashLaneView: View {
|
||||
card: card,
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee,
|
||||
drops: drops
|
||||
drops: drops,
|
||||
isSelected: selectedIDs.contains(card.id),
|
||||
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
|
||||
)
|
||||
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
|
||||
.equatable()
|
||||
@@ -361,8 +374,14 @@ struct TrashLaneView: View {
|
||||
lane: lane,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
marquee: marquee
|
||||
marquee: marquee,
|
||||
isSelected: selectedIDs.contains(lane.id),
|
||||
selectedCount: selectedIDs.contains(lane.id) ? selectedCount : 1
|
||||
)
|
||||
// The row's own gate (`TrashLaneRowView.==`) — worth having now that the
|
||||
// column's body re-runs on every selection change rather than only on a
|
||||
// reload.
|
||||
.equatable()
|
||||
case .shadow:
|
||||
// The delete gesture's shadow, holding the topmost row open
|
||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||
|
||||
Reference in New Issue
Block a user