고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

채팅방 목록에 새로운 기능을 추가해보자

고윤태 2023. 10. 19. 16:18
안녕하세요 이번에 작성하게 될 내용은 제가 지난번에 개발한 채팅방 목록에서 기획자 분이 새로운 기능을 요청하셔서 수정하게 된 이야기를 가볍게 적어볼까 합니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

제가 지난번에 아래 글에도 작성했듯이 채팅방 목록을 구현했었습니다.

https://yun-tech-diary.tistory.com/entry/React-Native-%EC%B1%84%ED%8C%85%EB%B0%A9-%EB%AA%A9%EB%A1%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

React Native 채팅방 목록 구현하기

안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 채팅방 목록을 어떤 식으로 구현했는지 글을 작성했습니다. 💻 개발 환경 React-native(expo) + TypeScript로 진행되었습니다. 🧐 구현하게

yun-tech-diary.tistory.com

하지만 저희가 디스코드를 사용하여 오프라인 회의를 진행하고 있는 와중에

기획자님👽 : 윤태 님 카카오톡 채팅방 목록을 보면 좌우 스와이핑이 하나의 채팅방만 가능한데 혹시 저희도 그렇게 변경이 가능할까요?
저😊: 일단 확인해 보겠습니다.
(확인 후)
저😊: 가능은 할 거 같아서 한 번 해볼게요

 

이러한 스토리로 인해서 하나의 채팅방만 스와이핑이 가능하도록 변경하게 되었습니다.

🤔 기능 산출 및 개발 계획

제일 먼저 확인해야 하는 것은 스와이핑이 된 요소를 사용자의 액션이 아닌 Code로 닫을 수 있는지 여부를 확인하는 것이었습니다.

당연히 제가 의존하는 공식 문서에 들어가 확인을 했고

 

