ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Google Codelabs Flutter 앱 만들기 실습 2
    Flutter 2023. 8. 3. 12:16
    728x90
    반응형
    SMALL

    코드랩스 플러터앱 만들기 실습을 이어가보도록 하겠습니다.

     

    6. 좋아요 버튼 추가

    현재 앱 상태는 단어 쌍이 다음 것으로 교체되면 없어지는 형태입니다. 한 번 지나간 단어도 다시 볼 수 있도록 하기 위해 favorite 버튼을 제작해보도록 하겠습니다.

     

    리스트 선언

    상태값에 favorites 리스트를 선언해줍니다.

    또한, favorites 리스트에 단어를 넣거나 빼줄 수 있는 로직을 추가해줍니다.

    class MyAppState extends ChangeNotifier {
      ...
    
      var favorites = <WordPair>[];
    
      void toggleFavorites(){
        if(favorites.contains(pairWords)){
          favorites.remove(pairWords);
        } else {
          favorites.add(pairWords);
        }
        notifyListeners();
      }
    }
    • favorites 리스트의 경우 제너릭 타입에 WordPair 타입의 원소만 올 수 있도록 해줌으로써 리스트에 WordPair 타입의 원소만 넣거나 뺄 수 있도록 해줍니다. 이러한 제한을 통해 앱은 의도치 않은 결과를 줄일 수 있도록 해줍니다.
    • toggleFavorites() 메서드 내에서 단어를 favorites 리스트에 추가해주거나 빼주고 나서 notifyListeners()를 호출해 상태값의 변경이 있음을 알려줍니다.

     

    버튼 추가

    실제 동작을 할 수 있는 버튼을 추가해줍니다.

    버튼은 한 번 누르면 속이 채워진 하트 아이콘이 나오도록, 다시 누르면 테두리만 있는 하트 아이콘이 나오도록 해줍니다.

    IconData icon;
    
    if(appState.favorites.contains(pairWords)){
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }
    
    ...
    
    ElevatedButton.icon(onPressed: (){
      appState.toggleFavorites();
    },
    icon: Icon(icon),
    label: Text('like'))
    • button에서 사용 할 icon을 정의해줍니다. Material 패키지에 포함되어 있는 Icons 내부에 사용 할 수 있는 icon들이 정의되어 있습니다.
    • ElevatedButton의 icon 속성을 통해 icon이 포함된 버튼을 생성 할 수 있습니다. onPressed 내부에서 toggleFavorites 메서드를 호출해줍니다.

     

    이 후 Row 위젯을 통해 가로 정렬을 해줍니다.

    Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        ElevatedButton.icon(onPressed: (){
          appState.toggleFavorites();
        },
        icon: Icon(icon),
        label: Text('like')),
        SizedBox(width: 10,),
        ElevatedButton(onPressed: (){
          appState.getNext();
        }, child: Text('Next')),
      ],
    )

     

    7. 탐색 레일 추가

    현재 Favorites에 담겨 있는 단어들을 볼 수 있는 화면은 존재하지 않습니다. 이를 추가해주기 위해 탐색 레일을 추가해보도록 하겠습니다.

    이를 위해 MyHomePage 위젯을 두 개로 분할해 줍니다.

    class MyHomePage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Row(
            children: [
              SafeArea(
                child: NavigationRail(
                  extended: false,
                  destinations: [
                    NavigationRailDestination(
                      icon: Icon(Icons.home),
                      label: Text('Home'),
                    ),
                    NavigationRailDestination(
                      icon: Icon(Icons.favorite),
                      label: Text('Favorites'),
                    ),
                  ],
                  selectedIndex: 0,
                  onDestinationSelected: (value) {
                    print('selected: $value');
                  },
                ),
              ),
              Expanded(
                child: Container(
                  color: Theme.of(context).colorScheme.primaryContainer,
                  child: GeneratorPage(),
                ),
              ),
            ],
          ),
        );
      }
    }
    
    class GeneratorPage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        var appState = context.watch<MyAppState>();
        var pairWords = appState.pairWords;
    
        IconData icon;
        if (appState.favorites.contains(pairWords)) {
          icon = Icons.favorite;
        } else {
          icon = Icons.favorite_border;
        }
    
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              BigCard(pairWords: pairWords),
              SizedBox(height: 10),
              Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  ElevatedButton.icon(
                    onPressed: () {
                      appState.toggleFavorite();
                    },
                    icon: Icon(icon),
                    label: Text('Like'),
                  ),
                  SizedBox(width: 10),
                  ElevatedButton(
                    onPressed: () {
                      appState.getNext();
                    },
                    child: Text('Next'),
                  ),
                ],
              ),
            ],
          ),
        );
      }
    }

     

    • MyHomePage의 모든 부분은 GeneratorPage로 추출됐습니다.
    • 바뀐 MyHomePage은 SafeArea, Expanded 두 위젯으로 나뉘었습니다.
    • SafeArea는 하위 요소가 하드웨어 노치나 상태 표시줄에 가려지지 않도록 해줍니다. 여기서는 NavigationRail을 래핑했고, 탐색 버튼이 상태 표시줄에 가려지지 않도록 해줍니다.
    • NavigationRail의 extended 속성은 boolean 값을 갖습니다(현재 false로 고정되어 있습니다). true일 경우 NavigationRailDestination의 icon 옆에 label이 보이게 됩니다(왼쪽 사진). false일 경우 아이콘만 표시됩니다(오른쪽 사진).

     

     

    • 현재 탐색 레일에는 두 가지 대상이 있습니다. 탐색 레일은 selectedIndex를 통해 레일에서 선택된 Destination을 알 수 있습니다. 첫 번째 Destination부터 0, 1, 2, 인덱스가 차례로 주어집니다. 위의 경우 home이 0, favorites가 1이 됩니다. (현재 0으로 고정되어 있습니다.)
    • onDestinationSelected는 Destination 중 하나를 선택했을 때 발생하는 작업을 정의합니다.
    • Expanded 위젯은 행과 열에서 유용한 위젯입니다. 이 위젯을 사용하면 같은 행이나 열에 포함된 요소들은 자신이 필요한 만큼만 공간을 차지하게 되고, Expanded 위젯에 포함된 하위 요소들은 남은 공간을 최대한 차지할 수 있도록 해줍니다.
      차이를 알기 위해 SafeArea를 Expanded로 래핑해보도록 하겠습니다.

     

    왼쪽 사진은 Expanded요소가 한 개인 경우이고, 오른쪽 사진은 Expanded 요소가 두 개인 경우입니다.

    Row의 하위 요소 모두가 Expanded인 경우 두 Expanded 위젯은 최대한 많은 공간을 차지하려고 하기 때문에 사용 가능한 모든 가로 공간을 절반씩 분할해 차지한 것을 알 수 있습니다.

    • Expanded 위젯 내부에 Container 위젯이 있고 이 Container 위젯에서 배경 색을 지정해주었습니다.

     

     

    StatelessWidget, StatefulWidget

    Stateless위젯에서 상태를 가질 수 없으므로 현재까지 MyAppState에서 모든 상태값들을 정의해 사용했습니다.

    현재 앱에 탐색 레일이 추가되면서 더 맣은 상태값(selectedIndex 등)을 가져야만 하는데, 상태값이 추가 될 때마다 이 모든 것을 MyAppState에서 처리하기에는 무리가 있습니다.

     

    그렇기 때문에 단일 위젯에 관련된 상태값과 같은 경우 위젯을 StatefulWidget으로 정의하여 해당 위젯에서 관리 할 수 있도록 해줍니다.

     

    MyHomePage 클래스에 selectedIndex와 같은 상태값이 존재하게 되므로 이를 StatefulWidget으로 변경해줍니다.

    MyHomePage 클래스 명 위에 커서를 둔 뒤 alt + enter 키를 눌러주고 convert to StatefulWidget을 클릭해줍니다.

    class MyHomePage extends StatefulWidget {
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      var selectedIndex = 0;
    
      @override
      Widget build(BuildContext context) {

    IDE에서 스스로 _MyHomePageState를 만드는 것을 알 수 있고 이 클래스는 State를 상속 받고 있습니다.

     

    클래스 명 앞에 "_"가 붙은 것은 해당 클래스를 비공개로 만들며 컴파일러에 의해 시행됩니다.

    _MyHomePageState에 selectedIndex를 상태값을 추가해줍니다.

     

    이 후, NavigationRail의 selectedIndex를 상태값인 selectedIndex로 변경해주고 onDestinationSelected 메서드에서 선택된 index값에 따라 selectedIndex 값이 달라질 수 있도록 합니다.

    class _MyHomePageState extends State<MyHomePage> {
      var selectedIndex = 0; //추가
    
      ...
    
                child: NavigationRail(
                  ...
                  selectedIndex: selectedIndex, //추가
                  onDestinationSelected: (value) {
                    setState(() { //추가
                      selectedIndex = value;
                    });
                  },
                ),
              ),
              ...

    onDestinationSelected 콜백이 호출되면 setState에 의해 selectedIndex가 새로운 값으로 변경됩니다. 이 호출은 이전의 notifyListeners() 메서드와 유사합니다.

     

    여기서 selectedIndex가 변경될 때마다 Destination에 맞는 화면이 뜨도록 해줍니다.

    @override
    Widget build(BuildContext context) {
      Widget widget;
    
      if(selectedIndex==0){
        widget = GeneratorPage();
      } else if(selectedIndex==1){
        widget = Placeholder();
      } else{
        throw UnimplementedError('no widget for $selectedIndex');
      }
      ...
    
    Expanded(
      child: Container(
        color: Theme.of(context).colorScheme.primaryContainer,
        child: widget,
      ),
    ),
    • Placeholder 위젯은 아직 완성이 되지 않은 위젯을 표시 할 때 사용 할 수 있습니다.
    • selectedIndex가 0, 1이 아닐 경우 Error를 던져줍니다. 이 후에 Destination이 추가되고 selectedIndex에 대한 로직을 추가해주지 않을 경우 발생 할 수 있는 오류를 미연에 방지해줍니다.

     

    이제 선택된 Destination에 따라 UI가 업데이트 됩니다.

     

     

    반응형 추가

    플러터는 크기에 따라 앱이 자동으로 반응하도록 할 수 있는 위젯을 제공해줍니다. Wrap 위젯은 가로나 세로 공간이 부족 할 경우 하위 요소를 다음 줄에 래핑('run'이라고 함) Row나 Column과 유사한 위젯입니다. 또한, FittedBox 위젯은 사양에 따라 하위 요소를 사용 가능한 공간에 자동으로 맞춰줍니다.

     

    현재 탐색 레일은 화면의 크기에 상관없이 라벨은 표시되지 않고 있습니다. 이를 화면이 커질 때에는 라벨이 보이도록, 화면이 작다면 라벨이 보이지 않도록 해주겠습니다.

     

    _MyHomePageState의 build 메소드의 return Scaffold에 커서를 두고 refactor 메뉴를 켜줍니다. 이 후 Wrap with Builder를 클릭하고 Builder 위젯을 LayoutBuilder로 변경해줍니다. 이 후, builder 속성의 함수 인자에 constraints를 추가해줍니다.

    그 다음, NavigationRail의 extended 속성에 화면 크기에 따른 제약 조건을 추가해줍니다.

      ...
      @override
      Widget build(BuildContext context) {
        ...
        return LayoutBuilder( // 수정
          builder: (context, constraints) { // 수정
            return Scaffold(
              body: Row(
                children: [
                  SafeArea(
                    child: NavigationRail(
                      extended: constraints.maxWidth >= 600, // 수정
                      ...
    • LayoutBuilder의 builder 콜백은 제약 조건이 변경될 때마다 호출됩니다. 제약 조건은 다음과 같습니다.
      • 사용자가 앱의 창 크기를 조절 할 경우
      • 사용자가 휴대전화를 가로 모드에서 세로모드 혹은 그 반대로 회전할 경우
      • MyHomePage 옆에 있는 일부 위젯의 크기가 커져 MyHomePage의 제약 조건이 작아질 경우
      • 등등...
    • constraints 의 maxWidth 속성을 통해 화면의 최대 크기가 600px이 넘어갈 경우 확장될 수 있도록 해주었습니다.

     

     

    8. Favorites 화면 추가

    현재 Favorites 화면은 Placeholder로 대체된 상태입니다. Favorites 화면을 만들어주도록 하겠습니다.

    이전에 정의한 favorites 상태값의 경우 MyAppState에 정의되어 있으므로 해당 상태값에 접근합니다.

    이 후, 접근한 favorites 상태값을 통해 UI 를 생성해줍니다.

    class Favorites extends StatelessWidget {
      const Favorites({super.key});
    
      @override
      Widget build(BuildContext context) {
        var appState = context.watch<MyAppState>();
        var favorites = appState.favorites;
    
        return ListView(
          children: favorites.map((favorite)=>Text(favorite.toString())).toList()
        );
      }
    }
    • context.watch() 메서드를 통해 MyAppState의 상태값에 접근해줍니다.
    • 스크롤이 되는 Column을 사용하고 싶다면 ListView 위젯을 사용해줍니다.
    • Dart언어에서 컬렉션 리터럴 내부에서 for 루프 문을 통해 위젯 목록을 만들 수 있습니다. 또한, 함수형 프로그래밍을 사용해 위젯목록을 만들 수 있습니다.
    // for 문
    children: [
        for(var str in strList)
            Text(str),
    ]
    
    // 함수형 프로그래밍
    children: favorites.map((favorite)=>Text(favorite.toString())).toList()

    현재 Favorites 리스트를 Text로 출력했기 때문에 아래와 같은 모습을 보여줍니다.

    추가 1)

    이를 좀 더 꾸며주기 위해 ListTile 위젯을 사용해보도록 하겠습니다.

    if(favorites.isEmpty){
      return Center(
        child: Text('No Favorites yet.'),
      );
    }
    
    return ListView(
        children: [
          Padding(
            padding: EdgeInsets.all(20),
            child: Text('you have ${favorites.length} favorites')
          ),
          ...favorites.map((favorite)=>ListTile(
            leading: Icon(Icons.favorite),
            title: Text(favorite.asLowerCase)
          )).toList()
        ]
      );
    • favorites 가 비어있을 경우 No Favorites yet을 출력해주도록 했습니다.
    • ListTile은 title(텍스트 표시), leading(아이콘 혹은 아바타), onTap(상호작용)과 같은 속성이 있습니다.

     

    추가 2)

    onTap을 통해 원하는 단어를 Favorites 리스트에서 제거해보도록 하겠습니다.

    void toggleFavorite(var word){
      if(favorites.contains(word)){
        favorites.remove(word);
      } else {
        favorites.add(word);
      }
      notifyListeners();
    }
    
    ...
    
    ...favorites.map((favorite)=>ListTile(
      leading: Icon(Icons.favorite),
      title: Text(favorite.asLowerCase),
      onTap: (){
        appState.toggleFavorite(favorite);
      },
    )).toList()

    toggleFavorite을 인자를 받을 수 있도록 수정하고 이 인자를 통해 토글하도록 해줍니다.

    onTap에서 toggleFavorite을 통해 원하는 단어를 favorite 리스트에서 제거해줍니다.

     

     

     

     

    마무리

    오늘까지 codelabs 의 플러터앱 만들기를 구현해보았습니다. stateless, stateful 위젯에 대해 많이 알게 되었고 전역 상태를 통한 위젯 정의도 알 수 있었습니다. 이를 통해 이제 실제 프로젝트를 구성해보도록 하겠습니다.

    반응형
    LIST

    댓글

Designed by Tistory.