Post

[트러블슈팅] React Native + Modal in FlatList

React Native 프로젝트에서 FlatList 내 Modal을 사용하면서 발생한 문제를 해결한 과정에 대해 기록한 페이지입니다.

[트러블슈팅] React Native + Modal in FlatList

Tags
Troubleshooting, TypeScript, React Native, Mobile

Environment
OS: Windows 11
react-native v0.76.5

✅ 개요

React Native 프로젝트에서 FlatList 내 Modal을 사용하면서 발생한 문제를 해결한 과정에 대해 기록한 페이지입니다.

❓ 문제

⚠️ 오류

Tips
발생한 버그를 간략히 설명해 주세요.

다음 영상과 같이 Modal 내의 TextInput을 클릭하여 키보드가 올라오는 경우 일부 상황에서 Modal이 화면에서 사라지는 문제가 발생하였습니다.

🖥️ 발생 환경

Tips
운영체제, 브라우저, 의존성 목록 등을 작성해 주세요.

  • OS: Android
  • Galaxy S8+
  • react-native v0.76.5

🕘 발생 일시

Tips
버그가 발생한 날짜와 시간을 입력해 주세요. (Ex. 2024년 10월 1일, 오후 3시 30분)

  • 2025년 2월 18일, 오후 2시 30분

📖 해결 과정

먼저 문제가 발생하는 상황에 대해 분석하였습니다. 문제 상황을 분석한 결과 Modal 컴포넌트를 포함하는 상위 컴포넌트가 TextInput을 클릭함으로써 키보드에 의해 가려질 때 Modal이 화면이 사라지는 것을 확인하였습니다. 이는 다음과 같이 React NativeFlatList를 사용함으로써 발생한 문제였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* TourItemList.tsx */

import React from "react";
import { FlatList, Image, Text, View } from "react-native";
import { tw } from "@src/libs/tailwind";
import { TourItem } from "./TourItem";
import { useTourItemList } from "@src/hooks/tour/useTourItemList";

export const TourItemList = () => {
  const { tourItemList } = useTourItemList();

  return (
    <View
      style={tw.style(
        tourItemList.length === 0 ? "bg-white" : "bg-[#F3F3F3]",
        "flex h-full flex-col justify-center px-4"
      )}
    >
      <FlatList
        contentContainerStyle={tw`flex flex-col`}
        data={tourItemList}
        renderItem={({ item }) => <TourItem data={item} />}
        keyExtractor={(item) => item.plan.planId.toString()}
        ListEmptyComponent={
          <View style={tw`flex flex-col items-center gap-[1.125rem]`}>
            <Image
              style={tw`h-16 w-16`}
              source={require("@src/assets/tour/tour-empty.png")}
            />
            <Text>아직 저장된 여행이 없어요</Text>
          </View>
        }
      />
    </View>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/* TourItemMenu.tsx */

import { COLOR } from "@src/constants/color";
import { useTourItemDelete } from "@src/hooks/tour/useTourItemDelete";
import { tw } from "@src/libs/tailwind";
import React, { useState } from "react";
import { ActivityIndicator, Image, Pressable, Text, View } from "react-native";
import { TourItemTitleModal } from "./TourItemTitleModal";

interface TourItemMenuProps {
  planId: number;
  planTitle: string;
}

export const TourItemMenu = ({ planId, planTitle }: TourItemMenuProps) => {
  const [menuVisible, setMenuVisible] = useState(false);
  const [modalVisible, setModalVisible] = useState(false);
  const { isPending, handleDeleteButtonClick } = useTourItemDelete(
    planId,
    planTitle
  );

  return (
    <View style={tw`relative`}>
      {isPending ? (
        <ActivityIndicator style={tw`h-8 w-8`} color={COLOR.PRIMARY_GREEN} />
      ) : (
        <Pressable
          style={({ pressed }) =>
            tw.style(pressed && "bg-white", "rounded-lg p-1")
          }
          onPress={() => setMenuVisible((value) => !value)}
        >
          <Image
            style={tw`h-6 w-6`}
            source={require("@src/assets/common/menu-icon.png")}
          />
        </Pressable>
      )}
      {menuVisible && (
        <View
          style={tw`absolute right-1.5 top-8 z-10 flex w-20 flex-col rounded-lg bg-white shadow`}
        >
          <Pressable
            style={({ pressed }) =>
              tw.style(pressed && "bg-slate-100", "w-full")
            }
            onPress={() => {
              setMenuVisible(false);
              setModalVisible(true);
            }}
          >
            <Text style={tw`py-2.5 text-center`}>수정</Text>
          </Pressable>
          <Pressable
            style={({ pressed }) =>
              tw.style(pressed && "bg-slate-100", "w-full")
            }
            onPress={() => {
              setMenuVisible(false);
              handleDeleteButtonClick();
            }}
          >
            <Text style={tw`py-2.5 text-center`}>삭제</Text>
          </Pressable>
        </View>
      )}
      <TourItemTitleModal
        planId={planId}
        title={planTitle}
        modalVisible={modalVisible}
        closeModal={() => setModalVisible(false)}
      />
    </View>
  );
};

React NativeFlatList는 가상화된 리스트 컴포넌트로, 화면에 보이는 항목만 렌더링합니다. 이를 고려하였을 때 키보드가 올라오기 전에는 Modal을 포함하는 컴포넌트가 화면에 보이므로 Modal이 제대로 렌더링되지만, TextInput을 클릭함으로써 키보드가 올라왔을 때 키보드에 의해 Modal을 포함하는 컴포넌트가 화면에서 사라지면 해당 컴포넌트가 언마운트(unmount)되어 메모리에서 제거되고, Modal 역시 사라지는 문제였습니다.

즉, 이 문제를 해결하기 위한 핵심은 키보드가 올라왔을 때 Modal을 포함하는 컴포넌트가 화면에서 사라지더라도 Modal의 렌더링 상태가 유지되어야 하는 것이었습니다. 따라서 화면에 보이는 항목만 렌더링하는 VirtualizedList, FlatList, SectionList를 사용해서 리스트를 렌더링하는 것이 아니라, ScrollViewmap 메서드를 활용해서 리스트를 렌더링하도록 변경하면 문제를 해결할 수 있습니다.

FlatList 대신 ScrollView로 변경한 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* TourItemList.tsx */

import React from "react";
import { Image, ScrollView, Text, View } from "react-native";
import { tw } from "@src/libs/tailwind";
import { TourItem } from "./TourItem";
import { useTourItemList } from "@src/hooks/tour/useTourItemList";

export const TourItemList = () => {
  const { tourItemList } = useTourItemList();

  return (
    <ScrollView
      style={tw.style(
        tourItemList.length === 0 ? "bg-white" : "bg-[#F3F3F3]",
        "flex h-full flex-col px-4"
      )}
    >
      {tourItemList.length === 0 ? (
        <View style={tw`flex flex-col items-center gap-[1.125rem]`}>
          <Image
            style={tw`h-16 w-16`}
            source={require("@src/assets/tour/tour-empty.png")}
          />
          <Text>아직 저장된 여행이 없어요</Text>
        </View>
      ) : (
        tourItemList.map((item) => (
          <TourItem key={item.plan.planId} data={item} />
        ))
      )}
    </ScrollView>
  );
};

위와 같이 문제를 해결한 결과는 다음과 같습니다.

📚 참고 자료

This post is licensed under CC BY 4.0 by the author.