출처(https://docs.swmansion.com/react-native-gesture-handler/docs/components/swipeable)

 

이렇게 Close Method가 있다는 걸 알 수 있었습니다. 혹시나 간혹 deprecate가 되었지만 문서에 반영이 안 된 경우도 있으니 확인이 필요했습니다.

가장 간단한 확인 방법으로는 직접 해보는 법입니다. 

기본 적으로 부모 컴포넌트에서 자식 컴포넌트 안의 DOM element에 접근하려면 저 같은 함수형 컴포넌트에서는 fowardRef를 사용하는 것이 정석적인 방법입니다.

 

https://react.dev/reference/react/forwardRef

 

forwardRef – React

The library for web and native user interfaces

react.dev

하지만 간단한 테스트만 진행할 것이니 Props를 이용하여 간단하게 진행해 보겠습니다.

setRef: (ref: Swipeable | null) => void;

라는 Props를 추가하고

  <Swipeable
      renderRightActions={renderRightActions}
      renderLeftActions={renderLeftActions}
      friction={1.5}
      ref={(ref) => {
        setRef(ref);
      }}
    >

이런 식으로 ref 값을 callback으로 받을 수 있도록 하였습니다.

import React, { useRef } from 'react';
import { Swipeable } from 'react-native-gesture-handler';
import ChatRoomItem from '../../components/ui/ChatRoomItem';
import Button from '../../components/ui/buttons/Button';

const Test = () => {
  const ref = useRef<Swipeable | null>(null);

  return (
    <>
      <ChatRoomItem
        roomId='roomId'
        roomName={'Test'}
        region={'Test'}
        timeStamp={'Test'}
        content={'Test'}
        setRef={(r) => (ref.current = r)}
      />
      <Button
        label='Item Close'
        onPressHandler={() => {
          ref.current?.close();
        }}
      />
    </>
  );
};

export default Test;

부모 컴포넌트에서 이런 식으로 코드를 작성했습니다.

 

결과

close Method를 사용하면 좌 또는 우로 스와이핑이 된 ChatRoomItem을 사용자의 액션 없이 닫을 수 있는 것을 확인했습니다.

일단 가능하단 것을 확인했으면 저것을 어떻게 활용할지 고민해봐야 합니다.

떠오른 방법은 2개가 존재합니다.

 

방법 1.

모든 ChatRoomItem의 ref를 array로 저장하여 하나의 ChatRoomItem이 열린다면 해당 ChatRoomItem을 제외하고 나머지 ChatRoomItem의 ref를 이용하여 close Method를 호출한다

 

방법 2.

하나의 ChatRoomItem 스와이핑이 발생하면 해당 ref를 저장 후 다른 ChatRoomItem이 열릴 때 저장되어 있는 Ref를 이용하여 close Method를 호출하고 새로 열린 ChatRoomItem의 ref를 저장한다.

 

저 같은 경우 2번의 방법을 선택했습니다.

 

방법 2가 더 효율적인 이유는 다음과 같습니다.

 

방법 1에서는 모든 ChatRoomItem의 ref를 배열로 저장해야 합니다. 이 경우, ChatRoomItem이 많을수록 배열에 저장되는 ref의 수도 증가하게 됩니다. 따라서, 메모리 사용량이 증가할 수 있고, 관리해야 할 ref의 수도 많아집니다. 반면에 방법 2에서는 현재 열려있는 ChatRoomItem의 ref만을 저장하면 되기 때문에 메모리 사용량과 관리해야 할 ref의 수를 줄일 수 있습니다.


방법 2에서는 스와이핑이 발생한 ChatRoomItem의 ref만을 저장합니다. 이렇게 하면 스와이핑으로 인해 닫힌 ChatRoomItem들에 대해서는 별도로 처리할 필요가 없습니다. 새로운 ChatRoomItem이 열릴 때마다 해당하는 ref를 사용하여 close Method를 호출하고 새로운 ChatRoomItem의 ref를 저장함으로써, 열린 상태인 ChatRoomItem들만 관리하면 됩니다.


따라서, 방법 2가 더 효율적입니다. 메모리 사용량과 관리 비용을 최소화하며 필요한 동작을 수행할 수 있다 판단했기 때문입니다.

 

그러면 이제 정해야 하는 것은 ref를 교체하는 타이밍입니다.

공식문서를 찾아보던 중

 

(출처 : https://docs.swmansion.com/react-native-gesture-handler/docs/components/swipeable/)

 

이런 좋은 것을 발견했습니다.

스와이핑이 열릴 때 호출되는 메서드라고 나와있습니다. 이 타이밍에 기존의 ref에 값이 있다면 close method를 호출하고 현재 새로 열린 것으로 ref 값을 교체해 준다면 될 것 같습니다.

👀 컴포넌트 개발(CODE)

Chatting.tsx(Parent)

import React, { useContext, useEffect, useRef, useState } from 'react';
import { FlatList, Text, View } from 'react-native';
import TYPOS from '../../components/ui/typo';
import ChatRoomItem from '../../components/ui/ChatRoomItem';
import Color from '../../constants/color';
import EmptyList from '../../components/chat/EmptyList';
import { WebSocketContext } from '../../components/WebSocketContainer';
import { useRecoilValue } from 'recoil';
import { UserState } from '../../store/atoms';
import { Swipeable } from 'react-native-gesture-handler';
interface RoomData {
  id: string;
  title: string;
  lastChat: string;
  lastChatAt: string;
  isAlarm: boolean;
  isPinned: boolean;
  isPetMate?: boolean;
  image?: string;
  region: string;
  productImage?: string;
}

const Chatting = () => {
  const [rooms, setRooms] = useState<RoomData[]>([]);
  const socket = useContext(WebSocketContext);
  const rowRef = useRef<Swipeable | null>(null);

  const { accessToken } = useRecoilValue(UserState);

  useEffect(() => {
    if (!socket) return;

    const handleGetChatList = () => {
      socket.emit('room-list', {
        token: accessToken,
      });
      socket.on('room-list', ({ data: { chatRoomList } }) => {
        setRooms(chatRoomList);
      });
    };

    handleGetChatList();

    return () => {
      socket.off('room-list');
    };
  }, [socket]);

  return (
    <>
      <View
        style={{
          paddingHorizontal: 24,
          paddingVertical: 16,
          backgroundColor: Color.white,
        }}
      >
        <Text style={[TYPOS.headline3, { color: Color.black }]}>채팅</Text>
      </View>
      <FlatList
        contentContainerStyle={{
          backgroundColor: Color.white,
          flex: 1,
        }}
        keyExtractor={(item) => item.id}
        data={rooms}
        showsVerticalScrollIndicator={false}
        renderItem={({ item }) => (
          <ChatRoomItem
            roomId={item.id}
            image={item.image}
            roomName={item.title}
            region={item.region}
            timeStamp={item.lastChatAt}
            content={item.lastChat}
            isPinned={item.isPinned}
            isNotificationEnabled={item.isAlarm}
            onSwipeableOpenHandler={(ref) => {
              if (rowRef.current && ref !== rowRef.current) {
                rowRef.current.close();
                rowRef.current = null;
              }
              rowRef.current = ref;
            }}
          />
        )}
        ListEmptyComponent={<EmptyList />}
      />
    </>
  );
};

export default Chatting;

코드 설명

현재 열려있는 스와이프 가능한 아이템(rowRef.current)이 존재하고, 새로운 아이템(ref)과 다른 경우에는 기존 아이템을 닫습니다.
그 후, 현재 열려있는 아이템의 참조(rowRef.current)를 새로운 아이템의 참조(ref)로 업데이트합니다.


ChatRoomItem.tsx(Child)

import React, { useRef } from 'react';
import { View, Text, Image, Pressable } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import Color from '../../constants/color';
import TYPOS from './typo';
import Pin24 from './icons/Pin24';
import FillPin24 from './icons/FillPin24';
import Bell16 from './icons/Bell16';
import PinIndicator from './icons/PinIndicator';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../../types/navigation';

interface Props {
  roomId: string;
  image?: string;
  productImage?: string;
  roomName: string;
  region: string;
  timeStamp: string;
  content: string;
  isPinned?: boolean;
  isNotificationEnabled?: boolean;
  onPinPressHandler?: () => void;
  onExitPressHandler?: () => void;
  onToggleNotificationHandler?: () => void;
  onSwipeableOpenHandler: (ref: Swipeable | null) => void;
}

const ChatRoomItem = ({
  roomId,
  image,
  productImage,
  roomName,
  region,
  timeStamp,
  content,
  isPinned,
  isNotificationEnabled,
  onPinPressHandler,
  onExitPressHandler,
  onToggleNotificationHandler,
  onSwipeableOpenHandler,
}: Props) => {
  const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
  const swipeableRef = useRef<Swipeable | null>(null);

  const renderLeftActions = () => {
    return (
      <View
        style={{
          flexDirection: 'row',
          alignItems: 'center',
        }}
      >
        <Pressable
          style={{
            backgroundColor: Color.primary700,
            width: 72,
            height: '100%',
            alignItems: 'center',
            justifyContent: 'center',
          }}
          onPress={onPinPressHandler}
        >
          {isPinned ? (
            <FillPin24 color={Color.white} />
          ) : (
            <Pin24 color={Color.white} />
          )}
        </Pressable>
      </View>
    );
  };

  const renderRightActions = () => {
    return (
      <View
        style={{
          flexDirection: 'row',
          alignItems: 'center',
        }}
      >
        <Pressable
          style={{
            backgroundColor: isNotificationEnabled
              ? Color.neutral3
              : Color.neutral2,
            width: 80,
            height: '100%',
            alignItems: 'center',
            justifyContent: 'center',
          }}
          onPress={onToggleNotificationHandler}
        >
          <Text style={[{ color: Color.white }, TYPOS.body1]}>{`알림 ${
            isNotificationEnabled ? '끄기' : '켜기'
          }`}</Text>
        </Pressable>
        <Pressable
          style={{
            backgroundColor: Color.error,
            width: 80,
            height: '100%',
            alignItems: 'center',
            justifyContent: 'center',
          }}
          onPress={onExitPressHandler}
        >
          <Text style={[{ color: Color.white }, TYPOS.body1]}>나가기</Text>
        </Pressable>
      </View>
    );
  };

  return (
    <Swipeable
      renderRightActions={renderRightActions}
      renderLeftActions={renderLeftActions}
      friction={1.5}
      ref={swipeableRef}
      onSwipeableOpen={() => {
        onSwipeableOpenHandler(swipeableRef.current);
      }}
    >
      <Pressable
        onPress={() => {
          navigation.navigate('ChatRoom', {
            roomId: roomId,
          });
        }}
        style={{
          backgroundColor: Color.white,
          padding: 16,
          flexDirection: 'row',
          position: 'relative',
          alignItems: 'center',
        }}
      >
        {isPinned && (
          <PinIndicator
            color={Color.primary700}
            style={{
              position: 'absolute',
              top: 0,
              right: 0,
            }}
          />
        )}
        <Image
          style={[
            {
              width: 48,
              height: 48,
              resizeMode: 'cover',
              borderRadius: 48,
              marginRight: 16,
            },
          ]}
          source={
            image
              ? { uri: image }
              : require('../../../assets/img/placeholder.png')
          }
        />
        <View style={{ flex: 1, gap: 4 }}>
          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
            <Text style={[{ color: Color.black }, TYPOS.headline4]}>
              {roomName}
            </Text>
            <Text
              style={[
                { color: Color.neutral2, marginHorizontal: 4 },
                TYPOS.body3,
              ]}
            >
              {region}
            </Text>
            {!isNotificationEnabled && <Bell16 color={Color.neutral2} />}
          </View>
          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
            <Text
              numberOfLines={1}
              style={[{ color: Color.neutral1, flex: 1 }, TYPOS.body2]}
            >
              {content}
            </Text>
            <Text
              style={[
                { color: Color.neutral2, marginHorizontal: 4 },
                TYPOS.body3,
              ]}
            >
              ·
            </Text>
            <Text style={[{ color: Color.neutral2 }, TYPOS.body3]}>
              {timeStamp}
            </Text>
          </View>
        </View>
        {productImage && (
          <Image
            style={[
              {
                width: 56,
                height: 56,
                resizeMode: 'cover',
                borderRadius: 5,
                marginLeft: 16,
              },
            ]}
            source={{ uri: productImage }}
          />
        )}
      </Pressable>
    </Swipeable>
  );
};

export default ChatRoomItem;

코드 설명

Swipeable 컴포넌트가 열리면, onSwipeableOpenHandler 함수가 호출됩니다.
swipeableRef.current를 인자로 전달하여 현재 열려 있는 Swipeable 컴포넌트의 참조를 전달합니다.

 완성된 채팅방 목록

이렇게 의도대로 잘 작동하는 것을 확인할 수 있습니다.

😁 글 작성 후기

처음에는 이것을 어떤 식으로 구현할지 막막했습니다. 자동으로 닫히게 하려면 현재 구현한 방식이 아니라 ScrollView를 이용하여 구현하고 닫혀야 하는 경우 스크롤 포지션을 바꿀까?라는 고민도 해봤었습니다.

스스로 생각해도 바보 같았던 부분이 close라는 Method를 찾아볼 생각이 늦게 들었습니다.

기존에 사용하고 있는 것에서 새로운 것이 먼저 가능한지 체크 후 안된다면 변경하는 것이 정답이라고 생각하는 저이면서도 이번에는 그 부분을 놓쳐서 돌아갔다 다시 시작점으로 돌아와 출발했네요

그래도 그 후부터는 공식 문서에서 원하는 내용을 찾는 시간도 어렵지 않았고 제 생각대로 잘 동작하여 계획이 잘 진행됐다는 느낌으로 마무리한 추가 기능 개발이었습니다.

 

다른 분들도 저처럼 기존 나무에서 새로운 가지를 키워야 한다면 나무를 바꾼다는 생각보다는 가지 추가가 가능한가?부터 먼저 생각해 봤는지 돌아보셔도 좋을 것 같습니다.

 

읽어주셔서 감사합니다.

'개발' 카테고리의 다른 글

React Native accordion(아코디언) 만들기  (1) 2023.11.07
고차 컴포넌트 구현하기  (2) 2023.10.27
React Native 채팅방 만들기  (2) 2023.10.12
React Native Picker  (2) 2023.09.19
useAsyncWithLoading custom hook  (0) 2023.09.12