본문 바로가기
프로그래밍/Flutter

[flutter 상태관리] 5. stateful widget 실습, 체크박스를 가진 갤러리 만들기

by 신나요 2022. 6. 4.

지난 포스트에서 상태 관점으로 stateful widget을 알아보았습니다. 이번 포스트는 상태를 관리하는 갤러리 화면을 만들면서 플러터 공부를 해보도록 하겠습니다. 이번 포스트에서는 Stateful Widget의 상태 관리, 상위 위젯에서 하위위젯으로의 데이터 전달, 갤러리 만들기, Stack 레이아웃 등에 대해 다루게 됩니다.


갤러리 화면 개요

앞으로 코딩할 화면을 간략히 파악해보겠습니다. 우선 아래처럼 갤러리 화면에 사진들이 보여지고 있습니다. 태그 모드가 존재하는데, 태그 모드로 들어가기 위해서는 사진 중 하나를 길게 클릭하면 됩니다.

일반모드 상태

아래가 사진을 길게 클릭해 태그모드로 전환된 화면입니다.

체크가 가능한 태그모드

태그 모드에 들어가면 레이아웃은 크게 달라지지 않지만 사진 왼쪽 위에 체크박스가 생기게 됩니다. 각 사진별로 체크를 설정하거나 체크를 해지할 수 있게 됩니다. 그리고 다시 사진을 길게 클릭하면 체크박스가 없는 원래 화면으로 돌아오게 됩니다.


Stateful Widget 만들기, 갤러리 만들기 실습

앱의 아웃라인은 아래와 같은 모양을 가지고 있습니다.

1. main 함수에서는 runApp함수로 App위젯을 넣어 플러터를 기동 해주고 있습니다.

2. urls 변수는 사진을 url을 저장하는 리스트 변수 입니다.

3. PhotoState 클래스는 사진의 상태를 나타내는 위젯이 아닌 단순 클래스입니다.

4. App 클래스는 메인 주제인 stateful 위젯으로 AppState와 쌍으로 이루는 클래스입니다.

5. AppState 클래스는 State<App>을 상속받는 클래스로 stateful 위젯의 State클래스입니다.

6. Gallery는 stateless위젯으로 Scaffold를 리턴해주고 있습니다.

7. Photo클래스는 stateless위젯으로 사진을 포함하는 위젯을 리턴하고 있습니다.

각각 변수와 클래스를 살펴보도록 하겠습니다.

 

1. main 함수

플러터 앱이 부트스트랩 되는 함수로, runApp에 App위젯을 생성하며 넣어주고 있습니다.

 

2. urls

먼저 갤러리에서 각 사진의 주소를 저장하는 urls은 아래와 같이 설정하였습니다.

 

3. PhotoState

PhotoState 클래스는 위젯이 아닌 단순 클래스이며 각 사진들의 상태를 담는 클래스입니다.

url 속성에는 사진은 주소가 저장하게 됩니다. selected는 사진이 체크되었는지 안되었는지 여부를 저장하는 속성입니다.

 

 

4. App 클래스(Stateful Widget)

App클래스는 StatefulWidget을 extends 하고 있습니다. App 클래스는 프로퍼티는 존재하지는 않지만 StatefulWidget을 상속받고 있으므로 createState 메서드를 오버라이드 해줘야 합니다. createState에서는 AppState클래스의 인스턴스를 생성할 것입니다.

 

5. AppState 클래스(Stateful Widget의 State클래스)

AppState클래스는 State를 상속받고 있습니다. 갤러리는 태그 지정 모드로 전환하는 기능이 필요로 합니다. 태그 모드의 여부를 저장하는 부울 변수인 isTaggingMod를 추가하였고 기본값으로 false를 설정하였습니다. photoStates 프로퍼티는 사진의 상태 리스트를 저장합니다. photoStates는 List.of 함수를 사용하여 루프를 돌며 초기화하고 있는데 url을 새 PhotoState 객체 하나에 하나씩 매핑하고 있습니다. PhotoState는 url 이외에 selected 프로퍼티도 가지고 있는데 기본값으로 false가 설정됩니다.

PhotoStates에서 상태를 저장하고 있는데 이 상태를 변경할 함수가 필요합니다. 그리고 그 상태 변경 함수를 호출을 트리거할 무언가가 있어야 합니다. 먼저 상태를 변경하는 역할은 toggleTagging 메서드와 onPhotoSelect 메소드가 담당합니다. 하나씩 알아보겠습니다.

 

먼저 toggleTagging 메소드 입니다.

App 클래스, toggleTagging 메소드

toggleTagging 메소드는 상태를 바꾸는 메소드이기 때문에 setState를 호출하면서 건네주는 함수에서 상태 변경을 하게 됩니다. 전달 함수 에는 유저가 사진을 길게 클릭했을 때 태그 모드 혹은 일반 모드로 변환하게 하기 위해 isTaggingMod를 false 면 true, ture면 false로 토글 해주고 있습니다.

 

