1. Project 생성
- 기존 방식과 동일
2. 코드 작성 - Stateful
- home_page.dart
- 장바구니 추가를 위한 AppBar의 action 추가
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
// 다른 화면에 AppBar만 만들어도 뒤로가기 버튼이 자동 생성
appBar: _appbar(),
body: Column(
children: [
Header(),
Expanded(
child: Detail(),
),
],
),
);
}
AppBar _appbar() {
return AppBar(
leading: IconButton(onPressed: () {}, icon: Icon(Icons.arrow_back)),
actions: [IconButton(onPressed: () {}, icon: Icon(Icons.shopping_cart))],
);
}
}
- header.dart
- StatefulWidget → setState를 통한 rebuild
- onClick을 버튼에 전달하여 rebuild 수행
class Header extends StatefulWidget {
@override
State<Header> createState() => _HeaderState();
}
class _HeaderState extends State<Header> {
List<String> images = ["p1.jpeg", "p2.jpeg", "p3.jpeg", "p4.jpeg"];
int selectedIndex = 0;
void onClick(int index) {
selectedIndex = index;
print("선택된 번호 : $selectedIndex");
print("현재 선택된 사진 : ${images[selectedIndex]}");
setState(() {});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
images[selectedIndex],
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SelectorButton(Icon(Icons.directions_bike), () => onClick(0)),
SelectorButton(Icon(Icons.pedal_bike), () => onClick(1)),
SelectorButton(Icon(CupertinoIcons.car_detailed), () => onClick(2)),
SelectorButton(Icon(CupertinoIcons.airplane), () => onClick(3)),
],
),
),
],
),
);
}
}
- selector_button.dart
- onClick 메서드를 받아 버튼 이벤트에 전달
Widgets
- IconButton : Icon이 달린 버튼
class SelectorButton extends StatelessWidget {
Icon icon;
var onClick;
SelectorButton(this.icon, this.onClick);
@override
Widget build(BuildContext context) {
return Container(
height: 70,
width: 70,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: Colors.grey),
child: IconButton(onPressed: onClick, icon: icon),
);
}
}
- detail.dart
- 상품의 상세 정보를 표시하는 컴포넌트
Widgets
- TextSpan : 여러 텍스트를 이어주고, 통일된 효과를 적용할 수 있는 위젯
class Detail extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(topLeft: Radius.circular(35), topRight: Radius.circular(35)),
),
child: Padding(
padding: const EdgeInsets.all(30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
"Urban Soft AL 10.0",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Spacer(),
Text("\$699", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 10),
Row(
children: [
Icon(Icons.star, color: Colors.yellow),
Icon(Icons.star, color: Colors.yellow),
Icon(Icons.star, color: Colors.yellow),
Icon(Icons.star, color: Colors.yellow),
Icon(Icons.star, color: Colors.yellow),
Spacer(),
RichText(
text: TextSpan(
children: [
TextSpan(text: "review "),
TextSpan(
text: "(26)",
style: TextStyle(color: Colors.blue),
),
],
),
),
],
),
SizedBox(height: 20),
Text("Color Options"),
SizedBox(height: 10),
Row(
children: [
ColorIcon(rGap: 10),
ColorIcon(rGap: 10, color: Colors.green),
ColorIcon(rGap: 10, color: Colors.orange),
ColorIcon(rGap: 10, color: Colors.grey),
ColorIcon(color: Colors.white),
],
),
SizedBox(height: 20),
Align(
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrangeAccent,
),
onPressed: () {},
child: Text(
"Add to Cart",
style: TextStyle(color: Colors.white),
),
),
),
),
],
),
),
);
}
}
- color_icon.dart
- 색상 아이콘을 표시하는 컴포넌트
- Container의 제약 조건을 회피할 수 있는 Stack 사용
Widgets
- Stack : 위젯을 스택 형태로 순서 있게 배치할 수 있는 위젯
- Positioned : 상위 위젯에 속하면서 위치를 정할 수 있는 위젯
class ColorIcon extends StatelessWidget {
double rGap;
Color color;
ColorIcon({this.rGap = 0, this.color = Colors.black});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(right: rGap),
child: Stack(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
// = BorderRadius.circular(width / 2 if width = height)
shape: BoxShape.circle,
border: Border.all(),
),
),
Positioned(
top: 5,
left: 5,
child: ClipOval(
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
),
),
],
),
);
}
}
Result
- 버튼을 클릭하면 상태가 변하는 것을 확인 가능


