[Flutter] 포켓몬 도감 만들기 - 필터링 기능

저우웅·2025년 11월 17일

Flutter

목록 보기
7/8

안녕하세요!
이번 주는 제가 동아리 세션을 준비하게 되어, 기존 프로젝트에 추가 기능을 넣어 세션을 진행하려고 합니다.


기존 앱

기존 앱은 pokeapi를 사용하여 포켓몬 도감을 만든 앱입니다. 먼저 포켓몬을 Column 리스트로 배치하고, 각 항목을 눌러 포켓몬 상세 정보에 접근할 수 있습니다.
(성능 이슈로 20번까지만 포켓몬을 불러왔습니다.)

상세 정보에는 포켓몬의 이미지, 속성, 스텟을 확인할 수 있습니다.


이번 주의 목표

따라서 이번 주는

  1. 포켓몬 검색 기능(이름 기반)
  2. 속성 기반 필터링
  3. (가능하다면) 성능 개선 및 포켓몬 수 추가

위 세 가지를 진행해보겠습니다.


1. 검색 기능 추가

검색 기능을 추가하려면 우선 검색창이 필요하겠죠?

그 전에 우선 검색 결과를 저장할 리스트를 추가해보겠습니다.

class _PokemonListPageState extends State<PokemonListPage> {
  bool isLoading = true;
  String? errorMessage;
  List<Pokemon> pokemons = [];
  // Step1. 검색 결과를 담아줄 리스트를 만들어주기
  List<Pokemon> filteredPokemons = []; // 검색용 리스트

위 과정은 웹도 마찬가지입니다. 검색 기능이 있는 경우 전체 리스트와 별개로 검색 결과만을 필터링해서 담아줄 리스트가 필요하죠.

따라서 검색 결과만을 띄워줄 filteredPokemons라는 이름의 list를 만들었습니다.

build 자세히 보기

플러터에서 눈에 보이는 부분은 모두 build 안에서 만들어지기 때문에, 우선 build코드를 보겠습니다.
이전에는

  @override
  Widget build(BuildContext context) {
    // iOS 스타일로 사용 중...
    return CupertinoPageScaffold(
      navigationBar: const CupertinoNavigationBar(middle: Text("Pokémon")),
      child: SafeArea(
        child: isLoading
            ? const Center(child: CupertinoActivityIndicator())
            : errorMessage != null
            ? Center(child: Text(errorMessage!))
            : Column(
                children: [
                  // Step A-3. 리스트 상단에 SearchBar 삽입
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: CupertinoSearchTextField(
                      placeholder: "포켓몬 이름 검색",
                      onChanged: (query) {
                        setState(() {
                          filteredPokemons = pokemons
                              .where(
                                (p) =>
                                    p.nameKo.toLowerCase().contains(
                                      query.toLowerCase(),
                                    ) ||
                                    p.nameEn.toLowerCase().contains(
                                      query.toLowerCase(),
                                    ),
                              )
                              .toList();
                        });
                      },
                    ),
                  ),
                  // 리스트 부분
                  Expanded(
                    child: ListView.builder(
                      itemCount: filteredPokemons.length,
                      itemBuilder: (context, index) {
                        final p = filteredPokemons[index];
                        return PokemonListItem(pokemon: p);
                      },
                    ),
                  ),
                ],
              ),
      ),
    );
  }

위 앱의 포켓몬 리스트 페이지의 build 코드는 이렇습니다.

위에서부터 자세히 살펴보면, 기존에서 iOS 스타일의 레이아웃을 사용하고 있었기 때문에 맞추어 iOS스타일 위젯을 사용했네요.

이제 눈에 띄는 부분은 SafeArea입니다.

SafeArea의 효과

우선 단어를 봤을 때 뭔가 자동으로 내용이 가려지지 않게 잘 보이게 해줄 것 같다는 생각이 듭니다.(?)

맞습니다! SafeArea는 화면의 안전한 영역만큼 패딩을 자동으로 잡아주는 역할을 합니다.

예를 들면, 아이폰 14 이상의 기기는 상단 카메라(노치)에 화면 요소가 가려질 수 있고, 하단 제스처 바에도 화면의 요소가 가려질 수 있겠죠.

SafeArea를 사용하면 자동으로 상하좌우 패딩을 계산해서 UI를 안전한 영역으로 이동시켜줍니다.
결론적으로 SafeArea = UI가 기기의 위험영역과 겹쳐 가려지지 않도록 않도록 자동으로 보호해주는 위젯 이라고 할 수 있습니다.


검색창

Padding(
  padding: const EdgeInsets.all(10.0),
  child: CupertinoSearchTextField(
    placeholder: "포켓몬 이름 검색",
    onChanged: (query) {
      setState(() {
        filteredPokemons = pokemons
          .where(
            (p) =>
              p.nameKo.toLowerCase().contains(
                query.toLowerCase(),
              ) ||
              p.nameEn.toLowerCase().contains(
                query.toLowerCase(),
              ),
            )
            .toList();
        });
      },
    ),
  ),

검색창에 해당하는 부분은 이렇습니다.
기존 네비게이션 바 하단, 포켓몬 리스트의 상단에 검색창을 놓는 일반적인 배치로 두었습니다.



2. 속성 기반 필터링

이제 속성 기반으로 포켓몬을 필터링하는 기능을 추가해봅시다.

속성은 검색과는 달리 토글을 열어 속성 버튼을 선택할 수 있도록 하거나, 가로 스크롤을 이용하여 속성 버튼을 가로로 배치하는 방법 등이 있겠습니다.

이번에는 SearchBar 하단에 속성 버튼들을 가로 스크롤 칩으로 만들어보겠습니다.


