Skip to content

perf(pinch): fix Fabric pinch shake and restructure counter-scaling#28

Draft
anton-patrushev wants to merge 24 commits into
fix/android-stale-paint-overlapfrom
fix/timeline-height-anim-style
Draft

perf(pinch): fix Fabric pinch shake and restructure counter-scaling#28
anton-patrushev wants to merge 24 commits into
fix/android-stale-paint-overlapfrom
fix/timeline-height-anim-style

Conversation

@anton-patrushev

@anton-patrushev anton-patrushev commented May 29, 2026

Copy link
Copy Markdown
Owner

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: counterScaleStyle was a useAnimatedStyle reading zoomScale.value directly. 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-tracks useAnimatedStyle on 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 CalendarList removed

Problem: the useAnimatedReaction on horizontal scrollOffsetAnim did runOnJS(setScrollOffset) every native scroll frame. React state changed → visibleRange useMemo recomputed → CalendarList re-rendered at 60 Hz during swipe.

Fix: the prepare worklet now computes visibleRange + column index on the UI thread and only runOnJS-dispatches when a boundary is actually crossed. Intra-range scroll movements cause zero JS commits.

3. useLinkedScrollGroup 1-pixel scrollTo gate

Problem: peer scrollTo fired 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 scrollTo if delta is < 1px.

4. useSyncedList per-column-change JS work

Problems:

  • 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 onChange is set; structural-equality gate on visibleWeeks; direct JS-thread SV assignment (no runOnUI wrapper).

5. EventsProvider / UnavailableHoursProvider heavy recompute

Problem: notifyDataChanged runs filterEventsprocessEventOccurrencespopulateEvents overlap-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. ResourceHeaderItem memoized

Problem: library re-invokes renderHeaderItem on every column boundary; the consumer's dense resources.map() re-rendered the avatar+text tree per crossing.

Fix: React.memo wrap.

7. Per-event borderRadiusStyle removed

Problem: per-event useAnimatedStyle reading zoomScale.value directly → 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 scrollTo in onUpdate was 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 a pinchScrollDelta SV folded into innerScaleStyle.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:

  • Android onUpdate: offsetY.value = newOffsetY; scrollTo(verticalListRef, 0, newOffsetY, false). The focal-anchor target maps directly into ScrollView contentOffset every frame. pinchScrollDelta stays at 0 so innerScaleStyle.translateY is just zoomT. No scroll-lock needed (we want the ScrollView to follow the pinch).
  • iOS onUpdate: translate-only via pinchScrollDelta folded into innerScaleStyle.translateY. Combined with the iOS-only scrollEnabled lock in CalendarBody, scrollOffsetLive stays stable through the pinch and the gesture-end transition lands cleanly via pinchEndTarget + auto-compensate reaction.
  • Android onEnd: early-return after resetting scale trackers. ScrollView already at target from per-frame scrollTo.
  • iOS onEnd: existing — fires scrollTo once, arms pinchEndTarget, auto-compensate reaction clears state when scroll arrives.

Bonus cleanup: dropped the dead withSpring overshoot branch — zoomScale is clamped in onUpdate already, so finalZoomScale !== zoomScale.value was never true at onEnd. The withSpring import, SPRING_DAMPING/SPRING_STIFFNESS constants, and isSettling destructure that only fed that branch are removed.


🤖 Generated with Claude Code

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.
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.
@anton-patrushev anton-patrushev force-pushed the fix/timeline-height-anim-style branch from 294b136 to 74245bb Compare May 29, 2026 17:53
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.
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.
…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.
anton-patrushev and others added 4 commits June 2, 2026 13:20
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant