Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/DAY_ANIMATED_SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# DayAnimated (floating day header) - animation spec

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.

## 1. Reference behaviour (Telegram iOS, what we replicate)

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:

1. **Inline separator** - a pill at the top of each day's group, scrolls with the
content like a normal row.
2. **Floating header** - while you scroll, the date of the *topmost visible day*
sticks just under the nav bar.

Behaviour:

- **Scroll-gated visibility.** Hidden at rest. Fades in fast when scrolling
starts, stays fully opaque for the *entire gesture* (drag + momentum, including
slow drags and pauses), fades out shortly after motion fully stops.
- **Which date.** The day of the messages at the top of the viewport (the "stuck"
day), pinned at a small offset under the nav bar.
- **Slide, not fade.** At a day boundary the date *slides*, it never cross-fades.
- Scrolling into an **older** day (scroll up / toward top): the new (older) date
**slides down from the top edge to the pin, pixel by pixel**, as the previous
date slides down below it.
- Scrolling into a **newer** day (scroll down / toward bottom): the newer date
rises from below to the pin and the previous date is pushed up and off.
- During the transition the two dates are **different**, separated by a margin,
moving together. Never two of the same date; never a dip to 0; never a jump.
- **Top of history / "Load earlier".** At the very top the oldest day is stuck.
Once the oldest day's own separator drops back below the pin (the "Load earlier"
button scrolls in) nothing is stuck: the header hands the date back to the inline
separator and hides - no duplicate over the loader. While actively loading, the
header tucks above the top edge.

## 2. Structure & geometry

- Inverted `FlatList`. `daysPositions` (shared value) holds, per day, the cell's
`{ y, height, createdAt }` measured via `onLayout`.
- Two elements in different view trees: inline separators (`AnimatedDayWrapper` in
`Item`) and the floating overlay (`DayAnimated`).
- On-screen Y of a day separator's top edge (dp):
`separatorScreenTop = (listHeight + scrolledY) - (day.y + day.height)`
- The inline pill renders `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.)

## 3. Mechanics (final implementation)

### Stuck-day selection (`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.
- The stuck day = the newest day whose `separatorScreenTop <= DAY_HANDOFF_OFFSET`.

### Position - the scroll-driven slide (`DayAnimated`, worklet)
- The floating header's `top` is positioned off the *next (newer)* day's separator:
`top = min(DAY_PIN_OFFSET, nextSeparatorScreenTop + DAY_MARGIN_TOP - headerHeight - DAY_PUSH_GAP)`
- When the next separator is far below, `top = DAY_PIN_OFFSET` (pinned).
- As the next separator nears the pin the header slides with it, pixel by pixel:
it slides **down from the top edge** as an older day takes over (scrolling up), or
is **pushed up and off** as a newer day rises (scrolling down). `DAY_PUSH_GAP`
keeps a margin between the outgoing and incoming pills.
- While `isLoading`, `top = -headerHeight` (tucked above the top).

### Handoff opacity - hard cutoffs (no fades)
The date stays solid (opacity 1) through the floating <-> inline handoff because
both sides hard-cut at the same pixel, rather than cross-fading:
- **Inline separator** (`Item`): `opacity = (belowHandoff || !headerShowsThisDay) ? 1 : 0`
where `belowHandoff = separatorScreenTop > DAY_HANDOFF_OFFSET` and
`headerShowsThisDay = floatingRenderedDate === this day's createdAt`.
- **Floating header `stuckGate`** (`DayAnimated`): `curSep <= DAY_HANDOFF_OFFSET ? 1 : 0`
(hard hide when nothing is stuck - the top-of-history / loader case).

### Render gate - covering the JS-thread text lag
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`).
- Header `renderGate = sticky.createdAt === floatingRenderedDate ? 1 : 0` - the
header is hidden for any frame where its text hasn't caught up.
- During that frame the inline separator stays up (`!headerShowsThisDay`) and shows
the correct date. Net: header opacity = `fade * stuckGate * renderGate`.

### Visibility / fade (`DayAnimated` + `MessagesContainer`)
- Driven by an `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.

## 4. Constants (`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.

## 5. Debug switches (`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`.

