[🐴] 60 FPS Keyboard (#4066)

* use `scrollTo`

* let the animated reaction handle keyboard scroll

* no need for `requestAnimationFrame` now

* 'worklet'

* nit

* fixes

* more nits

* bool check
zio/stable
Hailey 2024-05-16 17:12:41 -07:00 committed by GitHub
parent eb1428b1d8
commit 808511617d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 45 additions and 32 deletions

View File

@ -2,8 +2,11 @@ import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View} from 'react-native'
import Animated, { import Animated, {
runOnJS, runOnJS,
runOnUI,
scrollTo,
useAnimatedKeyboard, useAnimatedKeyboard,
useAnimatedReaction, useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
@ -65,7 +68,7 @@ export function MessagesList() {
const t = useTheme() const t = useTheme()
const convo = useConvoActive() const convo = useConvoActive()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const flatListRef = useRef<FlatList>(null) const flatListRef = useAnimatedRef<FlatList>()
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false) const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
@ -86,9 +89,21 @@ export function MessagesList() {
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false) const isMomentumScrolling = useSharedValue(false)
const hasInitiallyScrolled = useSharedValue(false) const hasInitiallyScrolled = useSharedValue(false)
const keyboardIsOpening = useSharedValue(false) const keyboardIsAnimating = useSharedValue(false)
const layoutHeight = useSharedValue(0) const layoutHeight = useSharedValue(0)
// Since we are using the native web methods for scrolling on `List`, we only use the reanimated `scrollTo` on native
const scrollToOffset = React.useCallback(
(offset: number, animated: boolean) => {
if (isWeb) {
flatListRef.current?.scrollToOffset({offset, animated})
} else {
runOnUI(scrollTo)(flatListRef, 0, offset, animated)
}
},
[flatListRef],
)
// Every time the content size changes, that means one of two things is happening: // Every time the content size changes, that means one of two things is happening:
// 1. New messages are being added from the log or from a message you have sent // 1. New messages are being added from the log or from a message you have sent
// 2. Old messages are being prepended to the top // 2. Old messages are being prepended to the top
@ -104,16 +119,12 @@ export function MessagesList() {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// previous off whenever we add new content to the previous offset whenever we add new content to the list. // previous off whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasInitiallyScrolled.value) { if (isWeb && isAtTop.value && hasInitiallyScrolled.value) {
flatListRef.current?.scrollToOffset({ scrollToOffset(height - contentHeight.value, false)
animated: false,
offset: height - contentHeight.value,
})
} }
// This number _must_ be the height of the MaybeLoader component // This number _must_ be the height of the MaybeLoader component
if (height > 50 && (isAtBottom.value || keyboardIsOpening.value)) { if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) {
let newOffset = height let newOffset = height
// If the size of the content is changing by more than the height of the screen, then we should only // If the size of the content is changing by more than the height of the screen, then we should only
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be // scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
// really large - and the normal chat behavior would be to still scroll to the end if it's only one // really large - and the normal chat behavior would be to still scroll to the end if it's only one
@ -126,11 +137,7 @@ export function MessagesList() {
newOffset = contentHeight.value - 50 newOffset = contentHeight.value - 50
setShowNewMessagesPill(true) setShowNewMessagesPill(true)
} }
scrollToOffset(newOffset, hasInitiallyScrolled.value)
flatListRef.current?.scrollToOffset({
animated: hasInitiallyScrolled.value && !keyboardIsOpening.value,
offset: newOffset,
})
isMomentumScrolling.value = true isMomentumScrolling.value = true
} }
contentHeight.value = height contentHeight.value = height
@ -138,13 +145,15 @@ export function MessagesList() {
}, },
[ [
contentHeight, contentHeight,
hasInitiallyScrolled.value, scrollToOffset,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling, isMomentumScrolling,
layoutHeight.value,
convo.items.length, convo.items.length,
keyboardIsOpening.value, // All of these are stable
isAtBottom.value,
keyboardIsAnimating.value,
layoutHeight.value,
hasInitiallyScrolled.value,
isAtTop.value,
], ],
) )
@ -212,8 +221,8 @@ export function MessagesList() {
showNewMessagesPill, showNewMessagesPill,
isAtBottom, isAtBottom,
isAtTop, isAtTop,
contentHeight.value,
hasInitiallyScrolled, hasInitiallyScrolled,
contentHeight.value,
], ],
) )
@ -223,13 +232,10 @@ export function MessagesList() {
}, [isMomentumScrolling]) }, [isMomentumScrolling])
const scrollToEnd = React.useCallback(() => { const scrollToEnd = React.useCallback(() => {
requestAnimationFrame(() => { if (isMomentumScrolling.value) return
if (isMomentumScrolling.value) return scrollToOffset(contentHeight.value, true)
isMomentumScrolling.value = true
flatListRef.current?.scrollToEnd({animated: true}) }, [contentHeight.value, isMomentumScrolling, scrollToOffset])
isMomentumScrolling.value = true
})
}, [isMomentumScrolling])
// -- Keyboard animation handling // -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard() const animatedKeyboard = useAnimatedKeyboard()
@ -239,18 +245,25 @@ export function MessagesList() {
const bottomOffset = const bottomOffset =
isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight
// We need to keep track of when the keyboard is animating and when it isn't, since we want our `onContentSizeChanged` // On web, we don't want to do anything.
// callback to animate the scroll _only_ when the keyboard isn't animating. Any time the previous value of kb height // On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
// is different, we know that it is animating. When it finally settles, now will be equal to prev. // on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction( useAnimatedReaction(
() => animatedKeyboard.height.value, () => animatedKeyboard.height.value,
(now, prev) => { (now, prev) => {
'worklet'
// This never applies on web // This never applies on web
if (isWeb) { if (isWeb) {
keyboardIsOpening.value = false keyboardIsAnimating.value = false
} else { return
keyboardIsOpening.value = now !== prev
} }
// We only need to scroll to end while the keyboard is _opening_. During close, the position changes as we
// "expand" the view.
if (prev && now > prev) {
scrollTo(flatListRef, 0, contentHeight.value + now, false)
}
keyboardIsAnimating.value = Boolean(prev) && now !== prev
}, },
) )