본문 바로가기
카테고리 없음

[Flutter] 개념 확장 #03 - 상태 관리 (StatefulWidget & InheritedWidget)

by NAIMJAE 2025. 1. 22.

#03 개념 확장 - 상태 관리 (StatefulWidget & InheritedWidget)

 


Ⅰ 상태관리의 필요성

01. 상태 관리

  • 모바일 환경에서는 사용자와의 상호작용을 기반으로 실시간으로 변하는 데이터와 UI를 처리해야 함
  • 사용자와 상호작용하는 순간마다 애플리케이션의 상태가 변경
  • 상태를 효율적으로 관리해야 앱의 성능 저하를 막고, 예상치 못한 오류를 방지할 수 있음
  • Flutter는 이러한 상태 관리를 효울적으로 처리할 수 있는 다양한 도구들을 제공

Ⅱ StatefulWidget을 이용한 상태 관리

01. StatefulWidget

StatefulWidget

  • StatefulWidget은 Flutter에서 상태를 관리하는 가장 기본적인 방식
  • 앱의 UI가 동적으로 변경될 필요가 있을 때 사용하는 위젯

구조와 특징

  • StatefulWidgetStatefulWidgetState로 구성
  • UI는 State 클래스에 정의되며, 상태가 변경되면 setState() 메서드를 호출해 업데이트

02. StatefulWidget 상태 관리 예제

  • 도서 목록에서 +, - 버튼을 클릭해 도서를 선택하는 예제
  • 사용자가 선택한 도서는 Cart 페이지에 표시

main

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: HomeScreen(mySelectedBook),
    );
  }
}

 

HomeScreen

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int pageIndex = 0; // bottomNavigation을 위한 현재 선택된 페이지 index
  List<String> mySelectedBook = []; // 사용자가 선택한 Book List (상태 관리 변수)

  // HomeScreen 의 상태를 관리하기 위한 함수 (상태 변경 함수)
  void _selectedBook(String book) {
    // setState 메서드를 호출해 화면 재랜더링
    setState(() {
      if (mySelectedBook.contains(book)) {
        mySelectedBook.remove(book);
      } else {
        mySelectedBook.add(book);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(title: Text('상태관리')),
        body: IndexedStack(
          index: pageIndex,
          children: [
            // ListPage에 필요한 매개 변수 전달
            ListPage(
              onSelectedBook: _selectedBook,
              selectedBook: mySelectedBook,
            ),
            CartPage(),
          ],
        ),
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: pageIndex,
          items: [
            BottomNavigationBarItem(
              icon: Icon(Icons.list),
              label: 'list',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.shopping_cart),
              label: 'cart',
            ),
          ],
          onTap: (index) {
            setState(() {
              pageIndex = index;
            });
          },
        ),
      ),
    );
  }
}

 

ListPage

class ListPage extends StatelessWidget {
  // 버튼을 선택했을 때 호출될 함수 (부모 위젯의 상태 변경 함수)
  final Function onSelectedBook; 
  final List<String> selectedBook;

  final List<String> books = ['호모사피엔스', '다트입문', '홍길동전', '코드리팩토링', '전치사도감'];

  ListPage(
      {required this.onSelectedBook, required this.selectedBook, super.key});

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: books.map((book) {
        final isSelectedBool = selectedBook.contains(book);
        return ListTile(
          leading: Container(
            width: 40,
            height: 40,
            decoration: BoxDecoration(
              color: Colors.redAccent,
              borderRadius: BorderRadius.circular(8.0),
            ),
          ),
          title: Text(
            book,
            style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
          ),
          trailing: IconButton(
            // 버튼을 클릭할 때 부모 위젯의 상태 관리 함수 호출
            onPressed: () {
              onSelectedBook(book);
            },
            icon: Icon(isSelectedBool ? Icons.remove_circle : Icons.add),
            color: isSelectedBool ? Colors.red : Colors.green,
          ),
        );
      }).toList(),
    );
  }
}

 

CartPage

class CartPage extends StatelessWidget {
  final List<String> selectedBook;

  const CartPage(this.selectedBook, {super.key});

  @override
  Widget build(BuildContext context) {
    return ListView(
      children:
          selectedBook.map((book) => ListTile(title: Text(book))).toList(),
    );
  }
}

 

코드 흐름

  • 부모 위젯에서 상태를 관리하는 변수와 상태를 변경하는 메서드를 정의
  • 상태를 관리하는 변수와 상태를 변경하는 메서드를 자식 위젯에게 전달
  • 자식 위젯에서 전달 받은 상태 변경 메서드를 호출
  • 부모 위젯의 상태 변경 메서드에서 setState() 메서드를 이용해 상태 변경하고 UI를 갱신
  • 부모 위젯에서 변경된 상태는 자식 위젯과 동기화되어, 자식 위젯은 최신 상태로 UI를 랜더링

Ⅲ InheritedWidget을 이용한 상태 관리

01. InheritedWidget

InheritedWidget

  • 위젯 트리에서 데이터를 상위 위젯으로부터 하위 위젯으로 효율적으로 전달하기 위해 사용
  • 상태 관리의 기본 원리와 Provider 같은 라이브러리의 기반을 이해하는 데 중요한 역할을 함

구조와 특징

  • 데이터 트리를 전체에서 공유해야 할 때 사용
  • 데이터가 변경되면 이를 참조하는 모든 위젯이 재빌드
  • 성능 최적화가 가능하며, 적절한 설계가 중요