3. RiverPod 전환
- StatefulWidget의 단점 : 수식을 적용해야 할 위젯의 부모가 많을 경우, 그 분기점의 root 위젯까지 전부 Stateful로 지정해야 함
- 행위를 전달할 경우 dribbling이 어려움
- RiverPod을 통한 Storage 및 Provider를 사용할 경우 UI와 BM (VM)의 책임을 분리할 수 있음
- UI : 그림만 표현, 데이터 표시
- VM : 데이터 송/수신, 처리
- main_vm.dart
- RiverPod 사용을 위한 상태를 저장하는 Business Model
- Dart는 상태가 클래스 형태일 경우 불변 상태여야 함
- Cart는 클래스이므로 내부 상태를 변경할 수 있음
- 내부 상태가 변경되어 저장될 경우, 이전의 값을 찾을 수 없기 때문에 변경점을 확인할 수 없음
- 이전 상태의 값을 복사하여 새 클래스로 바꾸는 깊은 복사를 활용하여 변경
- 이 메서드를 copyWith라는 이름으로 자주 사용
class Cart {
final List<String> images;
final int selectedIndex;
Cart({required this.images, required this.selectedIndex});
String get currentImage => images[selectedIndex];
Cart copyWith({List<String>? images, int? selectedIndex}) {
return Cart(images: images ?? this.images, selectedIndex: selectedIndex ?? this.selectedIndex);
}
}
class MainVM extends Notifier<Cart> {
@override
Cart build() {
print("State 초기화");
return Cart(images: ["p1.jpeg", "p2.jpeg", "p3.jpeg", "p4.jpeg"], selectedIndex: 0);
}
void onClick(int index) {
state = state.copyWith(selectedIndex: index); // ✅ 새 상태 객체 할당 - deep copy
print("선택한 index: $index");
}
}
final cartProvider = NotifierProvider<MainVM, Cart>(() {
print("Storage 생성됨");
return MainVM();
});
- header.dart
- StatefulWidget → ConsumerWidget으로 변경
- 현재 index에 따른 사진의 경로를 받아오기 위해 model을 watch
- Button의 index를 판별하기 위해 각 버튼에 index를 따로 전달해야 함
class Header extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Cart model = ref.watch(cartProvider);
print("rebuild");
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
model.currentImage,
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SelectorButton(Icon(Icons.directions_bike), 0),
SelectorButton(Icon(Icons.pedal_bike), 1),
SelectorButton(Icon(CupertinoIcons.car_detailed), 2),
SelectorButton(Icon(CupertinoIcons.airplane), 3),
],
),
),
],
),
);
}
}
- selector_button.dart
- 현재 index를 불러오기 위한 watch, 함수를 받아오는 read 메서드를 모두 사용
- 입력받은 index와 model에 저장된 selectedIndex가 동일하면 색 변경
class SelectorButton extends ConsumerWidget {
Icon icon;
int index;
SelectorButton(this.icon, this.index);
@override
Widget build(BuildContext context, WidgetRef ref) {
Cart model = ref.watch(cartProvider);
MainVM model2 = ref.read(cartProvider.notifier);
final isSelected = model.selectedIndex == index;
final color = isSelected ? Colors.deepOrange : Colors.grey;
return Container(
height: 70,
width: 70,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: color),
child: IconButton(
onPressed: () {
model2.onClick(index);
},
icon: icon,
),
);
}
}
Result
- 버튼을 클릭하면 해당 버튼의 색도 변경됨

Share article