책 검색 앱 (6) — 뷰모델 구현 & 마무리

Eungae's avatar
Jan 19, 2026
책 검색 앱 (6) — 뷰모델 구현 & 마무리

HomeViewModel 구현

home_view_model.dart
작업 순서
  1. 상태 클래스 만들기
  1. 뷰모델 만들기 — Notifier 상속
  1. 뷰모델 관리자 만들기 — NotifierProvider 객체
import 'package:book_search/data/models/book.dart'; import 'package:book_search/data/repositories/book_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // 1. 상태 클래스 만들기 class HomeState{ List<Book> books; HomeState(this.books); } // 2. 뷰모델 만들기 - Notifier 상속 class HomeViewModel extends Notifier<HomeState> { // Notifier 상속 시 이 뷰모델이 어떤 상태를 관리하는지 제너릭으로 명시해줄 것. @override HomeState build() { return HomeState([]); // 검색하기 전 최초의 상태는 북리스트가 비어있으므로 빈 리스트로 초기화. } // 사용자가 검색했을 때 데이터를 가지고 와서 이 뷰모델의 상태를 업데이트 해줄 수 있는 메서드. void searchBooks(String query) async { final bookRepository = BookRepository(); final books = await bookRepository.searchBooks(query); // Book으로부터 전달받은 쿼리를 가져오도록. -> async - await 걸어서 동기화. state = HomeState(books); // 결과를 가져왔으니 상태를 업데이트 해줌. state에 새로운 객체 HomeState(books)로 업데이트 하도록. } } // 3. 뷰모델 관리자 만들기 - NotifierProvider 객체 final homeViewModelProvider = NotifierProvider<HomeViewModel, HomeState>(() { // 이 관리자가 어떤 뷰모델을 관리하는지(HomeViewModel), 그 뷰모델은 어떤 상태를 가지고 있는지(HomeState)를 알려줘야 함. return HomeViewModel(); });
 
📍 git push

뷰모델 테스트

test/home_view_model_test.dart 생성
import 'package:book_search/presentation/home/home_view_model.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test("HomeViewModel test", () async { // 복습!! // 플러터에서 riverpod 사용할 때, 뷰모델 공급받을 때 ProviderScope 내에 있어야만 공급받을 수 있음. // ConsumerWidget을 통해 WidgetRef라는 타입의 ref변수를 통해서 참조 했었음. // 앱 내에서는 ProviderScope 안에서 뷰모델을 관리. 테스트 환경에서는 Widget 을 생성하지 않고 테스트 할 수 있게 ProviderContainer 제공 final providerContainer = ProviderContainer(); addTearDown(providerContainer.dispose); final HomeVm = providerContainer.read(homeViewModelProvider.notifier); // 처음 HomeViewModel의 상태 => 빈 리스트 연결 테스트 providerContainer.read(homeViewModelProvider); final firstState = providerContainer.read(homeViewModelProvider); expect(firstState.books.isEmpty, true); // HomeViewModel에서 searchBooks 호출 후 상태가 정상적으로 변경되는지 테스트 await HomeVm.searchBooks('harry'); final afterState = providerContainer.read(homeViewModelProvider); expect(afterState.books.isEmpty, false); afterState.books.forEach((element){ print(element.toJson()); }); }); }
테스트를 돌려보면 정상적으로 돌아간다.
📍 git push
 
 

HomePage에 붙이기

