docs/DAY_ANIMATED_SPEC.md
How the Telegram-style sticky day header works in this library. Describes the
final implementation across DayAnimated, Item (inline separators),
MessagesContainer, and the shared dayLayout constants.
The conversation is an inverted list (newest at the bottom). Each day has a centered date pill. Two elements together read as one sticky header:
Behaviour:
FlatList. daysPositions (shared value) holds, per day, the cell's
{ y, height, createdAt } measured via onLayout.AnimatedDayWrapper in
Item) and the floating overlay (DayAnimated).separatorScreenTop = (listHeight + scrolledY) - (day.y + day.height)DAY_MARGIN_TOP below separatorScreenTop (Day's
container marginTop). The floating overlay overrides that margin to 0, so when it
is pinned at top = DAY_PIN_OFFSET its pill lines up exactly with an inline
separator whose separatorScreenTop == DAY_PIN_OFFSET - DAY_MARGIN_TOP. That
shared line is DAY_HANDOFF_OFFSET = DAY_PIN_OFFSET - DAY_MARGIN_TOP.
(Verified on device, all dp; the display-density factor cancels.)DayAnimated, worklet)daysPositionsArray is sorted by createdAt (newest first), NOT by measured
y. y jitters while cells are (re)measured mid-scroll; sorting by date keeps
the selection deterministic so it can't briefly jump to the wrong neighbour.separatorScreenTop <= DAY_HANDOFF_OFFSET.DayAnimated, worklet)top is positioned off the next (newer) day's separator:
top = min(DAY_PIN_OFFSET, nextSeparatorScreenTop + DAY_MARGIN_TOP - headerHeight - DAY_PUSH_GAP)top = DAY_PIN_OFFSET (pinned).DAY_PUSH_GAP
keeps a margin between the outgoing and incoming pills.isLoading, top = -headerHeight (tucked above the top).The date stays solid (opacity 1) through the floating <-> inline handoff because both sides hard-cut at the same pixel, rather than cross-fading:
Item): opacity = (belowHandoff || !headerShowsThisDay) ? 1 : 0
where belowHandoff = separatorScreenTop > DAY_HANDOFF_OFFSET and
headerShowsThisDay = floatingRenderedDate === this day's createdAt.stuckGate (DayAnimated): curSep <= DAY_HANDOFF_OFFSET ? 1 : 0
(hard hide when nothing is stuck - the top-of-history / loader case).The header's date text is React state, updated via runOnJS, so it lags the
worklet by ~1 frame. Without handling, scrolling into a newer day flashes the old
date at the pin (the header takes over on-screen there; scrolling into an older day
it takes over off-screen, so the lag is invisible - hence the bug was top->bottom
only). Fix:
DayAnimated publishes the date it is actually rendering to the
floatingRenderedDate shared value (a useEffect on the rendered createdAt).renderGate = sticky.createdAt === floatingRenderedDate ? 1 : 0 - the
header is hidden for any frame where its text hasn't caught up.!headerShowsThisDay) and shows
the correct date. Net: header opacity = fade * stuckGate * renderGate.DayAnimated + MessagesContainer)isScrollActive shared value set from the scroll handler's
onBeginDrag / onEndDrag / onMomentumBegin / onMomentumEnd - NOT from
per-scroll-delta idle timers. This keeps the header at full opacity for the whole
gesture (slow drags and pauses included) instead of flickering out on a pause.isScrollActive true -> fade in (FADE_IN_DURATION), cancel pending fade-out.
false -> schedule fade-out (FADE_OUT_DELAY then FADE_OUT_DURATION). A flick's
momentum re-asserts isScrollActive via onMomentumBegin within the delay, so it
stays visible through momentum.dayLayout.ts)DAY_PIN_OFFSET = 10 - resting top offset of the floating header.DAY_MARGIN_TOP = 5 - Day container marginTop baked into the inline rel math.DAY_HANDOFF_OFFSET = DAY_PIN_OFFSET - DAY_MARGIN_TOP - the shared handoff line.DAY_PUSH_GAP = 8 - margin kept between the two date pills during the slide.DAY_HANDOFF_FADE = 10 - legacy; no longer used by the hard-cutoff handoff.DayAnimated, all OFF/1 for production)DEBUG_TIME_SCALE - multiply fade durations/delay to capture fades frame-by-frame.DEBUG_FORCE_OPACITY - keep the header at full opacity to study geometry.DEBUG_OVERLAY - on-screen readout of top / curSep / headerHeight / load.