## 6. Verified edge cases (Android emulator, multi-day data + Load earlier)
- [x] Handoff between days, both directions: solid date, no dip, no duplicate, gap.
- [x] Scroll into newer day (top->bottom): no 1-frame wrong-date flash (render gate).
- [x] "Load earlier" at the top: no duplicate over the loader; tucks while loading;
correct day after prepend.
- [x] At rest: header hidden (Telegram shows the date only while scrolling).
- [~] Fast fling across many days: during a very fast fling the header may stay
hidden (render gate never settles) and the inline separators carry the dates;
self-corrects as scrolling slows. Acceptable.
3 changes: 2 additions & 1 deletion example/app/(tabs)/explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useThemeColor } from '@/hooks/use-theme-color'

type ChatExample = 'basic' | 'customized-rendering' | 'slack' | 'links' | 'reply'
type ChatExample = 'basic' | 'customized-rendering' | 'slack' | 'links' | 'reply' | 'day-animated'

const examples: Array<{ id: ChatExample, title: string, description: string }> = [
{ id: 'basic', title: 'Basic Example', description: 'Basic chat with keyboard logging for testing' },
{ id: 'links', title: 'Links & Patterns', description: 'Phone numbers, emails, URLs, hashtags, and mentions' },
{ id: 'customized-rendering', title: 'Customized Rendering', description: 'Customized chat with all rendering options' },
{ id: 'slack', title: 'Slack Style', description: 'Slack-like message styling' },
{ id: 'reply', title: 'Reply Example', description: 'Example demonstrating reply functionality' },
{ id: 'day-animated', title: 'Day Animated', description: 'Multi-day chat with Load earlier for testing the animated day header' },
]

export default function ExploreScreen () {
Expand Down
12 changes: 6 additions & 6 deletions example/app/chat/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TouchableOpacity, Text, StyleSheet } from 'react-native'
import { TouchableOpacity, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { Stack, useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
Expand All @@ -11,11 +11,11 @@ export default function ChatLayout () {
<Stack
screenOptions={{
headerShown: true,
headerTitleAlign: 'center',
contentStyle: { paddingBottom: insets.bottom, backgroundColor: '#fff' },
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name='chevron-back' size={24} color='#007AFF' />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
),
}}
Expand All @@ -40,6 +40,10 @@ export default function ChatLayout () {
name='slack'
options={{ title: 'Slack Style' }}
/>
<Stack.Screen
name='day-animated'
options={{ title: 'Day Animated' }}
/>
</Stack>
)
}
Expand All @@ -50,8 +54,4 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginLeft: -8,
},
backText: {
color: '#007AFF',
fontSize: 17,
},
})
3 changes: 3 additions & 0 deletions example/app/chat/day-animated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import DayAnimatedExample from '@/components/chat-examples/DayAnimatedExample'

export default DayAnimatedExample
111 changes: 111 additions & 0 deletions example/components/chat-examples/DayAnimatedExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useCallback, useMemo, useState } from 'react'
import { StyleSheet, View, useColorScheme } from 'react-native'
import { GiftedChat, IMessage } from 'react-native-gifted-chat'
import { useKeyboardVerticalOffset } from '../../hooks/useKeyboardVerticalOffset'
import { getColorSchemeStyle } from '../../utils/styleUtils'

// Generates a labelled chat spanning several days so the floating/animated day
// header and the inline day separators can be exercised. Each "day" is a fixed
// number of days before today with a handful of messages, alternating sides.
const MESSAGES_PER_DAY = 6
const INITIAL_DAYS = 4
const LOAD_EARLIER_DAYS = 3

const generateDay = (dayOffset: number): IMessage[] => {
const messages: IMessage[] = []
for (let m = MESSAGES_PER_DAY - 1; m >= 0; m--) {
const createdAt = new Date()
createdAt.setDate(createdAt.getDate() - dayOffset)
createdAt.setHours(10, m, 0, 0)
const fromMe = m % 2 === 0
messages.push({
_id: `day-${dayOffset}-msg-${m}`,
text: `Day -${dayOffset} · message ${m}`,
createdAt,
user: fromMe
? { _id: 1, name: 'Developer' }
: { _id: 2, name: 'John Doe' },
})
}
return messages
}

// Inclusive range of day offsets, newest message first (descending createdAt).
const generateRange = (fromDayOffset: number, toDayOffset: number): IMessage[] => {
let messages: IMessage[] = []
for (let day = fromDayOffset; day <= toDayOffset; day++)
messages = messages.concat(generateDay(day))

return messages.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}