02. InheritedWidget 상태 관리 예제

  • 도서 목록에서 +, - 버튼을 클릭해 도서를 선택하는 예제
  • 사용자가 선택한 도서는 Cart 페이지에 표시

main

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      home: HomeScreen(),
    ),
  );
}

 

HomeScreen

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int pageIndex = 0;
  List<String> mySelectedBook = [];

  // 상태를 변경하는 메서드
  void _toggleSaveStates(String book) {
    setState(() {
      if (mySelectedBook.contains(book)) {
        // 리스트를 불변 데이터로 관리하여 상태 변경을 감지
        mySelectedBook = List.from(mySelectedBook)..remove(book);
      } else {
        mySelectedBook = List.from(mySelectedBook)..add(book);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          title: Text('상태관리'),
          backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
        ),
        body: InheritedParent(
          state: mySelectedBook,
          onPressed: _toggleSaveStates,
          child: IndexedStack(
            index: pageIndex,
            children: [
              BookListPage(),
              BookCartPage(),
            ],
          ),
        ),
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: pageIndex,
          onTap: (index) {
            setState(() {
              pageIndex = index;
            });
          },
          items: [
            BottomNavigationBarItem(icon: Icon(Icons.list), label: 'book-list'),
            BottomNavigationBarItem(icon: Icon(Icons.shopping_cart), label: 'cart'),
          ],
        ),
      ),
    );
  }
}

 

BookListPage

class BookListPage extends StatelessWidget {
  BookListPage({super.key});

  final List<String> books = ['호모사피엔스', '다트입문', '홍길동전', '코드리팩토링', '전치사도감'];

  @override
  Widget build(BuildContext context) {
    // InheritedParent 위젯에 접근하여 상태 가져오기
    InheritedParent? inheritedParent = context.dependOnInheritedWidgetOfExactType();

    List<String> selectedBook = inheritedParent?.state ?? []; // 선택된 책 목록 초기값 설정

    return inheritedParent == null
        ? Center(child: Text('Data Null'))
        : ListView(
            children: books.map(
              (book) {
                final isSelectedBool = selectedBook.contains(book);
                return ListTile(
                  leading: Container(
                    width: 35,
                    height: 35,
                    decoration: BoxDecoration(
                      color: Theme.of(context).secondaryHeaderColor,
                      borderRadius: BorderRadius.circular(8.0),
                      border: Border.all(color: Colors.black),
                    ),
                  ),
                  title: Text(
                    book,
                    style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
                  ),
                  trailing: IconButton(
                    onPressed: () {
                      inheritedParent.onPressed(book);
                    },
                    icon: Icon(
                      isSelectedBool ? Icons.remove_circle : Icons.add,
                      color: isSelectedBool ? Colors.red : Colors.green,
                    ),
                  ),
                );
              },
            ).toList(),
          );
  }
}

 

BookCartPage

class BookCartPage extends StatelessWidget {
  const BookCartPage({super.key});

  @override
  Widget build(BuildContext context) {
    // InheritedParent 위젯에 접근하여 상태 가져오기
    InheritedParent? inheritedParent = context.dependOnInheritedWidgetOfExactType();

    List<String> selectedBook = inheritedParent?.state ?? []; // 선택된 책 목록 초기값 설정

    return ListView(
      children:
          selectedBook.map((book) => ListTile(title: Text(book))).toList(),
    );
  }
}

 

InheritedParent

class InheritedParent extends InheritedWidget {
  List<String> state; // 공유 상태 데이터 관리

  void Function(String book) onPressed; // 상태 변경 메서드

  InheritedParent(
      {required this.state, required this.onPressed, required super.child});

  // 상태가 변경되었는지 여부를 판단하는 메서드
  // 주의점 : InheritedWidget을 상속받아 재정의한 InheritedParent을 넣어주자
  @override
  bool updateShouldNotify(covariant InheritedParent oldWidget) {
    print('InheritedParent - updateShouldNotify() 호출');
    print('state : ${state.toString()}');
    print('oldWidget.state : ${oldWidget.state.toString()}');

    // 상태가 달라졌음을 판단하는 방법 - 현재의 state와 oldWidget의 state를 비교
    if (state.length != oldWidget.state.length) {
      print('상태 변경됨');
      return true;
    }

    for (int i = 0; i < state.length; i++) {
      if (state[i] != oldWidget.state[i]) {
        print('상태 변경됨');
        return true;
      }
    }
    print('상태 변경 안됨');
    
    // 상태 변경이 있으면 true, 없으면 false -> bool 여부에 따라 자식 위젯의 build 재호출 결정
    return false; 
  }
}

 

코드 흐름

  • InheritedParent는 상태를 관리하는 변수와 상태를 변경하는 메서드를 정의하고 전달하는 역할
  • 부모 위젯인 HomeScreen 위젯에서 전체 상태를 관리
  • InheritedParent를 통해 하위 위젯에 상태와 상태 변경 메서드를 전달
  • 하위 위젯은 InheritedParent를 통해 상태를 참조하며, 상태 변경 메서드를 호출해 상태 변경
  • 상태를 변경하면 부모에서 관리하는 상태가 업데이트 되고, InheritedParent가 이를 감지해 하위 위젯을 재랜더링 하여 동기화