Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[🐴] Fully implement keyboard controller #4106

Merged
merged 19 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
Expand Down
45 changes: 24 additions & 21 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'view/icons'

import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {RootSiblingParent} from 'react-native-root-siblings'
import {
initialWindowMetrics,
Expand Down Expand Up @@ -142,27 +143,29 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
<KeyboardProvider enabled={true}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</KeyboardProvider>
)
}

Expand Down
3 changes: 1 addition & 2 deletions src/components/dms/MessagesListHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useCallback} from 'react'
import {Keyboard, TouchableOpacity, View} from 'react-native'
import {TouchableOpacity, View} from 'react-native'
import {
AppBskyActorDefs,
ModerationCause,
Expand Down Expand Up @@ -46,7 +46,6 @@ export let MessagesListHeader = ({
if (isWeb) {
navigation.replace('Messages', {})
} else {
Keyboard.dismiss()
navigation.goBack()
}
}, [navigation])
Expand Down
130 changes: 46 additions & 84 deletions src/screens/Messages/Conversation/MessagesList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import Animated, {
import {
KeyboardStickyView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {
runOnJS,
scrollTo,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
Expand All @@ -24,7 +26,6 @@ import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a} from '#/alf'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
Expand Down Expand Up @@ -95,7 +96,6 @@ export function MessagesList({
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)

const isDragging = useSharedValue(false)
const layoutHeight = useSharedValue(0)

// -- Scroll handling
Expand Down Expand Up @@ -172,16 +172,6 @@ export function MessagesList({
],
)

const onBeginDrag = React.useCallback(() => {
'worklet'
isDragging.value = true
}, [isDragging])

const onEndDrag = React.useCallback(() => {
'worklet'
isDragging.value = false
}, [isDragging])

const onStartReached = useCallback(() => {
if (hasScrolled) {
convoState.fetchMessageHistory()
Expand Down Expand Up @@ -211,49 +201,36 @@ export function MessagesList({
)

// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
const finalKeyboardHeight = useSharedValue(0)

// On web, we don't want to do anything.
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction(
() => animatedKeyboard.height.value,
(now, prev) => {

const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false)

useKeyboardHandler({
onStart: () => {
'worklet'
// This never applies on web
if (isWeb) {
return
}
keyboardIsOpening.value = true
},
onMove: e => {
'worklet'
keyboardHeight.value = e.height

// We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not
// any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the
// scrollview synchronously.
// On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd`
// method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated
// doesn't support a `scrollToEnd`.
if (prev && now > 0 && now >= prev) {
if (e.height > bottomOffset) {
console.log('move')
scrollTo(flatListRef, 0, 1e7, false)
}

// We want to store the full keyboard height after it fully opens so we can make some
// assumptions in onLayout
if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) {
finalKeyboardHeight.value = now
}
},
)
onEnd: () => {
'worklet'
keyboardIsOpening.value = false
},
})

// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedStyle = useAnimatedStyle(() => ({
const animatedListStyle = useAnimatedStyle(() => ({
marginBottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
}))

// -- Message sending
Expand Down Expand Up @@ -282,43 +259,26 @@ export function MessagesList({
[convoState, getAgent],
)

// Any time the List layout changes, we want to scroll to the bottom. This only happens whenever
// the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`.
// This accounts for things like the emoji keyboard opening, changes in block state, etc.
// -- List layout changes (opening emoji keyboard, etc.)
const onListLayout = React.useCallback(() => {
if (isDragging.value) return

const kh = animatedKeyboard.height.value
const fkh = finalKeyboardHeight.value

// We only run the layout scroll if:
// - We're on web
// - The keyboard is not open. This accounts for changing block states
// - The final keyboard height has been initially set and the keyboard height is greater than that
if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) {
if (keyboardIsOpening.value) return
if (isWeb || !keyboardIsOpening.value) {
flatListRef.current?.scrollToEnd({animated: true})
}
}, [
flatListRef,
finalKeyboardHeight.value,
animatedKeyboard.height.value,
isDragging.value,
])
}, [flatListRef, keyboardIsOpening.value])

return (
<Animated.View style={[a.flex_1, animatedStyle]}>
<>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider
onScroll={onScroll}
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={convoState.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
containWeb={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
initialNumToRender={isNative ? 32 : 62}
maxToRenderPerBatch={isWeb ? 32 : 62}
Expand All @@ -339,18 +299,20 @@ export function MessagesList({
}
/>
</ScrollProvider>
{!blocked ? (
<>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : (
<MessageInput onSendMessage={onSendMessage} />
)}
</>
) : (
footer
)}
<KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
{!blocked ? (
<>
{convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled />
) : (
<MessageInput onSendMessage={onSendMessage} />
)}
</>
) : (
footer
)}
</KeyboardStickyView>
{showNewMessagesPill && <NewMessagesPill />}
</Animated.View>
</>
)
}
Loading
Loading