[🐴] 60 FPS Keyboard (#4066)
* use `scrollTo` * let the animated reaction handle keyboard scroll * no need for `requestAnimationFrame` now * 'worklet' * nit * fixes * more nits * bool checkzio/stable
parent
eb1428b1d8
commit
808511617d
|
@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue