즐겨찾기, 아이콘 : 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 는 이를 방지하기 위해 장비를 보호하는 경계심을 자동으로 조정합니다.
- 구조 변경
- MyHomePage의 콘텐츠가 GeneratorPage 위젯으로 분리됨.
- MyHomePage는 Scaffold를 유지한 채, 두 개의 하위 요소를 가진 Row로 구성됨.
- MyHomePage 내비게이션 메뉴 (페이지 이동 버튼)
- 역할 : 앱의 메인 화면을 구성하며, 아케이드와 메인 컨텐츠를 포함합니다.
- Scaffold : 기본 앱 구축을 제공합니다. (앱바, 바디, 네비게이션 등)
- NavigationRail : 왼쪽에 좌로 배치된 내비게이션 메뉴입니다. 'Home'과 'Favorites' 두 아이콘 버튼이 있습니다.
- 확장된 컨테이너 : 메인 컨텐츠 영역을 확장하여 화면의 보호 공간을 표시하고, 배경색을 설정한 뒤 GeneratorPage 를 표시합니다.
- selectedIndex: 0 (현재 하드코딩)
- 0: Home, 1: Favorites
- 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(),
),
),
],
),
);
}
}
- 새 변수 selectedIndex를 도입하고 0으로 초기화했습니다.
- 지금까지 있던 하드 코딩 0 대신 NavigationRail 정의에서 이 새 변수를 사용했습니다.
- 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의 해당 부분이 미완성임을 표시하는 편리한 위젯.
향후 있을 버그를 방지할 수 있습니다. 탐색 레일에 새 대상을 추가하고 이 코드를 업데이트하지 않은 경우 프로그램이 개발 중에 다운됩니다.
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 사이에 유연한 빈 공간 삽입 |
각 위젯 활용 시점:
- 디바이스 정보가 필요할 때 → MediaQuery
- 부모 제약에 따라 레이아웃 분기 필요할 때 → LayoutBuilder, OrientationBuilder
- 남은 공간을 채우거나 비율 조정할 때 → Expanded, Flexible, Spacer, FractionallySizedBox
- 자식 크기 축소·확대 필요할 때 → FittedBox, AspectRatio
- 자동 줄 바꿈 플렉스 레이아웃 → Wrap
탐색 레일을 반응형(라벨 펼치기)으로 만듭니다. 즉, 공간이 충분하면 자동으로 라벨을 표시하도록 만듭니다(extended: true 사용).
NavigationRail은 공간이 충분히 있을 때 자동으로 라벨을 표시하지 않습니다. 모든 컨텍스트에서 충분한 공간이 무엇인지 알 수 없기 때문입니다. 이 결정은 개발자에게 달려 있습니다.
MyHomePage의 너비가 600픽셀 이상일 때만 라벨을 표시한다면 LayoutBuilder 위젯을 사용하여 사용할 수 있는 공간의 양에 따라 위젯 트리를 변경할 수 있습니다.
※ Flutter는 논리 픽셀을 길이 단위로 사용합니다. 기기 독립형 픽셀이라고도 합니다.
- _MyHomePageState의 build 메서드에서 Scaffold에 커서를 두고 Refactor 메뉴를 불러와 Wrap with Builder를 선택합니다.
- 새로 추가한 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 상자입니다. 주로 개발된 실제 콘텐츠가 아직 준비되지 않은 부분을 표시하여 표시할 때 사용됩니다.
전체 코드 ./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);
},
),
);
},
),
),
],
);
}
}
'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 |