0%
July 13, 2024

Various ways of Implementing Chatroom Input Field in React Native

react-native

KeyboardAvoidingView

Demo Video 1
Implementation

When building mobile application, the main headache of using an input field is that the keyboard can hide your form.

The first thing we learn is KeyboardAvoidingView, which in the past I have created one component to abstract every detail that I don't want to care again:

// KeyboardPushedView

import { KeyboardAvoidingView, Platform, View } from "react-native";
import { ReactNode } from "react"
import WbView from "./WbView";
import { ScrollView } from "react-native";
import useKeyboardVisible from "../hooks/useKeyboardVisible";

export default ({ formEle, bottomEle, keyboardVerticalOffset = 60, backgroundColor }: {
    formEle: ReactNode,
    bottomEle: ReactNode,
    keyboardVerticalOffset?: number,
    backgroundColor?: string
}) => {
    const { isKeyboardVisible } = useKeyboardVisible();
    const backgroundStyle: { backgroundColor?: string } = {}
    if (backgroundColor) {
        backgroundStyle.backgroundColor = backgroundColor;
    }
    return (
        <KeyboardAvoidingView
            behavior={Platform.OS === "ios" ? "padding" : "height"}
            style={{ flex: 1 }}
            keyboardVerticalOffset={keyboardVerticalOffset}
        >
            <WbView style={{ height: "100%", ...backgroundStyle }} >
                <ScrollView showsVerticalScrollIndicator={false}>
                    <View style={{
                        flex: 1,
                        justifyContent: "flex-end"
                    }}>
                        {formEle}
                        <View style={{ flex: 1 }} />
                    </View >
                </ScrollView>
                {!isKeyboardVisible && bottomEle}
            </WbView>
        </KeyboardAvoidingView>
    )
}

So the next time I want to make a Stack.Screen that avoids the input being blocked by the keyboard, I can simply write:

const Login = () => {
    ...
    return (
        <KeyboardPushedView
            backgroundColor='white'
            keyboardVerticalOffset={0}
            formEle={
                <InputFieldAndButtons />
            }
            bottomEle={
                <></>
            }
        />
    )
}

Case When KeyboardAvoidingView Would not Help, Especially in Chat App ...

What if I want the input field to be sticky with the keyboard just like any existing messaging applications? KeyboardAvoidingView is not an answer here. There are two solutions for this scenario.

Solution 1. InputAccessoryView
Demo Video 2
Implementation with Highlights and Explanation
1const MultiChat = ({
2    quitRoomAction,
3    sort,
4    sessionId,
5    inverted,
6    showKeyboard,
7    showCachedMessages = false,
8    hasHeader,
9    shouldEstablishSocketConnection,
10    enableDeleteMessage,
11    input,
12}: {
13    quitRoomAction: () => void,
14    sort: "asc" | "desc",
15    sessionId: string,
16    inverted: boolean,
17    showKeyboard: boolean,
18    hasHeader: boolean,
19    showCachedMessages?: boolean,
20    shouldEstablishSocketConnection: boolean,
21    enableDeleteMessage: boolean,
22    input?: ReactNode
23}) => {
24    const keyboardActive = useAppSelector(s => s.chat.ongoingSessionInfo.roomKeyboardActive);
  • In line 24 we need a boolean to control the existence of the input component as shown in our video above.

    We save that boolean in the redux store in order to control it wherever we want.

  • A similar example can be found in discord mobile app, in which you swipe to the center to chat, and you swipe to the left to view channels and the keyboard is closed automatically.

25    const dispatch = useAppDispatch();
26    const { addPageCount, count, resetPageCount } = usePageCount();
27
28    const { flatListRef, messages, audioStartPlay } = useInitChatroom({
29        quitRoomAction,
30        sort,
31        sessionId,
32        shouldEstablishSocketConnection,
33        showCachedMessages
34    });
35    const InputComponent = useCallback(() => input, [])
36
37    useEffect(() => {
38        resetPageCount()
39        return () => {
40            resetPageCount()
41        }
42    }, [])
43
44    useEffect(() => {
45        return () => {
46            dispatch(chatSlice.actions.resetOngoingSession());
47        }
48    }, [])
49
50    useEffect(() => {
51        if (showKeyboard) {
52            dispatch(chatSlice.actions.setRoomKeyboardActive(showKeyboard));
53        }
54        return () => {
55            dispatch(chatSlice.actions.setRoomKeyboardActive(true));
56        }
57    }, [])
58
59    return (
60        <View style={{ position: "relative", flex: 1 }}>
61            <CompLabel componentName="MultiChat" />
62            <View style={{
63                flexDirection: "column",
64                flex: 1,
65                alignItems: "center",
66            }}>
67                {hasHeader && <MultiChatHeader />}
68                <FlatList
69                    contentContainerStyle={{ paddingBottom: 60 }}
70                    persistentScrollbar
71                    keyboardShouldPersistTaps='handled'
72                    automaticallyAdjustKeyboardInsets={true}
73                    showsVerticalScrollIndicator={true}
  • In line 72 we need to set automaticallyAdjustKeyboardInsets={true} to let the height of ScrollView be adjustable when keyboard popups.
74                    onEndReachedThreshold={0.2}
75                    onEndReached={async() => {
76                        await dispatch(ChatThunkAction.getRoomMessagesHistory({
77                            roomId: sessionId || "",
78                            page: count,
79                            sort: sort,
80                            showCachedMessages
81                        })).unwrap()
82                        addPageCount();
83                    }}
84                    onScrollToIndexFailed={info => {
85                        const wait = new Promise(resolve => setTimeout(resolve, 500));
86                        wait.then(() => {
87                            if (messages.length === 0) {
88                                return;
89                            }
90                            flatListRef.current?.scrollToIndex({ index: info.index, animated: true });
91                        });
92                    }}
93                    initialScrollIndex={0}
94                    inverted={inverted}
95                    style={{ height: "100%", width: "100%", marginBottom: 0, paddingBottom: 0, }}
96                    data={messages}
97                    keyExtractor={item => item.uuid}
98                    renderItem={({ item, index }) => {
99                        return (
100                            <View key={item.uuid} >
101                                <MessageRow
102                                    enableDelete={enableDeleteMessage}
103                                    message={item}
104                                    playAudio={audioStartPlay}
105                                />
106                            </View>
107                        )
108                    }}
109                />
110                {Platform.OS === "ios" && keyboardActive && (
111                    <InputAccessoryView>
112                        <InputComponent />
113                    </InputAccessoryView>
114                )}
115                {Platform.OS !== "ios" && keyboardActive && (
116                    <InputComponent />
117                )}
118            </View >
119        </View>
120    )
121}
  • Lastly, note that InputAccessoryView is iOS-specific. The stickyless of inputfield right above the keyboard is natively supported in Android.
