[🐴] Only scroll down one "screen" in height when foregrounding (#4027)
* maintain position after foreground * one possibility * don't overscroll when content size changes. * ignore the rule on 1 item * fix * [🐴] Pill for additional unreads when coming from background (#4043) * create a pill with some animatons * add some basic styles to the pill * make the animations reusable * bit better styling * rm logs --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> * import --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
parent
b15b49a48f
commit
ef0ce951e7
3 changed files with 136 additions and 12 deletions
47
src/components/dms/NewMessagesPill.tsx
Normal file
47
src/components/dms/NewMessagesPill.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated from 'react-native-reanimated'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ScaleAndFadeIn,
|
||||||
|
ScaleAndFadeOut,
|
||||||
|
} from 'lib/custom-animations/ScaleAndFade'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function NewMessagesPill() {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
React.useEffect(() => {}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.py_sm,
|
||||||
|
a.rounded_full,
|
||||||
|
a.shadow_sm,
|
||||||
|
a.border,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 70,
|
||||||
|
width: '40%',
|
||||||
|
left: '30%',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowOpacity: 0.125,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: {width: 0, height: 5},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
entering={ScaleAndFadeIn}
|
||||||
|
exiting={ScaleAndFadeOut}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={[a.font_bold]}>
|
||||||
|
<Trans>New messages</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
39
src/lib/custom-animations/ScaleAndFade.ts
Normal file
39
src/lib/custom-animations/ScaleAndFade.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import {withTiming} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
export function ScaleAndFadeIn() {
|
||||||
|
'worklet'
|
||||||
|
|
||||||
|
const animations = {
|
||||||
|
opacity: withTiming(1),
|
||||||
|
transform: [{scale: withTiming(1)}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
opacity: 0,
|
||||||
|
transform: [{scale: 0.7}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
animations,
|
||||||
|
initialValues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScaleAndFadeOut() {
|
||||||
|
'worklet'
|
||||||
|
|
||||||
|
const animations = {
|
||||||
|
opacity: withTiming(0),
|
||||||
|
transform: [{scale: withTiming(0.7)}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
opacity: 1,
|
||||||
|
transform: [{scale: 1}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
animations,
|
||||||
|
initialValues,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React, {useCallback, useRef} from 'react'
|
import React, {useCallback, useRef} from 'react'
|
||||||
import {FlatList, View} from 'react-native'
|
import {FlatList, View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
useAnimatedKeyboard,
|
useAnimatedKeyboard,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
@ -22,6 +23,7 @@ import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||||
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {MessageItem} from '#/components/dms/MessageItem'
|
import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
@ -65,6 +67,8 @@ export function MessagesList() {
|
||||||
const {getAgent} = useAgent()
|
const {getAgent} = useAgent()
|
||||||
const flatListRef = useRef<FlatList>(null)
|
const flatListRef = useRef<FlatList>(null)
|
||||||
|
|
||||||
|
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
|
||||||
|
|
||||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||||
// the bottom.
|
// the bottom.
|
||||||
|
@ -76,12 +80,14 @@ export function MessagesList() {
|
||||||
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
|
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
|
||||||
// onStartReached to fire.
|
// onStartReached to fire.
|
||||||
const contentHeight = useSharedValue(0)
|
const contentHeight = useSharedValue(0)
|
||||||
|
const prevItemCount = useRef(0)
|
||||||
|
|
||||||
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
|
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
|
||||||
// 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 keyboardIsOpening = useSharedValue(false)
|
||||||
|
const layoutHeight = useSharedValue(0)
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -96,7 +102,7 @@ export function MessagesList() {
|
||||||
const onContentSizeChange = useCallback(
|
const onContentSizeChange = useCallback(
|
||||||
(_: number, height: number) => {
|
(_: number, height: number) => {
|
||||||
// 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 offset 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({
|
flatListRef.current?.scrollToOffset({
|
||||||
animated: false,
|
animated: false,
|
||||||
|
@ -104,18 +110,31 @@ export function MessagesList() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHeight.value = height
|
|
||||||
|
|
||||||
// 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 || keyboardIsOpening.value)) {
|
||||||
return
|
let newOffset = height
|
||||||
}
|
|
||||||
|
|
||||||
flatListRef.current?.scrollToOffset({
|
// If the size of the content is changing by more than the height of the screen, then we should only
|
||||||
animated: hasInitiallyScrolled.value && !keyboardIsOpening.value,
|
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
|
||||||
offset: height,
|
// really large - and the normal chat behavior would be to still scroll to the end if it's only one
|
||||||
})
|
// message - we ignore this rule if there's only one additional message
|
||||||
isMomentumScrolling.value = true
|
if (
|
||||||
|
hasInitiallyScrolled.value &&
|
||||||
|
height - contentHeight.value > layoutHeight.value - 50 &&
|
||||||
|
convo.items.length - prevItemCount.current > 1
|
||||||
|
) {
|
||||||
|
newOffset = contentHeight.value - 50
|
||||||
|
setShowNewMessagesPill(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
animated: hasInitiallyScrolled.value && !keyboardIsOpening.value,
|
||||||
|
offset: newOffset,
|
||||||
|
})
|
||||||
|
isMomentumScrolling.value = true
|
||||||
|
}
|
||||||
|
contentHeight.value = height
|
||||||
|
prevItemCount.current = convo.items.length
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
contentHeight,
|
contentHeight,
|
||||||
|
@ -123,6 +142,8 @@ export function MessagesList() {
|
||||||
isAtBottom.value,
|
isAtBottom.value,
|
||||||
isAtTop.value,
|
isAtTop.value,
|
||||||
isMomentumScrolling,
|
isMomentumScrolling,
|
||||||
|
layoutHeight.value,
|
||||||
|
convo.items.length,
|
||||||
keyboardIsOpening.value,
|
keyboardIsOpening.value,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -163,8 +184,17 @@ export function MessagesList() {
|
||||||
const onScroll = React.useCallback(
|
const onScroll = React.useCallback(
|
||||||
(e: ReanimatedScrollEvent) => {
|
(e: ReanimatedScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
|
layoutHeight.value = e.layoutMeasurement.height
|
||||||
|
|
||||||
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
||||||
|
|
||||||
|
if (
|
||||||
|
showNewMessagesPill &&
|
||||||
|
e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset
|
||||||
|
) {
|
||||||
|
runOnJS(setShowNewMessagesPill)(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
|
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
|
||||||
// when a new message is added, hence the 100 pixel offset
|
// when a new message is added, hence the 100 pixel offset
|
||||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
||||||
|
@ -177,7 +207,14 @@ export function MessagesList() {
|
||||||
hasInitiallyScrolled.value = true
|
hasInitiallyScrolled.value = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop],
|
[
|
||||||
|
layoutHeight,
|
||||||
|
showNewMessagesPill,
|
||||||
|
isAtBottom,
|
||||||
|
isAtTop,
|
||||||
|
contentHeight.value,
|
||||||
|
hasInitiallyScrolled,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onMomentumEnd = React.useCallback(() => {
|
const onMomentumEnd = React.useCallback(() => {
|
||||||
|
@ -267,6 +304,7 @@ export function MessagesList() {
|
||||||
ListFooterComponent={<Animated.View style={[animatedFooterStyle]} />}
|
ListFooterComponent={<Animated.View style={[animatedFooterStyle]} />}
|
||||||
/>
|
/>
|
||||||
</ScrollProvider>
|
</ScrollProvider>
|
||||||
|
{showNewMessagesPill && <NewMessagesPill />}
|
||||||
<Animated.View style={[a.relative, t.atoms.bg, animatedInputStyle]}>
|
<Animated.View style={[a.relative, t.atoms.bg, animatedInputStyle]}>
|
||||||
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue