[Flutter] 이미지 리스트 예제 (feat. MVVM, Provider)

G·2024년 7월 18일
0

개요

Kakao 이미지 검색 API를 이용하여 검색한 이미지 목록을 보여주고 즐겨찾기 하는 앱 예제입니다.
Flutter로 작성한 첫 코드이기 때문에 완성도가 보장되지 않습니다😅


  1. 이미지 리스트뷰는 flutter_staggered_grid_view 라이브러리를 사용합니다
  2. 이미지 로딩은 cached_network_image 라이브러리를 사용합니다
  3. 즐겨찾기 저장은 shared_preferences 라이브러리를 사용합니다
  4. 즐겨찾기에 따른 상태 관리는 Provider를 사용합니다




1. UI

TabBar

검색과 즐겨찾기 페이지를 만들고 이를 TabBar로 네비게이션합니다.

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? tabController;

  
  void initState() {
    super.initState();
    tabController = TabController(length: 2, vsync: this);
  }

  
  void dispose() {
    tabController!.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: TabBarView(
        controller: tabController,
        children: <Widget>[SearchPage(), FavoritePage()],
      ),
      bottomNavigationBar: TabBar(tabs: const <Tab>[
        Tab(icon: Icon(Icons.search, color: Colors.white)),
        Tab(icon: Icon(Icons.favorite_outline, color: Colors.white)),
      ],
        controller: tabController,
        indicatorColor: Colors.white,
        indicatorWeight: 3,
      ),
    );
  }
}

검색창 UI를 구현합니다.

class SearchPage extends StatefulWidget {
  
  State<StatefulWidget> createState() {
    return _SearchPageState();
  }
}

class _SearchPageState extends State<SearchPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Center(child: _buildSearchBar()), backgroundColor: Colors.black,),
      body: Center(
      ),
    );
  }

  /// 검색창을 생성합니다
  Widget _buildSearchBar() {
    return SearchBar(
      trailing: const [Icon(Icons.search)],
      constraints: const BoxConstraints(maxWidth: 300, minHeight: 36),
      hintText: "검색어를 입력하세요",
      textInputAction: TextInputAction.done,
      onChanged: (text) {
        debugPrint('input text: $text');
      },
      onSubmitted: (text) {
		if (text.isEmpty) {
          // 검색어 미입력 시 스낵바로 알림
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
            content: Text("검색어를 입력하세요"),
            duration: Duration(seconds: 2),
          ));
        }
      },
    );
  }
}




2. 검색

기본 UI는 준비되었으니 이미지 검색를 구현합니다.

1. SearchRepository

SearchRepository는 API를 통해 이미지를 검색하고 결과를 반환하는 기능을 제공합니다.

class SearchRepository {
  /// 검색어에 해당하는 이미지를 조회합니다
  Future<List<ImageModel>> getImageList(String query, int page) async {
    final url = "https://dapi.kakao.com/v2/search/image?target=title&page=$page&query=$query";
    final response = await http.get(
        Uri.parse(url),
        headers: {"Authorization": "KakaoAK $REST_API_KEY"}
    );

    return jsonDecode(response.body)['documents']
        .map<ImageModel>((json) => ImageModel.fromJson(json))
        .toList();
  }
}

2. SearchViewModel

SearchViewModel은 검색 기능을 관리하고 뷰에 변경 사항을 알리는 역할을 수행합니다.

class SearchViewModel extends ChangeNotifier {
  final SearchRepository _repository;
  final List<ImageModel> _imageList = [];
  String _inputText = '';
  int _page = 1;
  bool _isLoading = false;

  SearchViewModel(this._repository);

  List<ImageModel> get imageList => _imageList;
  String get inputText => _inputText;
  bool get isLoading => _isLoading;

  void updateInputText(String text) {
    _inputText = text;
  }

  void nextPage() {
    _page++;
  }

  /// 검색 초기화
  void resetSearch() {
    _inputText = '';
    resetImageList();
  }

  /// 검색 시작
  void search() {
    resetImageList();
    getImageList();
  }

  /// 이미지 리스트 초기화
  void resetImageList() {
    _imageList.clear();
    _page = 1;
    notifyListeners();
  }

  /// 이미지 조회
  Future<void> getImageList() async {
    if (_isLoading) return;

    _isLoading = true;
    List<ImageModel> images = await _repository.getImageList(_inputText, _page);
    _imageList.addAll(images);
    _isLoading = false;

    notifyListeners();
  }
}

3. SearchPage

SearchPage는 검색 페이지의 뷰를 담당합니다

class SearchPage extends StatefulWidget {
  
  State<StatefulWidget> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  late ScrollController _scrollController;

  
  void initState() {
    super.initState();

    // 무한 스크롤
    _scrollController = ScrollController();
    _scrollController.addListener(() {
      final viewModel = Provider.of<SearchViewModel>(context);
      if (viewModel.isLoading) return;

      if (_scrollController!.offset >= _scrollController!.position.maxScrollExtent &&
          !_scrollController!.position.outOfRange) {
        viewModel.nextPage();
        viewModel.getImageList();
      }
    });
  }

  
  dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  /// 이미지 클릭
  onTapImage(ImageModel image) {
    // 상세 페이지로 이동
    Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(image: image)));
  }

  
  Widget build(BuildContext context) {
    final viewModel = Provider.of<SearchViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Center(child: _buildSearchBar()),
        backgroundColor: Colors.black,
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: MasonryGridView.count(
            crossAxisCount: 2,
            mainAxisSpacing: 8,
            crossAxisSpacing: 8,
            controller: _scrollController,
            itemCount: viewModel.imageList.length,
            itemBuilder: (BuildContext context, int index) {
              final image = viewModel.imageList[index];
              return ImageCell(
                onTap: () => onTapImage(image),
                image: image,
              );
            },
          ),
        ),
      ),
    );
  }

  /// 검색창을 생성합니다
  Widget _buildSearchBar() {
    final viewModel = Provider.of<SearchViewModel>(context);

    return SearchBar(
      trailing: const [Icon(Icons.search)],
      constraints: const BoxConstraints(maxWidth: 300, minHeight: 36),
      hintText: "검색어를 입력하세요",
      textInputAction: TextInputAction.done,
      onChanged: (text) {
        viewModel.updateInputText(text);
      },
      onSubmitted: (text) {
        if (viewModel.inputText.isNotEmpty) {
          viewModel.search();
          return;
        }

        // 검색어 미입력 시 스낵바로 알림
        ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
          content: Text("검색어를 입력하세요"),
          duration: Duration(seconds: 2),
        ));
      },
    );
  }
}

완성된 검색 결과 화면




3. 즐겨찾기

즐겨찾기 기능을 추가합니다.

1. FavoriteRepository

FavoriteRepository는 즐겨찾기 이미지를 로컬 저장소에 저장하고 로드하는 기능을 제공합니다.

class FavoriteRepository {
  late SharedPreferences pref;
  late List<ImageModel> _favoriteImageList;

  FavoriteRepository() {
    _favoriteImageList = List.empty(growable: true);
    _load();
  }

  List<ImageModel> get favoriteImageList => _favoriteImageList;

  /// 즐겨찾기 데이터를 불러옵니다
  void _load() async {
    pref = await SharedPreferences.getInstance();
    final json = pref.getString(FAVORITE_DATA_KEY);

    if (json != null && json.isNotEmpty) {
      _favoriteImageList = jsonDecode(json)['favorite_list']
          .map<ImageModel>((json) => ImageModel.fromJson(json))
          .toList();
    }
  }

  /// 즐겨찾기 이미지를 설정합니다
  void setFavoriteImage(ImageModel image, bool isFavorite) async {
    if (isFavorite) {
      _favoriteImageList.add(image);
    } else {
      _favoriteImageList.removeWhere((it) => it.imageUrl == image.imageUrl);
    }

    Map<String, dynamic> jsonMap = {
      'favorite_list': _favoriteImageList.map((image) => image.toJson()).toList(),
    };
    pref.setString(FAVORITE_DATA_KEY, jsonEncode(jsonMap));
  }

  bool isFavoriteImage(ImageModel image) {
    return _favoriteImageList.any((it) => it.imageUrl == image.imageUrl);
  }
}

2. FavoriteViewModel

FavoriteViewModel은 즐겨찾기 이미지를 관리하고 뷰에 변경 사항을 알리는 역할을 수행합니다.

class FavoriteViewModel extends ChangeNotifier {
  final FavoriteRepository _repository;

  FavoriteViewModel(this._repository);

  List<ImageModel> get favoriteImages => _repository.favoriteImageList;

  void toggleFavoriteStatus(ImageModel image) {
    _repository.setFavoriteImage(image, !isFavoriteImage(image));
    notifyListeners();
  }

  bool isFavoriteImage(ImageModel image) {
    return _repository.isFavoriteImage(image);
  }
}

3. FavoritePage

FavoritePage는 즐겨찾기 페이지의 뷰를 담당합니다

class FavoritePage extends StatefulWidget {
  
  State<StatefulWidget> createState() => _FavoritePageState();
}

class _FavoritePageState extends State<FavoritePage> {
  /// 이미지 클릭
  onTapImage(ImageModel image) {
    // 상세 페이지로 이동
    Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(image: image)));
  }

  
  Widget build(BuildContext context) {
    final viewModel = Provider.of<FavoriteViewModel>(context);

    return Scaffold(
      appBar: AppBar(title: const Center(child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.favorite, color: Colors.pink,),
          Padding(padding: EdgeInsets.only(left: 8)),
          Text("Favorite"),
        ],
      ),), backgroundColor: Colors.black, foregroundColor: Colors.white,),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: MasonryGridView.count(
            crossAxisCount: 2,
            mainAxisSpacing: 8,
            crossAxisSpacing: 8,
            itemCount: viewModel.favoriteImages.length,
            itemBuilder: (BuildContext context, int index) {
              final image = viewModel.favoriteImages[index];
              return ImageCell(
                onTap: () => onTapImage(image),
                image: image,
              );
            },
          ),
        ),
      ),
    );
  }
}

완성된 즐겨찾기 화면




마치며

개발을 마치고 테스트를 하던 중 페이지 전환 시 리렌더링되는 현상이 거슬려 TabBarView를 IndexedStack으로 변경했습니다.
추후에 유사한 UX를 구현하게되면 TabBarView, IndexedStack, PageView를 비교하여 보다 적합한 위젯을 선정해야 할 것 같습니다.

완성된 전체 소스코드는 GitHub에서 확인할 수 있습니다.

profile
Hello!

0개의 댓글

관련 채용 정보