Caveat of InputAccessoryView
  • As shown in the demo video, our InputAccessoryView cannot be animated by PanGesture, i.e., not controllable by react-reanimated.

  • When there is highly customized pan gesture involved for page transition, the logic of closedness and openedness of the custom inputfield will also be error-prone.

  • When the InputAccessoryView is embedded into a bottomsheet, then InputAccessoryView may still persist while transitioning to second bottomsheet.

    Unless you kill/dismiss the first bottomsheet (that contains InputAccessoryView), but that means you can't return from the second.

Solution 2. Treat Input Element as a Sticky Header of the (Inverted) FlatList
Demo Video 3

(Here I scroll up to remove the keyboard)

Implementation with Highlights

With the same code as above, we replace the FlatList element with emphasis on the highlighted lines:

68                <FlatList
69                    ref={flatListRef}
70                    persistentScrollbar
71                    ListHeaderComponent={InputComponent}
72                    stickyHeaderIndices={[0]}
73                    invertStickyHeaders={false}
74                    keyboardShouldPersistTaps='handled'
75                    automaticallyAdjustKeyboardInsets={true}
76                    showsVerticalScrollIndicator={true}
77                    onEndReachedThreshold={0.2}
78                    onEndReached={onEndReached_}
79                    onScrollToIndexFailed={info => {
80                        const wait = new Promise(resolve => setTimeout(resolve, 500));
81                        wait.then(() => {
82                            if (messages.length === 0) {
83                                return;
84                            }
85                            flatListRef.current?.scrollToIndex({ index: info.index, animated: true });
86                        });
87                    }}
88                    initialScrollIndex={0}
89                    inverted={inverted}
90                    style={{ height: "100%", width: "100%", marginBottom: 0, paddingBottom: 0, }}
91                    data={messages}
92                    keyExtractor={item => item.uuid}
93                    renderItem={({ item, index }) => {
94                        return (
95                            <View key={item.uuid} >
96                                <MessageRow
97                                    enableContextMenu={enableDeleteMessage}
98                                    message={item}
99                                    playAudio={audioStartPlay}
100                                />
101                            </View>
102                        )
103                    }}
104                    onScroll={e => {
105                        const currentOffset = e.nativeEvent.contentOffset.y;
106                        const scrollUp = currentOffset > prevOffetRef.current;
107                        if (scrollUp) {
108                            Keyboard.dismiss();
109                        }
110                        prevOffetRef.current = currentOffset;
111                    }}
112                />

Now the input element is part of the FlatList, and it can be animated by the pan-gesture handler. We don't need to worry about the blockage of the keyboard to other views transitted by pan-guesture any more.

Conclusion

  • When your view does not involve highly complicated pan-gesture animation (e.g., every page transition is simply controlled by stack-navigation), go InputAccessoryView.

  • Otherwise, treat the input element as the sticky header in a FlatList/ScrollView to avoid potential issues caused by the unexpected persistence of the input element.