다음은 사진의 체크박스를 클릭했을 때 호출될 onPhotoSelect메소드 입니다.

App 클래스, onPhotoSelect 메소드

메소드에서는 선택한 사진의 url과 체크박스가 체크되었는지 부울 값 selected가 필요합니다. setState를 호출하여 함수를 전달하고 photoStates 배열을 루프를 돌아 일치하는 url로 해당 객체를 찾은 후 selected 프로퍼티를 갱신해 주고 있습니다.

 

실제로 App 위젯에서는 toggleTagging과 onPhotoSelect를 직접 호출하지 않습니다. 실제로 할 일은 하위 위젯에서 사용하기 위해서 상태와 콜백을 하위 위젯인 Gallery로 전달하는 것입니다. 이제 statefulWidget의 필수사항인 build 메서드를 알아보도록 하겠습니다.

App 클래스, build 메소드

build 메소드에서 MaterialApp을 리턴해 주고 있습니다. MaterialApp의 home 속성에는 Gallery 위젯을 설정해 주고 있습니다. Gallery 위젯을 초기화 할때 사진의 상태 리스트인 photoStates, 상태를 변경시키는 콜백 메소드 onPhotoSelect와 toggleTagging, 태그 모드의 상태 변수인 isTaggingMod를 Gallery위젯에 넣어주고 있습니다.

 

6. Gallery 클래스

Gallery는 Stateless 위젯입니다. 생성자에서 상태들과 콜백 함수를 초기화하게 됩니다.  build 메서드에서는 스캐폴드를 리턴해주고 있습니다.  그리고  body에는 GridView를 설정하고 있습니다. GridView에 들어갈 위젯은 children에서 설정해 주게 되는데, photoStates 리스트를 루프를 돌며 Photo 위젯의 리스트를 만들고 있습니다. 이 과정에서 다시 사진의 상태 정보인 PhotoState와 상태를 변경해주는 콜백 메서드, 모드 정보를 각 Photo위젯에 건네주게 됩니다.

 

7. Photo 클래스

Photo  클래스는 StatelessWidget으로 사진을 담당해주고 있습니다. 프로퍼티로는 사진의 상태인 state, 사진이 태그 모드여서 선택이 가능한 상태인지 나타내는 selectable, 길게 클릭할 때의 콜백 함수인 onLongPress, 체크 박스를 클릭할때 콜백인 onSelect를 가지고 있습니다. 이 프로퍼티들은 Photo 객체가 생성될 때 생성자에서 초기화됩니다. 즉 부모 위젯에서 건네받게 되는 값들입니다.

 

빌드 메서드를 살펴보겠습니다.

Photo 위젯의 build 메소드

1. 빌드 메소드에서 먼저 할 일은 WIdget리스트를 만드는 일입니다.   Widget 리스트를 만드는 이유는 Stack 위젯의 children으로 설정해주기 위해서인데요. 체크박스로 선택 가능한 모드인 태그 모드라면 이미지와 함께 체크박스를 렌더링 해야 되기 때문입니다.

2. GestureDetector로 children 리스트를 채우고 있습니다. GestureDetector의 child에는 Image 위젯을 network 생성자로 상위 위젯에서 건네받은 state의 url로 설정해 주고 있습니다.

3. GestureDetector의 onLongPress는 길게 터치하는 동작을 감지해  콜백 함수를 호출해 주는 기능을 가지고 있습니다. 상위 위젯에 받은 onLongPress 메서드에 위임하고 url을 전달하였습니다.

4. 태그 모드에 있을 때는 체크박스로 선택이 가능해야 되고 if문을 통해 선택이 가능한지 확인한 후 체크 박스를 위젯 리스트에 추가해 주고 있습니다.

5. CheckBox 에는 onChanged 핸들러가 있으며 체크박스의 값이 바뀔 때마다 호출됩니다. 이것이 호출되면 전달받은 값으로 onSelect 콜백을 호출하여 사진의 url과 체크박스 값을 전달합니다. CheckBox의 value속성에는 현재 값이 설정되어야 하므로 상위 위젯에서 건네받은 state.selected값을 설정하였습니다.

6. 위젯을 만들고 리턴해주고 있습니다. Stack을 가지는 Container를 반환하고 있습니다. Stack에는 사진과 체크박스가 들어있는 위젯 배열인 children을 설정해 주고 있습니다.

* Stack 레이아웃에 대해 자세히 알고 싶은 시면 아래 포스트를 참고해 주세요.

[프로그래밍/Flutter] - [Flutter Layout] 9. Stack 위젯, 겹치는 레이아웃 만들기

 