home_page.dart 현재 코드
import 'package:book_search/presentation/home/widgets/home_bottom_sheet.dart'; import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { TextEditingController textEditingController = TextEditingController(); @override void dispose() { // TextEditingController 사용시에는 반드시 dispose 호출해주어야 메모리에서 소거됨! textEditingController.dispose(); super.dispose(); } void onSearch(String text) { print("onSearch 호출됨"); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () { FocusScope.of(context).unfocus(); }, child: Scaffold( appBar: AppBar( actions: [ GestureDetector( onTap: () { onSearch(textEditingController.text); }, // 버튼의 터치영역은 44 디바이스 픽셀 이상으로 설정할 것 -> 이하일 경우 오류 발생 child: Container( width: 50, height: 50, // 컨테이너에 배경색이 없으면 자녀위젯에만 터치 이벤트가 적용됨. color: Colors.transparent, child: Icon(Icons.search), ), ), ], title: TextField( textInputAction: TextInputAction.search, onSubmitted: onSearch, maxLines: 1, controller: textEditingController, decoration: InputDecoration( contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), hintText: "검색어를 입력해 주세요", enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(width: 2), ), ), ), ), body: GridView.builder( padding: EdgeInsets.all(20), itemCount: 10, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, childAspectRatio: 3 / 4, crossAxisSpacing: 10, mainAxisSpacing: 10, ), itemBuilder: (context, index) { return GestureDetector( onTap: () { showModalBottomSheet( context: context, builder: (context) { return HomeBottomSheet(); }, ); }, child: Image.network( 'https://fastly.picsum.photos/id/705/300/400.jpg?hmac=fS4rfBAi01bPlJVcEOYfDlIGjxgtg21XNhBAgAThAOQ', ), ); }, ), ), ); } }
작업 개요
  • onSearch 함수에서 HomeViewModel의 searchBooks 메서드 호출.
  • 홈뷰모델의 상태를 참조하고 있는 부분에 ConSumerStatefulWidget 사용하여 데이터 씌우기
 
ConsumerStatefuleWidget
StatefulWidget 사용 시 사용가능.
StatefulWidget → ConsumerStatefulWidget으로 수정 ⇒ flutter_riverpod.dart 자동 임포트
State<HomePage> → ConsumerState<HomePage>로 수정.
before
notion image
after
notion image
ConsumerStateful을 사용하면 빌드함수를 정의하는 ConsumerState클래스 내에서 WidgetRef가 참조 가능해진다.
→ onSearch 함수에서 ref를 편하게 쓸 수 있음.
& 별도로 Consumer로 감싸지 않아도 돼서 가독성이 더 좋아짐.
 
필요한 부분들에 상태 적용하기
  1. build 메서드 내에서 final homeState 정의
    1. final homeState = ref.watch(homeViewModelProvider); // 상태가 변경되면 계속 감지해야 하니까 홈뷰모델 관리자에게 상태를 달라고 요청하는 것.
      → homeState의 타입은 HomeState.
  1. 다음으로 상태가 필요한 부분: 그리드 뷰 부분
    1. itemCount : (기존) 10 → (수정) homeState.books.length 로 수정
    2. 아이템 갯수만큼 itemBuilder 속성에서 정의한 함수가 실행되어 children을 구성할 것이므로 여기도 수정
      1. → final book 정의
        final book = homeState.books[index]; // homeState.books의 갯수만큼 0부터 n-1까지 인덱스가 전달됨. // 각각 돌 때마다 이 book은 n-1번째 책의 객체가 될 것.
    3. image.network 수정: (기존) url → (수정) book.image
      1. child: Image.network(book.image)
         
onSearch 함수에서 뷰모델에 있는 searchBooks 메서드를 호출
void onSearch(String text) { // TODO 홈뷰모델의 searchBooks 메서드 호출 ref.read(homeViewModelProvider.notifier).searchBooks(text); print("onSearch 호출됨");
 
 
main.dart
  1. 작업 편의를 위해 주석처리했던 home: HomePage를 살려주고, body를 주석 처리한다.
  1. main 함수에서 실행하고 있는 함수는 현재 runApp(const MyApp()); 일텐데(이게 최상위 위젯임) → 이거를 ProviderScope로 묶어 줘야 한다.
    1. import 'package:book_search/presentation/detail/detail_page.dart'; import 'package:book_search/presentation/home/home_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp(ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: HomePage(), //home: DetailPage(), ); } }
 
⇒ 시뮬레이터 열어서 hot restart 실행해본다.
notion image
이렇게 아름답게 나와버리는 것!!
 
notion image
이제 바텀시트에도 정보들이 연동되도록 작업해주어야 한다.
📍 git push
 

바텀시트 작업

home_bottom_sheet.dart
  1. HomeBottomSheet 클래스 내에 Book book 정의
    1. Book 입력 시 자동완성되도록 하여 /data/models/book.dart를 임포트 해줘야 한다.
  1. 생성자 생성
  1. → HomePage(home_page.dart)에 빨간 줄 생성됨. why? → 바텀시트에서 반환할 HomeBottomSheet 클래스는 생성자에서 book 변수를 갖는다고 방금 설정했는데, HomePage에 설정된 바텀시트에는 변수가 없기 때문 → 여기에도 book 넣어주어서 HomePage와 바텀시트를 연결해준다. 이 때의 book은 당연히 Book 클래스의 변수인 book이고, 입력할 때 자동완성 기능을 활용하여 Book클래스의 변수 book이 입력되도록 한다.
  1. 전달받은 book 객체에 해당하는 데이터로 수정
    1. 이미지: (현재) url → (수정) book.image
    2. 제목: (현재) “해리포터” → (수정) book.title
    3. 저자: (현재) “J.K.롤링” → (수정) book.author
    4. 각 항목은 최대 2줄이 넘어가지 않도록 설정: maxLines: 2
 
📍git push : “bottomSheet 변수 연결”
 
 

디테일 페이지 작업

detail_page.dart
  1. DetailPage 클래스 내에 Book book 정의
    1. → book.dart 임포트
  1. 생성자 생성
  1. home_bottom_sheet.dart 의 빨간 줄 수정: DetailPage에서 호출하는 변수 넣어주기.
  1. 전달받은 book 객체에 해당하는 데이터로 수정
 
📍 git push: “DetailPage 변수 연결, 프로젝트 완료”
 
 
 
 

마무리

이 전체를 다 외울 필요는 없다고 하신다.
개발할 때는 흐름이 중요함.
  1. UI 먼저 그리기
  1. API 테스트 해보기
  1. 모델 만들고
  1. 레포 만들고
  1. 뷰모델 만들고
  1. 각각 만들 때마다 테스트 진행
  1. 마지막으로 위젯에 데이터 씌우기
이 순서를 기억하는 것이 핵심!
⇒ 실습 해보자.
 
Share article

나새끼메이커