perf(pinch): fix Fabric pinch shake and restructure counter-scaling#28
Draft
anton-patrushev wants to merge 24 commits into
Draft
perf(pinch): fix Fabric pinch shake and restructure counter-scaling#28anton-patrushev wants to merge 24 commits into
anton-patrushev wants to merge 24 commits into
Conversation
Reading `timelineHeight.value` inside an inline JSX style object triggered Reanimated's "shared value .value inside inline style" warning and captured only a one-shot snapshot. Folding the height into the existing useAnimatedStyle subscribes it to the shared value like the rest of the scale transform.
…title to top Counter-scale per-frame native commits on every grid line, hour-tick marker, now-line, and drag dot were the dominant cost during pinch on Fabric. Each `useAnimatedStyle` with nested `scaleY(z) * scaleY(1/z)` produces a separate view commit and accumulates matrix rounding jitter, which manifested as a visible shake. Removed counter-scale from purely geometric elements (HorizontalLine, shortLine markers in TimeColumn, NowIndicator line+dot, DraggableEvent/DraggingEvent drag dots) — pinch is now smooth. Counter-scale is kept on text-bearing wrappers (hour/half/quarter labels in TimeColumn, EventItem title, DraggingHour times, DraggingEvent/DraggableEvent titles) so text doesn't stretch vertically when zoomed. EventItem title also gets `transformOrigin: 'top'` so the counter-scale shrinks around the title's top edge instead of its center — without this the title drifted ~titleHeight*(zoom-1)/2 below the block top on short events. Known trade-off — deferred: grid lines (HorizontalLine) and hour-tick markers no longer counter-scale, so they stretch vertically with zoom (1px → zoom*px). Barely visible at zoom < 1.5, noticeable at high zoom. Will be addressed in a follow-up by rendering lines in a sibling overlay outside the scaled container (computing absolute `top` from `zoomScale * fraction * timelineHeight`) — keeps them 1px without re-introducing nested transform commits.
…ent syntax error)
On Fabric, every Animated.View with a useAnimatedStyle that reads zoomScale commits independently per pinch frame. The dominant cost wasn't the worklets — it was the nested-transform native commits stacking up across many subtrees, producing matrix-rounding jitter that manifested as a visible shake. Two structural changes: 1. Grid lines and hour-tick markers moved to a single sibling-overlay layer in CalendarBody, OUTSIDE the scaled inner container. Lines and ticks render as static Views (no counter-scale, no nested transforms); their positions track zoom via the wrapper's single useAnimatedStyle. This eliminates ~24 lines + ~24 ticks of per-element animated commits that ran on every pinch frame. 2. Counter-scale retained only on text/UI affordances that genuinely need it (event titles, hour/half/quarter labels, drag dots, NowIndicator, drag preview titles). For consumer-provided custom renderers (NowIndicatorComponent, Top/BottomEdgeComponent), the wrapping counter-scale also applies so custom components don't stretch. borderRadius/borderWidth fixes: - Drag/draggable event outlines and TappedSlotIndicator now animate borderTopWidth, borderBottomWidth, and borderRadius via useAnimatedStyle (base / zoomScale) so visual thickness and vertical corner radius stay constant at any zoom. RN can't express asymmetric X/Y radii, so the horizontal corner radius flattens at high zoom — accepted trade-off. - EventItem content gets the same animated borderRadius compensation. Title alignment: - EventItem title and drag-preview titles use transformOrigin: 'top' on the counter-scale wrapper so they stay anchored at the event's top edge instead of drifting toward the center at non-1 zoom. - Drag dots keep default (center) transformOrigin so the dot's visual center stays on the event's top/bottom edge. Reanimated-warning fix: - The inner scale container's height moved into innerScaleStyle's useAnimatedStyle so the shared value is read in the worklet, not as a one-shot snapshot inside an inline JSX style.
…, drop stale dep Reviewer caught that resource mode was still rendering its own _renderHorizontalLines inside the scaled subtree, so lines drew twice (once in the body sibling overlay, once inside the scaled container where they got vertically stretched). Also removes a stale `cellBorderColor` dep from TimeColumn's _renderHour useCallback — it was a leftover from when the shortLine ticks lived in that callback.
…ight-line ticks Three follow-ups from real-app testing of PR #28: 1. borderRadius now honours consumer theming. EventItem, DraggingEvent, DraggableEvent, and TappedSlotIndicator previously hardcoded the base radius (2 / 4) before dividing by zoomScale, overriding any `theme.eventContainerStyle.borderRadius` or `containerStyle.borderRadius` the consumer set. Now the base is sourced from those props (falling back to the library default) and only the / zoomScale compensation is applied by the library. 2. TimeColumn always renders at body level. In single-day non-resource mode it used to live inside each day cell, so on day-swipe it slid horizontally — desyncing visually from the body-level lines overlay (which stays static). Lifted to body level in every mode: - CalendarContainer: `calendarGridWidth` for single-day is now `calendarLayout.width - hourWidth` (matches resource mode). - CalendarBody: `leftSize = hourWidth` always; always renders TimeColumn at body level. - CalendarHeader: same `leftSize = hourWidth` change so the day-list viewport matches the body's. Previously the header's day list was `calendarLayout.width` wide while each header item was only `calendarGridWidth` wide, so two days were visible at once. - TimelineBoard: dropped the inner TimeColumn render path. - BodyItem: `leftSpacing = 0` always (cell no longer carries TimeColumn). 3. shortLine hour-tick markers in the lines overlay are now gated on `showTimeColumnRightLine`. Consumers passing `showTimeColumnRightLine={false}` no longer see the small hour ticks peeking back into the TimeColumn area — they had no opt-out before.
…verlay When a consumer passes `containerStyle` to DraggingEvent or DraggableEvent they're taking visual control of the drag overlay — but the library was still injecting `borderLeftWidth: 3`, `borderRightWidth: 3`, animated `borderTopWidth` / `borderBottomWidth`, and `borderColor: theme.primary` BEFORE the consumer's style in the merge array. RN's per-side border merge meant a consumer setting `borderWidth: 0` or `borderWidth: 2` was still seeing the library's blue 3px outline leak through, because side-specific border widths win over the shorthand. Now: if `containerStyle` is present, the library drops its outline styles (`styles.event`, `outlineBorderStyle`, default `borderColor`) and lets the consumer fully own border styling — they keep backgroundColor plus theme.eventContainerStyle, then their containerStyle. Without `containerStyle`, behaviour is unchanged (default 3px outline with zoom compensation). Trade-off: a consumer using a static `borderWidth` value loses the library's zoom compensation on that border. Acceptable — consumers opting into custom container styling are also opting into managing their own scaling.
…er compensation Two pieces, both motivated by consumer-side shake at threshold crossings: 1. onZoomChange deferred through the post-pinch settle spring. The library was already deferring `onZoomChange` until the pinch gesture released, but `usePinchToZoom.onEnd` then started a `withSpring(...)` on `zoomScale` when finger-release left the value outside [min, max] (rubber-band overshoot). During that spring, `isPinching` was already false so the reaction started firing ~once per integer percent — ~100 JS callbacks while the spring settled, each landing as a React commit in consumers that `setState` from onZoomChange. Now: an `isSettling` shared value is owned alongside `isPinching` in CalendarContainer. usePinchToZoom flips it to true before `withSpring` and back to false from the spring's completion callback. The reaction gates emissions on `isPinching || isSettling` — onZoomChange only fires once both are false (gesture truly finished). Programmatic / non-pinch zoom changes still emit. 2. DraggingEvent/DraggableEvent border compensation always-on, with base values sourced from consumer's `containerStyle`. Earlier we tried "consumer provides containerStyle → library steps back entirely" to fix library-borders leaking through consumer's shorthand `borderWidth`. That fixed the leak but lost zoom compensation, so a consumer setting `borderWidth: 2` saw its borders visibly stretch top/bottom at high zoom. Now the library always applies a `sideBordersStyle` (borderLeftWidth/RightWidth, static) plus an `outlineBorderStyle` (borderTopWidth/BottomWidth/borderRadius, animated as base / zoom) AFTER the consumer's containerStyle in the merge array. The base values prefer `containerStyle.borderWidth` / `containerStyle.borderRadius`, then `theme.eventContainerStyle`, then the library default (3 / 4). RN's per-side border merging means our computed sides win over the consumer's shorthand — which is exactly what gets us symmetric, zoom-stable borders regardless of which layer set the base value. Consumer drag-to-edit (`borderWidth: 0`) gets 0 on all sides at every zoom. Consumer drag-to-create (`borderWidth: 2`) gets 2 on all sides at every zoom.
…om release usePinchToZoom previously clamped `zoomScale` to [minZoomScale - 5% range, maxZoomScale + 5% range] in onUpdate to give a rubber-band feel when pinching past limits. On release, `onEnd` sprang `zoomScale` back to the unpadded clamp. That spring caused a visible "weird ScrollView-like bounce" on Fabric: - Per-frame in onUpdate we set the focal-anchored scroll offset using the live (possibly-overshoot) zoomScale. - On release we instantly snapped offsetY to a target computed for the post-clamp zoomScale, while zoomScale itself was still overshooting and animating down via withSpring. - During the spring the rendered content size shrank (driven by the animating zoomScale) but the scroll offset was already at the post-clamp target → focal point appeared to jump and then "bounce" back as the value settled. On Paper, content-size and scroll-offset commits were batched through UIManager so the transition was seamless. Fabric commits each independently, exposing the mismatch. Fix: clamp directly to [min, max] in onUpdate. No overshoot, no spring on release, no bounce. The defensive spring branch in onEnd is kept (still fires if zoomScale ever drifts out of range, e.g. via programmatic changes) but won't run in normal pinch flow. Trade-off: pinching past the zoom limits stops dead instead of springing — the rubber-band feedback is gone. Acceptable on New Arch.
…lumn Two fixes that surfaced when testing in the consumer app: 1. Now-indicator showed through stale (mounted-but-inactive) BodyItems. Virtualization keeps neighbour day pages mounted in the drawDistance buffer, so after scrolling away from today in single-day mode, today's BodyItem stayed mounted and rendered its NowIndicator — visibly leaking into view during/after the horizontal swipe. Added an `activeDayUnix` read (via useDateChangedListener) and gate the indicator on BOTH `today in this BodyItem's visibleDates` AND `activeDay in this BodyItem's visibleDates`. Single-day collapses to "activeDay === today"; multi-day means "today is in the currently-visible week". Matches what users expect. 2. NowIndicator container had `zIndex: 1` while TimeColumn has `zIndex: 998`, so the indicator painted beneath the time labels. Raised to 999 so it draws across the time-label column.
294b136 to
74245bb
Compare
NowIndicator now renders as a sibling of the horizontal CalendarList inside the vertical body scroll, instead of inside each per-day BodyItem. The chip+line scrolls vertically with the timeline and stays static during horizontal page swipes — so a custom consumer chip no longer rides across days during swipe. Container spans the full body width: a consumer NowIndicatorComponent laid out as chip-on-left + flex:1 line lands the chip over the TimeColumn area and the line across the entire visible row. Hidden via inRange check when the active page does not contain today. NowIndicatorResource (multi-resource mode) is unchanged.
…nt over UnavailableHours The body-level grid-line overlay (introduced in ba81e5a as a Fabric pinch perf fix) sits BEFORE the scaled inner container, so opaque UnavailableHours backgrounds paint over the lines — leaving non-working hours visually blank. Move the lines back into TimelineBoard (sibling of UnavailableHours → paints on top of unavailable bgs, below Events). To keep the perf gain of not running per-line useAnimatedStyle, wrap all lines in a single counter-scale Animated.View: height = totalH * zoomScale transform = [translateY(totalH*(1-Z)/2), scaleY(1/Z)] Children are static percentage-positioned lines (height: 1). Math: parent inner-scale's scaleY(Z) × wrapper's scaleY(1/Z) = net 1 → 1px preserved at any zoom. Wrapper's animated layout height drives child percentage positions → lines spread with zoom. One useAnimatedStyle per mounted BodyItem (~5). Body-level overlay now renders only the small hour-tick markers (which peek into the TimeColumn area via negative left).
Targets the per-frame work that drops UI and JS FPS on Fabric when
events/header are dense.
CalendarBody:
- counterScaleStyle now reads from a stepped useDerivedValue (0.1-zoom
steps). The style is consumed by ~100 nodes (event titles, hour /
half-hour / quarter-hour labels, NowIndicator, drag overlays), so
driving the transform off the raw zoomScale meant every consumer
committed a transform on every pinch frame. The stepped source means
consumer worklets only re-fire at step crossings.
usePinchToZoom:
- Per-frame scrollTo on the vertical list is gone. Focal-anchor scroll
movement is folded into innerScaleStyle's translateY via a new
pinchScrollDelta SharedValue, so the scroll position and scaleY
transform commit on the same node in the same frame. At gesture end
we commit the accumulated delta back into offsetY + one scrollTo and
reset the delta. Removes the cross-node scroll/transform commit race
that produced the visible "shake" of the scrollable surface.
useSyncedList:
- Throttle selection haptic to a minimum 80ms gap so a fast cross-week
swipe doesn't queue ~7 back-to-back native haptic calls.
- Skip the `visibleWeeks` shared-value write when the new range is
structurally equal to the previous (avoids waking downstream UI
consumers per column).
- Drop the `runOnUI(() => { sv.value = x })()` wrapper around the
per-column write to `visibleDateUnixAnim` — direct JS-thread
assignment to a SharedValue is sufficient and skips a closure
allocation + UI thread schedule per column change.
EventsProvider / UnavailableHoursProvider:
- Cache the date / range / inputs the store was last computed against.
Skip the recompute when the new center is still inside the cached
window minus a one-page margin and inputs haven't changed.
notifyDataChanged is O(events × processing) and was rerunning on
every column boundary, blocking JS for tens of ms.
ResourceHeaderItem:
- React.memo. Library's header CalendarList re-invokes renderHeaderItem
on every column boundary; without memo, consumers passing dense
resource subtrees re-rendered the resources.map() tree per crossing.
EventItem:
- Drop per-event animated counter-scale of borderRadius. Use the
theme's base radius statically. Eliminates N event-node transform
commits per pinch frame. The corner curve elongates slightly along
Y at high zoom (no asymmetric radii in RN) — accepted trade-off.
useLinkedScrollGroup:
- Gate the per-peer scrollTo to ≥1px drift. Fabric still commits a
native scroll event on the peer even when the offset hasn't changed;
the 1px gate eliminates redundant commits on the linked header.
CalendarList:
- visibleRange now lives in state directly; the animated reaction
computes range + column on the UI thread and only runOnJS-dispatches
when either crosses a boundary. Intra-range scroll movements cause
zero JS commits where previously every native scroll frame fired
runOnJS(setScrollOffset).
- isScrollable reads internalOffset.value (shared value) instead of
the removed per-frame state.
apps/example:
- Per-weekday unavailable-hours Record (kept for future testing of the
date-flash bug class).
…bric
At gesture end we both call scrollTo (native scroll command) and reset
pinchScrollDelta to 0. On Fabric the SV write propagates immediately to
innerScaleStyle, but scrollTo's contentOffset commit lands ~1 frame
later — so for one frame the transform has already dropped its delta
while ScrollView is still at the gesture-start offset. Result: a few
pixels of visible jump (down on zoom-in, up on zoom-out) right at
release.
withDelay(16, withTiming(0, {duration:0})) holds the delta for ~1
frame, by which point the native scroll has caught up. The transition
is then atomic from the user's perspective.
The translateY-based approach (Alt 3) was introduced to remove a "shake of the scrollable surface" that was visible in dev/simulator. After release-device testing it was clear that: 1) the original per-frame scrollTo didn't actually shake on real hardware — the shake was a dev-build artifact, and 2) the translateY approach added a one-frame visual mismatch on gesture release (a few pixels jump down on zoom-in, up on zoom-out) because resetting `pinchScrollDelta.value = 0` propagated immediately while the compensating `scrollTo` landed one Fabric frame later. An earlier attempt to defer the delta reset with `withDelay` instead produced a visible "double jump." Going back to the simpler per-frame scrollTo: - usePinchToZoom.onUpdate writes `offsetY` and calls `scrollTo` every frame (as it did originally). - usePinchToZoom.onEnd no longer reconciles a delta — the only scrollTo at end happens on the overscroll-spring path. - `pinchScrollDelta` SharedValue and its plumbing through CalendarBody are removed.
…lta" This reverts commit 116e186.
Experimental fix for the pinch-end visual jump on Fabric release-device.
Instead of resetting `pinchScrollDelta` to 0 immediately on gesture end
(which races with the native scrollTo and produces a few-pixel jump),
keep the delta and let `innerScaleStyle`'s translateY auto-compensate
based on the live scroll position.
Mechanics:
- Add `useScrollViewOffset(verticalListRef)` to read the live native
scroll position into a SharedValue.
- Add `pinchEndTarget` SV — armed at gesture end, cleared once scroll
arrives. Acts as the gate for the auto-compensation residual.
- innerScaleStyle.translateY becomes:
zoomT + pinchScrollDelta + (gated residual)
where residual = scrollOffsetLive - pinchStartOffsetY while
pinchEndTarget is armed, 0 otherwise.
- useAnimatedReaction watches scrollOffsetLive. When it arrives within
0.5px of pinchEndTarget, re-baselines startOffsetY + zeros the delta
+ clears pinchEndTarget — atomically in one worklet tick.
The residual only contributes during the settle window. After the
reaction fires, the gate flips and subsequent vertical scrolls don't
trigger auto-compensation.
Math verification: visual position is invariant through the transition
(pre-end, mid-settle, post-settle).
Branch is experimental — needs release-device verification before
merging anywhere.
Reports of small residual jumps even with the auto-compensating translateY suggest the native ScrollView is occasionally consuming a slice of the finger movement during a pinch (pan + pinch coexist on the same view, with `simultaneousHandlers` allowing both to fire). Any native scroll during pinch shifts `scrollOffsetLive`, which throws off the focal-anchor math and leaves a small residual at gesture end. Bridge `isPinching` SV → `scrollEnabled` React state via useAnimatedReaction. ScrollView is locked while pinching, re-enabled when the gesture finalizes. The state only toggles twice per pinch so the React re-render is cheap.
Android's native ScrollView doesn't re-attach pan handling cleanly after scrollEnabled flips false → true mid-touch, leaving vertical scroll dead until next down event. Android's gesture recognizer already rejects pan during pinch, so the lock isn't needed there.
This was referenced Jun 1, 2026
try(pinch): Wave 1 — snapshot + defensive reset + robust arrival + deferred-reenable scroll-lock
#29
Closed
…e+lock Each platform gets the approach that works for it: - Android (onUpdate): per-frame scrollTo on the underlying ScrollView. The focal-anchor target maps directly into contentOffset every frame, with no residual to reconcile on release. No scroll-lock needed. - iOS (onUpdate): translate-only via pinchScrollDelta folded into innerScaleStyle's translateY. Combined with the iOS-only scrollEnabled lock in CalendarBody, this avoids the Fabric commit-order shake the per-frame scrollTo approach exhibited on iOS. - Android (onEnd): early-return after resetting scale trackers. The ScrollView is already at the focal-anchor target from the last per-frame scrollTo. - iOS (onEnd): unchanged — fires scrollTo once + arms pinchEndTarget for the auto-compensate reaction. Drop the dead spring-overshoot branch: zoomScale is clamped in onUpdate (no rubber-band), so finalZoomScale always equals zoomScale.value at onEnd. The withSpring import + SPRING_DAMPING/SPRING_STIFFNESS constants + isSettling destructure were only used by that dead branch.
4 tasks
Zooming out near the top produced a negative target scroll offset. The iOS gesture-end settle reaction waits for the real contentOffset to reach that target, but a ScrollView can never rest above the top edge — so the reaction stayed armed forever, its auto-compensation canceling every later vertical scroll (scroll appeared stuck, with a phantom gap at the top until the next pinch). Clamp the focal-anchor target to [0, contentHeight - viewport] in onUpdate and onEnd, and reset pinchEndTarget on gesture begin as a self-heal for any stale armed state.
NowIndicatorResource previously started at startLeft=hourWidth with a single columnWidth, so the chip+line only covered the body and never reached the time column in multi-resource (scroll-by-provider) mode. Match the body-level NowIndicator geometry: startLeft=0 and width = hourWidth + columns * columnWidth, so the consumer chip lands over the TimeColumn and the flex:1 line spans the full visible width.
Horizontal grid lines were relocated from a body-level overlay into TimelineBoard (so they paint over opaque UnavailableHours), but ResourceBoard never received an equivalent layer — multi-resource grids lost their hour rows. Add the same counter-scaled horizontal-line wrapper as a sibling after UnavailableHoursByResource: paints over the unavailable-hours background, below events, and stays 1px at any zoom.
…r; re-commit blank pages iOS Fabric can't smoothly coordinate the scaleY zoom transform with native scroll (per-frame scrollTo shakes; deferring jumps). iOS: freeze the scroll during the pinch and anchor to the viewport center via a transform translate (no scrollTo → no shake), reconciling the scroll once on release. Android has no such race, so it keeps the original focal-point per-frame scrollTo (finger-anchored, settled at release). Also: a freshly-virtualized BodyItem (mounted while zoomed) bumps a commitTick so the persistent scaleY re-commits and Fabric composites the new page — fixes pages rendering blank until a manual pinch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Pinch, scroll, and grid-line perf on Fabric
Targets per-frame UI/JS commits that hurt FPS on the New Architecture when the calendar tree is dense (many events, hour labels, header items). Each section below lists the problem and the fix.
1. Pinch counter-scale stepped at the source
Problem:
counterScaleStylewas auseAnimatedStylereadingzoomScale.valuedirectly. It's a single style handle but referenced by ~100 consumer nodes — every event title wrapper, hour / half-hour / quarter-hour label (~96), NowIndicator, drag overlays. On every pinch frame the worklet re-fired and each consumer node committed a new transform.Fix: route the style through a stepped
useDerivedValue(rounds zoom to nearest 0.1). Reanimated dep-tracksuseAnimatedStyleon the SVs it reads — when the stepped SV doesn't change between frames the worklet doesn't fire and consumer nodes don't commit. Counter-scale "steps" through zoom in 10% increments (visually imperceptible).2. Per-frame React re-render in
CalendarListremovedProblem: the
useAnimatedReactionon horizontalscrollOffsetAnimdidrunOnJS(setScrollOffset)every native scroll frame. React state changed →visibleRangeuseMemorecomputed →CalendarListre-rendered at 60 Hz during swipe.Fix: the prepare worklet now computes
visibleRange+ column index on the UI thread and onlyrunOnJS-dispatches when a boundary is actually crossed. Intra-range scroll movements cause zero JS commits.3.
useLinkedScrollGroup1-pixel scrollTo gateProblem: peer
scrollTofired on every native scroll frame, producing a native scroll commit on the header ScrollView even when the offset hadn't drifted from the previous frame.Fix: skip the
scrollToif delta is < 1px.4.
useSyncedListper-column-change JS workProblems:
hapticService.selection()per column → 7 native haptic calls per week swipe.parseDateTime+ ISO string allocated even when no consumer was listening.visibleWeeks.value = [...]written on every column boundary even when structurally equal.runOnUI(() => sv.value = x)()closure allocation + cross-thread schedule per column.Fix: throttle haptic to ≥80 ms gap; build ISO only when
onChangeis set; structural-equality gate onvisibleWeeks; direct JS-thread SV assignment (norunOnUIwrapper).5.
EventsProvider/UnavailableHoursProviderheavy recomputeProblem:
notifyDataChangedrunsfilterEvents→processEventOccurrences→populateEventsoverlap-packing — O(events × processing) per day in the range. Re-fired on every date change AND every consumer prop change (events ref instability).Fix: cache the inputs and range the store was last computed against. Skip the recompute when the new center is still inside the cached window minus one page of margin and inputs are unchanged.
6.
ResourceHeaderItemmemoizedProblem: library re-invokes
renderHeaderItemon every column boundary; the consumer's denseresources.map()re-rendered the avatar+text tree per crossing.Fix:
React.memowrap.7. Per-event
borderRadiusStyleremovedProblem: per-event
useAnimatedStylereadingzoomScale.valuedirectly → N event-node transform commits per pinch frame.Fix: static
baseBorderRadius. Corner curve elongates slightly along Y at high zoom (RN can't express asymmetric radii) — accepted trade-off.8. Grid lines + NowIndicator at body level
Lines and NowIndicator were lifted from per-BodyItem renders to single body-level wrappers (single animated style each), so they don't multiply across virtualized BodyItems.
9. Pinch: platform-split (Android per-frame
scrollTo, iOS translate + lock)Background: per-frame
scrollToinonUpdatewas the original approach that worked smoothly on Android, but it caused a visible "shake" of the scrollable surface on iOS Fabric (separate native scroll commit racing the inner-scale transform commit). The translate-only iOS workaround (write apinchScrollDeltaSV folded intoinnerScaleStyle.translateY) fixed iOS but never had a clean Android end-of-gesture story — Android's gesture handler leaks finger motion into native scroll concurrent with the pinch, leaving a residual offset on release that showed as a visible jump.Several intermediate attempts were tried and reverted: drift-absorb via
useScrollViewOffset, robust arrival reaction with crossed-through detection, deferred-reenable scroll-lock on Android, stepped layout-prop animated styles, consolidated scroll source. Each fixed one symptom and introduced another.Final shape — give each platform what works for it:
onUpdate:offsetY.value = newOffsetY; scrollTo(verticalListRef, 0, newOffsetY, false). The focal-anchor target maps directly into ScrollView contentOffset every frame.pinchScrollDeltastays at 0 soinnerScaleStyle.translateYis justzoomT. No scroll-lock needed (we want the ScrollView to follow the pinch).onUpdate: translate-only viapinchScrollDeltafolded intoinnerScaleStyle.translateY. Combined with the iOS-onlyscrollEnabledlock inCalendarBody,scrollOffsetLivestays stable through the pinch and the gesture-end transition lands cleanly viapinchEndTarget+ auto-compensate reaction.onEnd: early-return after resetting scale trackers. ScrollView already at target from per-frame scrollTo.onEnd: existing — firesscrollToonce, armspinchEndTarget, auto-compensate reaction clears state when scroll arrives.Bonus cleanup: dropped the dead
withSpringovershoot branch —zoomScaleis clamped inonUpdatealready, sofinalZoomScale !== zoomScale.valuewas never true atonEnd. ThewithSpringimport,SPRING_DAMPING/SPRING_STIFFNESSconstants, andisSettlingdestructure that only fed that branch are removed.🤖 Generated with Claude Code