결과적으로  태그 모드가 아닐 때는 children의 위젯 리스트에는 GestureDetector로 감싸준 Image 위젯 하나만 설정되지만, 태그 모드일 때는 checkbox를 사진 위에 올려줘야 하므로 위젯 리스트에 이미지 위젯과 checkbox위젯 두 개가 설정됩니다. 그리고 그 두 위젯을 스택으로 쌓아 올려서 표한하게 됩니다.


길었던 코딩이  끝났습니다. 앱 화면에서 살펴보도록 하겠습니다.

길게 클릭하면 태그 모드로 들어가게 되고, 사진 위에 체크박스가 표시됩니다. 체크박스를 체크하고 다시 길게 클릭하면 일반 모드로 돌아갑니다. 각 상태는 최상위 위젯인 App클래스의 State클래스 AppState에서 관리되며 상태가 변경됩니다.


여기까지 수고하셨습니다. 일반적으로  Stateful widget을 사용하여 위와 같이 애플리케이션의 상태를 처리하지는 않습니다. 실제 프로젝트에서는 앞으로 다룰 상태 관리 패턴이나 혹은 Redux와 같은 패턴을 적용하게 됩니다. 상태관리 패턴은 계속 이어지는 블로그에서 다뤄보도록 하겠습니다.

Stateful Widget의 태그 기능을 가진 갤러리 앱은 아직 완성되지 않은 상태입니다. 태그 모드가 존재하지만 정작 태그를 지정할 수는 없는 상태입니다. 다음 포스트에서 태그 모드를 완성하도록 하겠습니다.


--전체코드--

import 'package:flutter/material.dart';

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

const List<String> urls = [
  "https://cdn.pixabay.com/photo/2017/06/29/13/29/pug-2454270_1280.jpg",
  "https://cdn.pixabay.com/photo/2014/09/19/21/47/chihuahua-453063_1280.jpg",
  "https://cdn.pixabay.com/photo/2015/06/15/23/56/chihuahua-810789_1280.jpg",
  "https://cdn.pixabay.com/photo/2019/04/02/16/11/cat-4098058_1280.jpg"
];

class PhotoState {
  String url;
  bool selected;

  PhotoState(this.url, {this.selected = false});
}

class App extends StatefulWidget {
  @override
  AppState createState() => AppState();
}

class AppState extends State<App> {
  bool isTaggingMod = false;
  List<PhotoState> photoStates = List.of(urls.map((url) => PhotoState(url)));

  void toggleTagging() {
    setState(() {
      isTaggingMod = !isTaggingMod;
    });
  }

  void onPhotoSelect(String url, bool selected) {
    setState(() {
      for (var element in photoStates) {
        if (element.url == url) {
          element.selected = selected;
        }
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Photo',
        theme: ThemeData(
          primarySwatch: Colors.blueGrey,
          unselectedWidgetColor: Colors.grey,
        ),
        home: Gallery(
          title: 'Gallery',
          photoStates: photoStates,
          onPhotoSelect: onPhotoSelect,
          toggleTagging: toggleTagging,
          isTaggingMod: isTaggingMod,
        ));
  }
}

class Gallery extends StatelessWidget {
  final String title;
  final List<PhotoState> photoStates;
  final bool isTaggingMod;

  final Function toggleTagging;
  final Function onPhotoSelect;

  const Gallery(
      {super.key,
      required this.title,
      required this.photoStates,
      required this.isTaggingMod,
      required this.toggleTagging,
      required this.onPhotoSelect});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text(title)),
        body: GridView.count(
            crossAxisCount: 2,
            mainAxisSpacing: 5.0,
            crossAxisSpacing: 5.0,
            padding: const EdgeInsets.all(5.0),
            children: List.of(
              photoStates.map((ps) => Photo(
                  state: ps,
                  selectable: isTaggingMod,
                  onLongPress: toggleTagging,
                  onSelect: onPhotoSelect)),
            )));
  }
}

class Photo extends StatelessWidget {
  final PhotoState state;
  final bool selectable;

  final Function onLongPress;
  final Function onSelect;

  Photo(
      {required this.state,
      required this.selectable,
      required this.onLongPress,
      required this.onSelect});

  @override
  Widget build(BuildContext context) {
    List<Widget> children = [
      GestureDetector(
        child: Image.network(state.url),
        onLongPress: () => onLongPress(),
      )
    ];

    if (selectable) {
      children.add(Positioned(
          left: 0,
          top: 0,
          child: Checkbox(
              onChanged: (value) {
                onSelect(state.url, value);
              },
              value: state.selected,
              activeColor: Colors.white,
              checkColor: Colors.black45,
            ),
          ));
    }

    return Container(
        child: Stack(
      alignment: Alignment.topLeft,
      children: children,
    ));
  }
}

댓글