export default function DayAnimatedExample () {
const [messages, setMessages] = useState<IMessage[]>(() => generateRange(0, INITIAL_DAYS - 1))
const [oldestDayOffset, setOldestDayOffset] = useState(INITIAL_DAYS - 1)
const [isLoadingEarlier, setIsLoadingEarlier] = useState(false)
const colorScheme = useColorScheme()

const keyboardVerticalOffset = useKeyboardVerticalOffset()

const user = useMemo(() => ({
_id: 1,
name: 'Developer',
}), [])

const onSend = useCallback((newMessages: IMessage[] = []) => {
setMessages(previousMessages => GiftedChat.append(previousMessages, newMessages))
}, [])

const onPressLoadEarlierMessages = useCallback(() => {
setIsLoadingEarlier(true)
setTimeout(() => {
const from = oldestDayOffset + 1
const to = oldestDayOffset + LOAD_EARLIER_DAYS
setMessages(previousMessages =>
GiftedChat.prepend(previousMessages, generateRange(from, to))
)
setOldestDayOffset(to)
setIsLoadingEarlier(false)
}, 1500)
}, [oldestDayOffset])

return (
<View style={[styles.container, getColorSchemeStyle(styles, 'container', colorScheme)]}>
<GiftedChat
messages={messages}
onSend={onSend}
user={user}
loadEarlierMessagesProps={{
isAvailable: true,
isLoading: isLoadingEarlier,
onPress: onPressLoadEarlierMessages,
}}
messagesContainerStyle={getColorSchemeStyle(styles, 'messagesContainer', colorScheme)}
textInputProps={{
style: getColorSchemeStyle(styles, 'composer', colorScheme),
}}
keyboardAvoidingViewProps={{ keyboardVerticalOffset }}
isScrollToBottomEnabled
/>
</View>
)
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
container_dark: {
backgroundColor: '#000',
},
messagesContainer_dark: {
backgroundColor: '#000',
},
composer_dark: {
backgroundColor: '#1a1a1a',
color: '#fff',
},
})
47 changes: 47 additions & 0 deletions src/MessagesContainer/components/DayAnimated/debug.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useState } from 'react'
import { StyleSheet, Text } from 'react-native'
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'

// Debug switches for the animated day header. All OFF/1 for production - flip them
// here when working on the animation.
export const DAY_DEBUG = {
// Multiply the fade durations/delay so the fades can be captured frame-by-frame.
timeScale: 1,
// Keep the floating header at full opacity (ignore the scroll fade) so the
// slide/push geometry can be studied independently of the fade.
forceOpacity: false,
// Render an on-screen readout of the sticky worklet values.
overlay: false,
}

// On-screen readout for the header, driven by a worklet `select` that returns the
// readout string. Returns null (and runs nothing) when DAY_DEBUG.overlay is off.
export function useDayDebugOverlay (select: () => string, deps: unknown[]): React.ReactElement | null {
const [text, setText] = useState('')

useAnimatedReaction(
() => (DAY_DEBUG.overlay ? select() : ''),
value => {

Check warning on line 24 in src/MessagesContainer/components/DayAnimated/debug.tsx

View workflow job for this annotation

GitHub Actions / checks (24)

React Hook useAnimatedReaction has a missing dependency: 'select'. Either include it or remove the dependency array. If 'select' changes too often, find the parent component that defines it and wrap that definition in useCallback

Check warning on line 24 in src/MessagesContainer/components/DayAnimated/debug.tsx

View workflow job for this annotation

GitHub Actions / checks (24)

React Hook useAnimatedReaction was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies

Check warning on line 24 in src/MessagesContainer/components/DayAnimated/debug.tsx

View workflow job for this annotation

GitHub Actions / checks (22)

React Hook useAnimatedReaction has a missing dependency: 'select'. Either include it or remove the dependency array. If 'select' changes too often, find the parent component that defines it and wrap that definition in useCallback

Check warning on line 24 in src/MessagesContainer/components/DayAnimated/debug.tsx

View workflow job for this annotation

GitHub Actions / checks (22)

React Hook useAnimatedReaction was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies
if (value)
runOnJS(setText)(value)
},
deps
)

if (!DAY_DEBUG.overlay)
return null

return <Text style={styles.overlay}>{text}</Text>
}

const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 2,
left: 4,
fontSize: 11,
color: 'red',
zIndex: 9999,
backgroundColor: 'rgba(255,255,255,0.8)',
},
})
Loading
Loading