[Flutter] 9. Cart App v1 : Prototype

문정준's avatar
May 30, 2025
[Flutter] 9. Cart App v1 : Prototype
 

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

  • 버튼을 클릭하면 상태가 변하는 것을 확인 가능
 
notion image
 
notion image
 

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

  • 버튼을 클릭하면 해당 버튼의 색도 변경됨
notion image
 
Share article

sxias