본문 바로가기
Flutter + Dart/Flutter + Dart 공부

UI 수정 - 즐겨찾기 기능, 아이콘, 레이블, 화면 전환

by GREEN나무 2025. 5. 7.
728x90

즐겨찾기, 아이콘 : https://codelabs.developers.google.com/codelabs/flutter-codelab-first?hl=ko#5

레이블, 화면 전환 : https://codelabs.developers.google.com/codelabs/flutter-codelab-first?hl=ko#6

속성 추가하기 - 리스트

새로고침할 때 마다 달라지는 단어 쌍을 기억하기 위해 좋아요 기능 추가 합니다.

 

./lib/main.dart

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
  // ↓ 버튼을 상태에 연결하기. getNext 메서드를 추가.
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  /////
  // ↓ favorites 속성 추가
  var favorites = <WordPair>[];

  void toggleFavorite() {
    if (favorites.contains(current)) {
      favorites.remove(current);
    } else {
      favorites.add(current);
    }
    notifyListeners();
  }
  /////
}

 

`MyAppState`에 `favorites` 속성을 새로 추가했습니다. 이 속성은 빈 리스트(`[]`)로 초기화됩니다. 제네릭을 활용해 리스트가 `<WordPair>` 타입의 단어 쌍만 포함하도록 지정했으며, 이를 통해 앱의 안정성이 향상됩니다. Dart는 `WordPair` 이외의 다른 타입을 추가하려는 시도를 컴파일 단계에서 차단합니다. 따라서 `favorites` 리스트를 사용할 때 `null`과 같은 예기치 않은 객체가 포함될 가능성이 없음을 보장합니다.

Dart에는 `[]`로 표현되는 `List` 외에도 `{}`로 표현되는 `Set`과 같은 컬렉션 타입이 있습니다. `Set`이 즐겨찾기 컬렉션에 더 적합하다고 볼 수 있지만, 이 Codelab의 단순화를 위해 `List`를 사용합니다. 원한다면 `Set`를 사용해도 되며, 코드 변경은 크지 않습니다.

또한, `toggleFavorite()` 메서드를 새로 추가했습니다. 이 메서드는 현재 단어 쌍이 즐겨찾기 리스트에 이미 있으면 제거하고, 없으면 추가합니다. 두 경우 모두 메서드는 `notifyListeners()`를 호출하여 변경 사항을 반영합니다.

 

데이터가 공유되었으므로 화면을 업데이트해 주세요! 

informListeners() 는 Dart의 ChangeNotifier 클래스에 포함된 메서드로, 상태 관리에서 사용됩니다. 이 방법은 상태를 변경하여 리스너(구독자)들에게 영양을 공급하는 역할 을 합니다.

  • MyAppState 와 같은 클래스가 ChangeNotifier 를 믿고 응답 상태를 관리할 때, 즐겨찾기 리스트와 같은 데이터가 변경되면 UI나 다른 부분에서 감지해야 합니다.
  • informListeners() 를 호출하면, 해당 클래스에 등록된 모든 리스너 (예: Flutter의 Provider소비자 )에게 상태 변경을 알리고, UI를 다시 표시해야 하는 신호를 보냅니다.
  • 예를 들어, 토글Favorite() 에서 즐겨 찾기 목록에 뉴스를 추가하거나 제거한 후 informListeners() 를 호출하면, 즐겨찾는 목록 표시를 메서드로 UI가 자동으로 새로고침 됩니다.

 

 

버튼 만들기(좋아요 + 즐겨찾기)

저장하면 Row Column과 유사하게 작동하는 것을 알 수 있습니다. 

mainAxisSize : Row Column 이 필드 공간을 조정합니다.

  • MainAxisSize.min : 필요한 만큼만 공간이 사라졌습니다(예: 버튼 크기만큼만).
  • MainAxisSize.max : 모든 공간을 사라지게 합니다(기본값).

 

mainAxisSize : 사용 가능한 모든 가로 공간을 차지하지 말라고 Row에 지시

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Center(
        child: Column(
           mainAxisAlignment: MainAxisAlignment.center,  // ← 세로 가운데 정렬
           children: [         
            BigCard(pair: pair), // ← 단어쌍 출력부분 수정
            SizedBox(height: 10),  // ← 두 위젯 사이에 공간 띄우기기    
            Row( // ← 리펙토링의 Wrap with Row로 추가됨
             mainAxisSize: MainAxisSize.min,   // ← 최소한의 공간만 사용
              children: [
                ElevatedButton(
                  onPressed: () {
                    appState.getNext(); //print('button pressed!');
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

 

아이콘 버튼 만들기

ElevatedButton.icon() 생성자를 사용하여 아이콘이 있는 버튼을 만듭니다. 

build 메서드 상단에서 현재 단어 쌍이 이미 즐겨찾기에 있는지에 따라 적절한 아이콘을 선택하세요.

SizedBox를 다시 사용하여 두 버튼을 약간 떨어뜨립니다.

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // ← 추가한 코드

    /// ↓ 아이콘 추가
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }
    ///


    return Scaffold(
      body: Center(
        child: Column(
           mainAxisAlignment: MainAxisAlignment.center,  // ← 세로 가운데 정렬
           children: [         
            BigCard(pair: pair), // ← 단어쌍 출력부분 수정
            SizedBox(height: 10),  // ← 두 위젯 사이에 공간 띄우기기    
            Row( // ← 리펙토링의 Wrap with Row로 추가됨
             mainAxisSize: MainAxisSize.min,   // ← 최소한의 공간만 사용용
              children: [
                /// ↓ 아이콘 버튼
                ElevatedButton.icon(
                  onPressed: () {
                    appState.toggleFavorite();
                  },
                  icon: Icon(icon),
                  label: Text('Like'),
                ),

                SizedBox(width: 10), // 두 버튼 사이의 공간
                ///

                ElevatedButton(
                  onPressed: () {
                    appState.getNext(); //print('button pressed!');
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

 

탐색 레일 추가하기

MyHomePage를 별도의 위젯 2개로 분할합니다.

 

기존 코드

더보기
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // ← 추가한 코드

    /// ↓ 아이콘 추가
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }
    ///


    return Scaffold(
      body: Center(
        child: Column(
           mainAxisAlignment: MainAxisAlignment.center,  // ← 세로 가운데 정렬
           children: [         
            BigCard(pair: pair), // ← 단어쌍 출력부분 수정
            SizedBox(height: 10),  // ← 두 위젯 사이에 공간 띄우기기    
            Row( // ← 리펙토링의 Wrap with Row로 추가됨
             mainAxisSize: MainAxisSize.min,   // ← 최소한의 공간만 사용용
              children: [
                /// ↓ 아이콘 버튼
                ElevatedButton.icon(
                  onPressed: () {
                    appState.toggleFavorite();
                  },
                  icon: Icon(icon),
                  label: Text('Like'),
                ),

                SizedBox(width: 10), // 두 버튼 사이의 공간
                ///

                ElevatedButton(
                  onPressed: () {
                    appState.getNext(); //print('button pressed!');
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

위젯 분할 코드

// MyHomePage 클래스는 앱의 메인 화면을 정의하는 StatelessWidget
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Scaffold: 앱의 기본 구조를 제공하는 위젯 (앱바, 바디, 네비게이션 등)
    return Scaffold(
      body: Row(
        children: [
          // SafeArea: 화면의 노치나 상태바 영역을 피해서 콘텐츠를 표시
          SafeArea(
            child: NavigationRail(
              extended: false, // 네비게이션 레일이 확장되지 않은 컴팩트 모드
              // destinations: 네비게이션 항목 리스트
              destinations: [
                // NavigationRailDestination: 네비게이션 항목 정의
                NavigationRailDestination(
                  icon: Icon(Icons.home), // 홈 아이콘
                  label: Text('Home'), // 홈 라벨
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite), // 즐겨찾기 아이콘
                  label: Text('Favorites'), // 즐겨찾기 라벨
                ),
              ],
              selectedIndex: 0, // 현재 선택된 네비게이션 항목 인덱스 (0 = Home)
              // onDestinationSelected: 네비게이션 항목 선택 시 호출되는 콜백
              onDestinationSelected: (value) {
                print('selected: $value'); // 선택된 인덱스 출력
              },
            ),
          ),
          // Expanded: 남은 공간을 채우는 위젯
          Expanded(
            child: Container(
              // 컨테이너의 배경색을 테마의 primaryContainer 색상으로 설정
              color: Theme.of(context).colorScheme.primaryContainer,
              // GeneratorPage: 메인 콘텐츠를 표시하는 위젯
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

// GeneratorPage 클래스는 메인 콘텐츠를 표시하는 StatelessWidget
class GeneratorPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // context.watch: MyAppState의 상태를 감시하여 변경 시 UI 갱신
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // 현재 표시할 데이터 (예: 단어 쌍)

    // 아이콘 설정: 즐겨찾기 여부에 따라 아이콘 변경
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite; // 즐겨찾기에 포함된 경우 채워진 하트
    } else {
      icon = Icons.favorite_border; // 포함되지 않은 경우 빈 하트
    }

    // Center: 자식 위젯을 화면 중앙에 배치
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center, // 세로축 중앙 정렬
        children: [
          // BigCard: pair 데이터를 표시하는 사용자 정의 위젯
          BigCard(pair: pair),
          SizedBox(height: 10), // 위젯 간 10px 간격
          // Row: 버튼들을 가로로 배치
          Row(
            mainAxisSize: MainAxisSize.min, // Row 크기를 자식 크기에 맞춤
            children: [
              // ElevatedButton.icon: 아이콘과 텍스트가 포함된 버튼
              ElevatedButton.icon(
                onPressed: () {
                  appState.toggleFavorite(); // 즐겨찾기 토글 함수 호출
                },
                icon: Icon(icon), // 동적 아이콘 표시
                label: Text('Like'), // 버튼 텍스트
              ),
              SizedBox(width: 10), // 버튼 간 10px 간격
              // ElevatedButton: 다음 항목으로 이동하는 버튼
              ElevatedButton(
                onPressed: () {
                  appState.getNext(); // 다음 데이터로 이동
                },
                child: Text('Next'), // 버튼 텍스트
              ),
            ],
          ),
        ],
      ),
    );
  }
}

 

 

노치와 SafeArea의 관계

노치(Notch)란 스마트폰이나 태블릿 상단에 카메라, 센서, 스피커 등을 배치하기 위해 일부가 잘려나간 부분을 표시하는 것을 말합니다.

Flutter에서 SafeArea는 앱의 콘텐츠가 노치, 상태바(예: 배터리, 시간 표시 영역), 하단 홈 버튼 바(제스처 내비게이션 바) 같은 장치의 시스템 UI 요소와 결합지 않도록 보호 범위를 설정합니다. SafeArea 를 사용하면 콘텐츠가 해당 구역을 제한 구역의 안전 부분에 배치되어 독성과 사용성을 높일 수 있습니다.

예를 들어, 노치가 있는 아이폰에서 SafeArea 를 사용하지 않고 검색하는 바나나 텍스트가 노치 범위에 잘릴 수 있습니다. SafeArea 는 이를 방지하기 위해 장비를 보호하는 경계심을 자동으로 조정합니다.

 

  1. 구조 변경
    • MyHomePage의 콘텐츠가 GeneratorPage 위젯으로 분리됨.
    • MyHomePage는 Scaffold를 유지한 채, 두 개의 하위 요소를 가진 Row로 구성됨.
  2. MyHomePage 내비게이션 메뉴 (페이지 이동 버튼)
    • 역할 : 앱의 메인 화면을 구성하며, 아케이드와 메인 컨텐츠를 포함합니다.
    • Scaffold : 기본 앱 구축을 제공합니다. (앱바, 바디, 네비게이션 등)
    • NavigationRail : 왼쪽에 좌로 배치된 내비게이션 메뉴입니다. 'Home'과 'Favorites' 두 아이콘 버튼이 있습니다.
    • 확장된 컨테이너 : 메인 컨텐츠 영역을 확장하여 화면의 보호 공간을 표시하고, 배경색을 설정한 뒤 GeneratorPage 를 표시합니다.
    • selectedIndex: 0 (현재 하드코딩)
      • 0: Home, 1: Favorites
  3. GeneratorPage 문자쌍과 즐겨찾기 버튼(기존 화면)
    • 역할 : 메인 컨텐츠를 표시하며 상태 관리( MyAppState )를 통해 데이터를 동적으로 전송합니다.
    • SafeArea : NavigationRail을 감싸 상태 표시줄/노치로 인한 가려짐 방지.
    • Expanded : 메인 콘텐츠 영역으로, 남은 공간을 모두 차지.
    • 상태 표시 : context.watch<MyAppState>() 를 통해 앱 상태를 계속해서 구성합니다.
    • 아이콘 : 현재 데이터( pair )가 즐겨 찾는 곳에 포함된 경우 하트, 아니면 빈 하트 표시.
    • UI 구성 :
      • BigCard : 데이터를 표시하는 맞춤형 설명입니다.
      • ElevatedButton.icon : 즐겨찾는 추가찾기/제거를 토글.
      • ElevatedButton : 다음 데이터로 이동.
    •  

화면 전환

스테이트리스(Stateless) 위젯과 스테이트풀(Stateful) 위젯

지금까지 작성한 모든 위젯은 스테이트리스(Stateless)로 변경 가능한 자체 상태를 포함하지 않습니다.

위젯은 스스로 변경할 수 없으며 MyAppState를 거쳐야 합니다.

 

스테이트풀(Stateful) 위젯으로 변경할 것 입니다.

탐색 레일의 selectedIndex 값을 보관할 방법이 필요합니다. 또한 onDestinationSelected 콜백 내에서 이 값을 변경하려고 합니다.

 

MyHomePage의 첫 번째 줄(class MyHomePage...로 시작하는 줄)에 커서를 두고 Ctrl+. 또는 Cmd+.를 사용하여 Refactor 메뉴를 불러옵니다. Convert to StatefulWidget을 선택합니다.

 

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
  ...

MyHomePage와 State<MyHomePage>를 상속받 는 _MyHomePageState로 분리되었습니다.
_MyHomePageState는 언더스코어(_)로 시작하므로 이 클래스는 라이브러리(파일) 내부에서만 사용되는 private 클래스입니다. 언더스코어(_)해당 클래스를 비공개로 만들며 컴파일러에 의해 시행됩니다.
즉 _MyHomePageState  클래스는 MyHomePage 위젯과 연결된 상태 관리 객체입니다. 이 클래스는 State를 확장하므로 자체 값을 관리할 수 있습니다. 자체적으로 변경할 수 있습니다. 
Flutter에서 StatefulWidget을 만들면 상태를 따로 클래스로 분리해서 이렇게 작성합니다.

 

setState

새 스테이트풀(Stateful) 위젯은 하나의 변수 selectedIndex만 추적하면 됩니다. 

class _MyHomePageState extends State<MyHomePage> {

  var selectedIndex = 0;     // ← 선택된 인덱스 값

  @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: selectedIndex,    // ← 선택된 값을 0에서 변수로 수정.
              onDestinationSelected: (value) {

                // ↓ 출력문을 함수로 수정
                setState(() {
                  selectedIndex = value;
                });

              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}
  1. 새 변수 selectedIndex를 도입하고 0으로 초기화했습니다.
  2. 지금까지 있던 하드 코딩 0 대신 NavigationRail 정의에서 이 새 변수를 사용했습니다.
  3. onDestinationSelected 콜백이 호출되면 새 값을 콘솔로 인쇄하는 대신 setState() 호출 내 selectedIndex에 할당합니다. 이 호출은 이전에 사용한 notifyListeners() 메서드와 유사합니다. UI가 업데이트되는지 확인합니다.

selectedIndex 사용

_MyHomePageState build 메서드 상단, return Scaffold 바로 앞에 아래 코드 추가.

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;  // 선택된 네비게이션 항목 인덱스 초기값(0 = Home) 
  @override
  Widget build(BuildContext context) {
    /// 추가
    Widget page; // Widget 유형의 새 변수 page를 선언.
    // selectedIndex의 현재 값에 따라 switch 문이 화면을 page에 할당합니다.
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        // 아직 FavoritesPage가 없으므로 Placeholder를 사용합니다.
        page = Placeholder();
        break;
      default:
        // fail-fast 원칙
        throw UnimplementedError('no widget for $selectedIndex');
    }
    ///
    
    return Scaffold(
    ...

Placeholder : 배치하는 곳마다 교차 사각형을 그려 UI의 해당 부분이 미완성임을 표시하는 편리한 위젯.

 

fail-fast 원칙

향후 있을 버그를 방지할 수 있습니다. 탐색 레일에 새 대상을 추가하고 이 코드를 업데이트하지 않은 경우 프로그램이 개발 중에 다운됩니다.

 

UnimplementedError : 아직 구현되지 않은 기능이나 메서드를 호출했을 때 발생하는 에러입니다. 주로 개발 중에 "나중에 여기에 뭔가 구현할 거야"라고 표시할 때 사용합니다.

 

_MyHomePageState의 Expanded안의 child: GeneratorPage(),를 child: page,로 수정합니다.

          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: page,  // ← 코드 수정
              // child: GeneratorPage(),
            ),
          ),
더보기
class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;  // 선택된 네비게이션 항목 인덱스 초기값(0 = Home) 
  @override
  Widget build(BuildContext context) {
    /// 추가가
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }
    ///

    // Scaffold: 앱의 기본 구조를 제공하는 위젯 (앱바, 바디, 네비게이션 등)
    return Scaffold(
      body: Row(
        children: [
          // SafeArea: 화면의 노치나 상태바 영역을 피해서 콘텐츠를 표시
          SafeArea(
            child: NavigationRail(
              extended: false, // 네비게이션 레일이 확장되지 않은 컴팩트 모드
              // destinations: 네비게이션 항목 리스트
              destinations: [
                // NavigationRailDestination: 네비게이션 항목 정의
                NavigationRailDestination(
                  icon: Icon(Icons.home), // 홈 아이콘
                  label: Text('Home'), // 홈 라벨
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite), // 즐겨찾기 아이콘
                  label: Text('Favorites'), // 즐겨찾기 라벨
                ),
              ],
              selectedIndex: selectedIndex,    // ← Change to this.
              onDestinationSelected: (value) {              // onDestinationSelected: 네비게이션 항목 선택 시 호출되는 콜백
                setState(() {
                  selectedIndex = value;
                });              
              },
            ),
          ),
          // Expanded: 남은 공간을 채우는 위젯
          Expanded(
            child: Container(
              // 컨테이너의 배경색을 테마의 primaryContainer 색상으로 설정
              color: Theme.of(context).colorScheme.primaryContainer,
              // GeneratorPage: 메인 콘텐츠를 표시하는 위젯
              child: page,  // ← 코드 수정
              // child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

 

반응형 레일 만들기

Flutter는 앱이 자동으로 반응하도록 할 수 있는 위젯을 제공합니다.

위젯 이름 주요 용도
MediaQuery 화면 크기, 해상도, 텍스트 배율 등 디바이스 정보 조회
LayoutBuilder 부모 위젯 제약(constraints)에 따라 자식 레이아웃을 동적으로 빌드
OrientationBuilder 화면 방향(가로/세로)에 따라 레이아웃 분기 처리
Expanded Row/Column 내 남은 공간을 자동으로 채워 반응형 레이아웃 구성
Flexible Expanded와 유사하나, flex 비율 조정으로 여유 공간 분배
FittedBox 자식 위젯 크기를 부모 영역에 맞춰 축소·확대
FractionallySizedBox 부모 크기의 비율(fraction)로 자식 크기를 설정
AspectRatio 가로·세로 비율 고정 레이아웃 유지
Wrap 화면 너비에 맞춰 자동 줄 바꿈되는 플렉스 레이아웃
Spacer Row/Column 사이에 유연한 빈 공간 삽입

각 위젯 활용 시점:

  1. 디바이스 정보가 필요할 때 → MediaQuery
  2. 부모 제약에 따라 레이아웃 분기 필요할 때 → LayoutBuilder, OrientationBuilder
  3. 남은 공간을 채우거나 비율 조정할 때 → Expanded, Flexible, Spacer, FractionallySizedBox
  4. 자식 크기 축소·확대 필요할 때 → FittedBox, AspectRatio
  5. 자동 줄 바꿈 플렉스 레이아웃 → Wrap

탐색 레일을 반응형(라벨 펼치기)으로 만듭니다. 즉, 공간이 충분하면 자동으로 라벨을 표시하도록 만듭니다(extended: true 사용).

NavigationRail은 공간이 충분히 있을 때 자동으로 라벨을 표시하지 않습니다. 모든 컨텍스트에서 충분한 공간이 무엇인지 알 수 없기 때문입니다. 이 결정은 개발자에게 달려 있습니다.

 

MyHomePage의 너비가 600픽셀 이상일 때만 라벨을 표시한다면  LayoutBuilder 위젯을 사용하여 사용할 수 있는 공간의 양에 따라 위젯 트리를 변경할 수 있습니다.

Flutter는 논리 픽셀을 길이 단위로 사용합니다. 기기 독립형 픽셀이라고도 합니다. 

 

  1. _MyHomePageState의 build 메서드에서 Scaffold에 커서를 두고 Refactor 메뉴를 불러와 Wrap with Builder를 선택합니다.
  2. 새로 추가한 Builder를 LayoutBuilder로 이름을 수정합니다.

콜백 매개변수 목록을 (context)에서 (context, constraints)로 수정합니다.

LayoutBuilder의 builder 콜백은 제약 조건이 변경될 때마다 호출됩니다. 

  • 사용자가 앱의 창 크기를 조절할 때.
  • 사용자가 휴대전화를 세로 모드에서 가로 모드로 또는 그 반대로 회전 할 때 .
  • MyHomePage 옆에 있는 일부 위젯의 크기가 커져 MyHomePage의 제약 조건이 작아질 때.
  • 기타 등등

이제 코드는 현재 constraints를 쿼리하여 라벨을 표시할지 결정할 수 있습니다. 

_MyHomePageState의 build 메서드에서 'extended: false,'을 'extended: constraints.maxWidth >= 600,'로 수정합니다.

return LayoutBuilder(
      builder: (context, constraints) { 
        return Scaffold(
          body: Row(
            children: [
              SafeArea(
                child: NavigationRail(
                  extended: constraints.maxWidth >= 600,  // ← 600px 이상일 때 확장(true)

 

 

이제 앱이 화면 크기, 방향, 플랫폼과 같은 환경에 반응합니다. 즉, 앱이 반응형이 되었습니다.

 

Favorites 화면 만들기

새 스테이트리스(Stateless) 위젯 FavoritesPage에 favorites 목록을 표시하고 Placeholder 대신 해당 위젯을 표시하면 됩니다.

다음은 몇 가지 도움말입니다.

  • 스크롤되는 Column을 원한다면 ListView 위젯을 사용합니다.
  • context.watch<MyAppState>()를 사용하여 위젯에서 MyAppState 인스턴스에 액세스합니다.
  • 새 위젯을 시도해 보려는 경우 ListTile에는 title(일반적으로 텍스트용), leading(아이콘 또는 아바타용), onTap(상호작용용)과 같은 속성이 있습니다. 하지만 이미 아는 위젯을 사용하여 비슷한 효과를 달성할 수 있습니다.
  • Dart를 사용하면 컬렉션 리터럴 내에서 for 루프를 사용할 수 있습니다. 예를 들어 messages에 문자열 목록이 포함되어 있으면 다음과 같은 코드를 사용할 수 있습니다.

반면 함수 프로그래밍에 더 익숙한 경우 Dart를 사용해 messages.map((m) => Text(m)).toList()와 같은 코드를 작성할 수도 있습니다. 

 

MyAppState에 get을 사용해 favorites 리스트 변수에 외부에서 접근할 수 있게 만듧니다.

 

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  /// ↓ favorites 속성 수정
  final List<WordPair> _favorites = [];
  // var favorites = <WordPair>[];

  void toggleFavorite() {
    if (_favorites.contains(current)) {
      _favorites.remove(current);
    } else {
      _favorites.add(current); 
    }
    notifyListeners();
  }
  // 즐겨찾기 단어 삭제함수 추가
    void removeFavorite(WordPair pair) {
    _favorites.remove(pair);
    notifyListeners();
  }
   // 외부 접근용 getter 추가
  List<WordPair> get favorites => _favorites;
}

 

코드 하단에 즐겨찾기 화면 위젯을 만듧니다.

class FavoritesList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final favorites = context.watch<MyAppState>().favorites;

    if (favorites.isEmpty) {
      return Center(child: Text('즐겨찾기한 단어가 없습니다.'));
    }

    return ListView.builder(
      itemCount: favorites.length,
      itemBuilder: (context, index) {
        final pair = favorites[index];
        return ListTile(
          title: Text(pair.asPascalCase),
          trailing: IconButton(
            icon: Icon(Icons.favorite, color: Colors.red),
            onPressed: () {
              // 속이 찬 하트 클릭 시 좋아요 해제
              context.read<MyAppState>().removeFavorite(pair);
            },
          ),
        );
      },
    );
  }
}

 

 

_MyHomePageState(레이블)의 스위치 문의 case 1; 아래 page: 를 FavoritesList() 로 수정합니다.

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;  // 선택된 네비게이션 항목 인덱스 초기값(0 = Home) 
  @override
  Widget build(BuildContext context) {
    /// 추가
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = FavoritesList();// Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }
    ...

 

기존에 있던  Placeholder();는 Flutter 프레임워크에서 사용하는 임시로 자리를 표시 UI 상자입니다. 주로 개발된 실제 콘텐츠가 아직 준비되지 않은 부분을 표시하여 표시할 때 사용됩니다.

 

 

https://youtu.be/ybR139315wE

 

전체 코드 ./lib/main.dart

더보기
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(
              // 앱 기본색상
              seedColor: const Color.fromARGB(160, 119, 214, 238)),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
  // ↓ 버튼을 상태에 연결하기. getNext 메서드를 추가.
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  /// ↓ favorites 속성 추가
  final List<WordPair> _favorites = [];
  // var favorites = <WordPair>[];

  void toggleFavorite() {
    if (_favorites.contains(current)) {
      _favorites.remove(current);
    } else {
      _favorites.add(current); // 즐겨찾기 단어 추가
    }
    notifyListeners();
  }
  ///
    void removeFavorite(WordPair pair) {
    _favorites.remove(pair);
    notifyListeners();
  }
    // ✅ 외부 접근용 getter 추가
  List<WordPair> get favorites => _favorites;
}

/// 
// MyHomePage 클래스는 앱의 메인 화면을 정의하는 StatelessWidget
class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;  // 선택된 네비게이션 항목 인덱스 초기값(0 = Home) 
  @override
  Widget build(BuildContext context) {
    /// 추가
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = FavoritesList(); // Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }
    ///

    // Scaffold: 앱의 기본 구조를 제공하는 위젯 (앱바, 바디, 네비게이션 등)
    return LayoutBuilder( // 여기 수정
      builder: (context, constraints) { // 여기 수정
        return Scaffold(
          body: Row(
            children: [
              // SafeArea: 화면의 노치나 상태바 영역을 피해서 콘텐츠를 표시
              SafeArea(
                child: NavigationRail(
                  // extended: false, // 네비게이션 레일이 확장되지 않은 컴팩트 모드
                  extended: constraints.maxWidth >= 600,  // ← 600px 이상일 때 확장(true)
                  // destinations: 네비게이션 항목 리스트
                  destinations: [
                    // NavigationRailDestination: 네비게이션 항목 정의
                    NavigationRailDestination(
                      icon: Icon(Icons.home), // 홈 아이콘
                      label: Text('Home'), // 홈 라벨
                    ),
                    NavigationRailDestination(
                      icon: Icon(Icons.favorite), // 즐겨찾기 아이콘
                      label: Text('Favorites'), // 즐겨찾기 라벨
                    ),
                  ],
                  selectedIndex: selectedIndex,    // ← Change to this.
                  onDestinationSelected: (value) {              // onDestinationSelected: 네비게이션 항목 선택 시 호출되는 콜백
                    setState(() {
                      selectedIndex = value;
                    });              
                  },
                ),
              ),
              // Expanded: 남은 공간을 채우는 위젯
              Expanded(
                child: Container(
                  // 컨테이너의 배경색을 테마의 primaryContainer 색상으로 설정
                  color: Theme.of(context).colorScheme.primaryContainer,
                  // GeneratorPage: 메인 콘텐츠를 표시하는 위젯
                  child: page,  // ← 코드 수정
                  // child: GeneratorPage(),
                ),
              ),
            ],
          ),
        );
      }
    );
  }
}

// GeneratorPage 클래스는 메인 콘텐츠를 표시하는 StatelessWidget
class GeneratorPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // context.watch: MyAppState의 상태를 감시하여 변경 시 UI 갱신
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // 현재 표시할 데이터 (예: 단어 쌍)

    // 아이콘 설정: 즐겨찾기 여부에 따라 아이콘 변경
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite; // 즐겨찾기에 포함된 경우 채워진 하트
    } else {
      icon = Icons.favorite_border; // 포함되지 않은 경우 빈 하트
    }

    // Center: 자식 위젯을 화면 중앙에 배치
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center, // 세로축 중앙 정렬
        children: [
          // BigCard: pair 데이터를 표시하는 사용자 정의 위젯
          BigCard(pair: pair),
          SizedBox(height: 10), // 위젯 간 10px 간격
          // Row: 버튼들을 가로로 배치
          Row(
            mainAxisSize: MainAxisSize.min, // Row 크기를 자식 크기에 맞춤
            children: [
              // ElevatedButton.icon: 아이콘과 텍스트가 포함된 버튼
              ElevatedButton.icon(
                onPressed: () {
                  appState.toggleFavorite(); // 즐겨찾기 토글 함수 호출
                },
                icon: Icon(icon), // 동적 아이콘 표시
                label: Text('Like'), // 버튼 텍스트
              ),
              SizedBox(width: 10), // 버튼 간 10px 간격
              // ElevatedButton: 다음 항목으로 이동하는 버튼
              ElevatedButton(
                onPressed: () {
                  appState.getNext(); // 다음 데이터로 이동
                },
                child: Text('Next'), // 버튼 텍스트
              ),
            ],
          ),
        ],
      ),
    );
  }
}
///
class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    // ↓ 코드 추가
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
      fontWeight: FontWeight.bold,
      letterSpacing: 2,
    );

    return Card(
      color: theme.colorScheme.primary,
      elevation: 8, // ← 그림자 깊이
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16), // ← 둥근 모서리
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        //child: Text(pair.asLowerCase, style: style),
        // ↓ 코드 수정
        child: Text(
          pair.asLowerCase,
          style: style,
          semanticsLabel: "${pair.first} ${pair.second}",
        ),
      ),
    );
  }
}


class FavoritesList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final favorites = context.watch<MyAppState>().favorites;

    if (favorites.isEmpty) {
      return Center(child: Text('즐겨찾기한 단어가 없습니다.'));
    }

    return ListView.builder(
      itemCount: favorites.length,
      itemBuilder: (context, index) {
        final pair = favorites[index];
        return ListTile(
          title: Text(pair.asPascalCase),
          trailing: IconButton(
            icon: Icon(Icons.favorite, color: Colors.red),
            onPressed: () {
              context.read<MyAppState>().removeFavorite(pair);
            },
          ),
        );
      },
    );
  }
}

 

+

즐겨찾기 화면에 Text('즐겨찾기: ${favorites.length}개')를 추가

class FavoritesList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final favorites = context.watch<MyAppState>().favorites;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      
      children: [
        Padding(
          padding: const EdgeInsets.all(10.0),
          child:SafeArea(
            child: Text(
            '즐겨찾기: ${favorites.length}개',
            style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
          ),),
        ),
        //SizedBox(height: 5), // 20px 간격
        Expanded(
          child: favorites.isEmpty
              ? Center(child: Text('즐겨찾기한 단어가 없습니다.'))
              : ListView.builder(
                  itemCount: favorites.length,
                  itemBuilder: (context, index) {
                    final pair = favorites[index];
                    return ListTile(
                      title: Text(pair.asPascalCase),
                      trailing: IconButton(
                        icon: Icon(Icons.favorite, color: Colors.red),
                        onPressed: () {
                          context.read<MyAppState>().removeFavorite(pair);
                        },
                      ),
                    );
                  },
                ),
        ),
      ],
    );
  }
}

 

app-release.zip
18.40MB

'Flutter + Dart > Flutter + Dart 공부' 카테고리의 다른 글

flutter 빌드하기  (0) 2025.05.07
Widget의 종류  (1) 2025.05.07
앱 UI 수정 - 위젯 추출, 컬럼 중앙 배열  (0) 2025.05.06
프로젝트 만들기  (1) 2025.05.06
Flutter 위젯 기본 이해  (1) 2025.05.05