우선 칩 제작시 칩 컬러에 속성별로 차이를 두고자 했는데요,
이미 기존 포켓몬 디테일 페이지 코드에 속성별 컬러가 지정되어 있기 때문에, 속성별 한/영 이름 매치 + 컬러 매치 부분을 공통으로 빼주는 편이 좋겠군요.

따라서 기존 포켓몬 디테일 페이지의 코드를 lib/features/pokemon/utils 폴더에 pokemon_type_utils.dart 파일을 생성하여 넣어주겠습니다.




공통 코드 정리

import 'package:flutter/cupertino.dart';

class PokemonTypeUtils {
  static Color getTypeColor(String type) {
    switch (type.toLowerCase()) {
      case 'fire':
        return const Color(0xFFFF6B6B);
      //....
    }
  }
  static String getTypeKorean(String typeEn) {
    const typeMap = {
      'fire': '불꽃',
      'water': '물',
      //...
    }
  }
  // (추가되는 부분) 칩 리스트 출력을 위한 타입 목록
  static final pokemonTypes = [
    {"label": "전체", "type": null},
    {"label": "불꽃", "type": "fire"},
    {"label": "물", "type": "water"},
    {"label": "풀", "type": "grass"},
    //...
  ]
}

이제 이 부분을 다른 파일에서 가져다 써야 하기 때문에, static을 붙여 전역으로 만들어주어야 합니다.

그러면 이제 리스트 페이지와 디테일 페이지에서는 각각

import 'package:pokemon/features/pokemon/utils/pokemon_type_utils.dart';

코드 상단에 util파일을 import해온 다음 사용해줘야 하겠죠.
추가로 detail페이지에 있는 중복 코드는 이제 제거해주시면 됩니다.




속성 칩을 만들자


이제 속성 칩 부분을 봅시다.

  Widget _buildTypeChip(String label, String? type) {
    final isSelected = selectedType == type;
    final color = type != null
        ? PokemonTypeUtils.getTypeColor(type)
        : CupertinoColors.systemGrey3;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 6),
      child: GestureDetector(
        onTap: () {
          // !
          selectedType = type;
          applyFilters();
        },
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
          decoration: BoxDecoration(
            color: isSelected ? color : color.withOpacity(0.3),
            borderRadius: BorderRadius.circular(20),
          ),
          child: Text(
            label,
            style: TextStyle(
              color: isSelected ? CupertinoColors.white : CupertinoColors.black,
            ),
          ),
        ),
      ),
    );
  }

여기서 로직상 주목해야 할 부분은 onTap이겠네요.
칩을 클릭하면 selectedType이 바뀌면서 applyFilter를 불러 필터링을 실행합니다.

그러면 이제 applyFilter를 살펴봅시다.


필터링 로직 변경 - 검색어 + 속성 처리

class _PokemonListPageState extends State<PokemonListPage> {
  // 기타 코드...
  // Step B-1. 필터 기능 합칠 준비(텍스트 검색 + 타입 필터링)
  String searchQuery = "";
  String? selectedType;
  
  // 기타 코드...

  // Step B-2. 필터 적용 함수(검색어 변경, 속성에 따른 필터링 관리)
  void applyFilters() {
    setState(() {
      filteredPokemons = pokemons.where((p) {
        final nameMatch =
            p.nameKo.contains(searchQuery) ||
            p.nameEn.toLowerCase().contains(searchQuery.toLowerCase());

        final typeMatch =
            selectedType == null || p.types.contains(selectedType);

        return nameMatch && typeMatch;
      }).toList();
    });
  }

이전에는 검색어 기반 필터링을 build에서 직접 처리했습니다.
하지만 이제 필터링을 속성으로도 걸 예정이니, 두 개의 처리를 하나의 함수에서 하도록 만들었습니다. (성능 문제에 대해서는 생각을 해보아야 할 것 같습니다.)

그러면 이제 build는 필터링 역할에서 벗어나 UI 렌더링만을 맡게 되었습니다.

위 코드 (검색어 필터링을 build 내부에서 했던 코드)처럼 build함수 안에서 기타 처리를 담당하게 될 경우, UI를 그릴 때마다 해당 처리까지 반복해야 하기 때문에 불필요한 계산이 늘어납니다.

따라서 로직과 UI는 항상 분리해서 써야 합니다.


build - 속성 칩을 추가하자

이제 마지막으로 build함수를 봅시다.

// Step A-2. 리스트 상단에 SearchBar 삽입
Padding(
  padding: const EdgeInsets.all(10.0),
  child: CupertinoSearchTextField(
    placeholder: "포켓몬 이름 검색",
    onChanged: (value) {
      searchQuery = value.trim();
      applyFilters();
    },
  ),
),
// Step B-3. 속성 - 가로 스크롤 칩 UI 만들기
SizedBox(
  height: 30,
  child: ListView(
    scrollDirection: Axis.horizontal,
    children: PokemonTypeUtils.pokemonTypes.map((t) {
      return _buildTypeChip(t["label"]!, t["type"]);
    }).toList(),
  ),
),
// 리스트 부분 코드...


마무리

오늘은 동아리 세션 준비용으로 기존 프로젝트에 기능을 추가하고, 이를 설명하는 블로그를 작성해보았습니다.

전체 소스 코드가 궁금하신 분들은

GDGoC SeoulTech App - 6th Session

해당 레포지토리에 들어가시면 이전 세션 코드들도 함께 구경하실 수 있습니다...ㅎㅎ
다음에는 해당 프로젝트의 성능을 개선해서 조금 더 많은 포켓몬을 불러오는 작업을 해보면 좋을 것 같네요.
감사합니다🙂‍↕️

profile
ㅠㅠ

0개의 댓글