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

[flutter 상태관리] 8. 플러터 ScopedModel

by 신나요 2022. 6. 9.

지금까지 상태를 전달해주기 위해 해왔던 일은 되짚어보면 첫 번째로는 상위 위젯에서 상태가 필요로 하는 위젯까지 연달아 상태를 전달해 주었습니다. 문제는 중간에 있는 위젯들도 전달을 목적으로만 상태를 건네받아야 했었죠. 두 번째는 InheritedWiget을 사용하여 상태를 필요한 위젯에서 접근해서 사용했습니다. 이제 더 발전된 형태인 scopedModel을 활용해서 모델(상태)을 하위 위젯에서 어떤 식으로 이용할 수 있는지 알아보도록 하겠습니다. ScopedModel에서는 상태를 모델이라고 부르게 됩니다.

 

지난 상태 관리 글에서 예제가 이어집니다. 🤓

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

[flutter 상태관리] 6. stateful widget 실습2, 태그 필터 갤러리 만들기

[flutter 상태관리] 7. InheritedWidget 으로 상태 공유하기


ScopedModel 이란?

우리가 원하는 앱의 구조는 다음과 같이 상태를 하위 위젯에서 직접 접근하는 방식입니다.

scopedModel을 사용하면 이러한 구조가 가능해집니다. 하지만 InheritedWIdget과는 약간 다른 접근 방식을 가지게 됩니다.

먼저 ScopedModel을 사용하여 모델(상태)을 정의하는 것으로 시작합니다. 이 모델 클래스는 위젯 트리 외부에 존재하게 됩니다.

그런 후 App위젯 내에서 ScopedModel이라는 위젯을 빌드하게 됩니다. ScopedModel안에 모델을 전달해 줍니다.

이제 위젯 트리 내부에서 ScopedModel에 대한 접근을 해야 되는데 이때 ScopedModelDescendant 클래스를 사용하여 해당 모델을 주입해주게 됩니다. 따라서 InheritedWidget과 비교해보면 접근 방식은 유사하지만 BuildContext를 사용하지 않고 ScopedModelDescendant를 사용하는 것이 다른 점 다르고 흥미로운 점은 StatefulWidget과 State를 사용할 필요가 없어집니다.

 

ScopedModel의 역할을 간략히 정리하면 아래와 같습니다.

  1. 상위 위젯에서 하위 위젯으로 모델을 전달한다.
  2. 모델이 업데이트되면 모델을 참조하는 위젯을 다시 렌더링 한다.

ScopedModel  실습하기

실습은 지난 포스트의 코드 소스를 베이스로 수정해서 실습이 이루어집니다.

코드를 바로 참고하고 싶으시면 제일 밑으로 내려주세요.

 

1. scoped_model 패키지 추가

scopedModel은 flutter의 추가 패키지로 제공됩니다.

pubspec.yaml파일의 dependencies영역에 아래와 같이 추가하고 저장을 하면 VSCode에서 자동으로 패키지를 인스톨해줍니다.

 

2. 모델 만들기

가장 먼저 할 일은 모델(상태)을 만드는 일입니다. AppState의 코드를 수정해서 모델을 만들겠습니다.

AppState클래스를 State를 extends 하는 대신에 scoped_model 패키지에서 가져온 Model을 extends 하고 있습니다. AppState클래스는 더 이상 State를 extends하지 않기 때문에 setState메서드가 존재하지 않습니다. 플러터에서 State가 업데이트되었음을 알리기 위해 사용했던 기술을 사용할 수 없는데요. 이 역할을 setState 메소드 대신에 notifyListeners의 호출로 대체할 수 있습니다. notifyListeners는 모델에 의존하는 위젯에 대한 변경을 리스너에게 알리기 위해 scoped_models내부에서 사용되는 기술입니다. 상태를 변경하는 각 콜백 메서드에서 이를 수행해주도록 수정하였습니다.

위에는 접혀있는 코드인 selectTag와 onPhotoSelect에서도 상태를 변경해주고 있으므로 notifyListeners를 호출합니다.(아래 전체 소스를 참고해 주세요)

 

3. 위젯에서 ScopedModel 정의하고 모델 가져오기

다음으로 해야 할 일은 ScopedModel을 App위젯에서 정의해주는 일입니다.

1. App위젯은 이제 StatefulWidget이 아닌 StatelessWidget으로 바뀌었습니다. StatefulWidget일 때는 상태 변경을 위해 setState메서드를 호출했었지만 이제 setState대신에 Model의 notifyListeners를 사용하므로 StatelessWidget으로도 변경해주었습니다. 대신 build메서드가 필요해졌고 AppState에서 쓰던 빌드 메서드를 그대로 가져와 주었습니다. 

2. 앱에 모델을 적용하는 방법은 ScopedModel로 위젯으로 생성하는 것입니다. ScopedModel 역시 단순한 StatelessWidget인데요. 간단히 사용하고 싶은 위젯을 ScoptedModel로 랩핑 해주면 되는데, 위젯 트리 상단에서 ScopedModel을 사용하여 사용할 모델을 인스턴스화 해주면 됩니다. model 속성에는 AppState의 인스턴스를 설정해주고 있습니다.

3. 그럼 자식 위젯에서 모델을 어떤 식으로 전달할 수 있을까요? 위에서 언급했듯이 ScopedModelDescendant 클래스를 이용합니다. 제네릭 유형을 사용해서 우리가 만든 모델인 AppState로 설정해 줍니다. 생성자 내부에서 빌더 함수를 전달해 줍니다. 이 빌더에서 가장 중요한 것은 model을 건네받는 것입니다. 이제 건네받은 model을 사용하여 직접 Gallery 위젯에 전달하고 있습니다.

이제 더 이상 빌드 콘텍스트에 의존하지 않게 되었습니다.

 

이제 하위 위젯인 Gallery에서도 같은 작업을 해줘서 model을 이용하게 됩니다.

ScopedModel.of 사용하여 model에 접근하기

 모델에 접근하는 다른 방법은 ScopedModel.of메서드를 이용하는 것입니다. ScopedModel.of <모델 클래스>(context)를 사용하면 하위 위젯에서 해당하는 모델 클래스에 접근을 할 수 있습니다. context는 ScopedModels에 의해 백그라운드에서 사용되고 직접 사용할 필요는 없습니다. ScopedModelDecendant의 빌더 함수에서 전달해주는 인수에서 model만 사용을 하는 경우에는 ScopedModel.of를 사용하면 좀 더 깔끔한 코딩이 가능해집니다.

 

여전히 앱은 이전과 동일하게 작동하지만 상태 공유는 좀 더 나은 구조를 갖게 되었습니다. 🥳


여기까지 수고하셨습니다. 다음 포스트는 Provider로 상태를 공유하는 방법에 대해 알아보도록 하겠습니다.

 

--전체 코드--

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.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;
  bool display;
  Set<String> tags = {};

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

class AppState extends Model {
  bool isTaggingMod = false;
  List<PhotoState> photoStates = List.of(urls.map((url) => PhotoState(url)));
  Set<String> tags = {"all", "dog", "cat"};
  
  void toggleTagging(url) {
    isTaggingMod = !isTaggingMod;
    for (var element in photoStates) {
      if (isTaggingMod && element.url == url) {
        element.selected = true;
      } else {
        element.selected = false;
      }
    }
    notifyListeners();
  }

  void selectTag(String tag) {
    if (isTaggingMod) {
      if (tag != "all") {
        for (var element in photoStates) {
          if (element.selected) {
            element.tags.add(tag);
          }
        }
        toggleTagging(null);
      }
    } else {
      for (var element in photoStates) {
        element.display = tag == "all" ? true : element.tags.contains(tag);
      }
    }
    notifyListeners();
  }

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

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScopedModel<AppState>(
      model: AppState(),
      child: MaterialApp(
          title: 'Photo',
          theme: ThemeData(
            primarySwatch: Colors.blueGrey,
            unselectedWidgetColor: Colors.grey,
          ),
          home: ScopedModelDescendant<AppState>(
            builder: (context, child, model) {
              return Gallery(title: 'Gallery', model: model);
            },
          )),
    );
  }
}

class Gallery extends StatelessWidget {
  final String title;
  final AppState model;

  const Gallery({super.key, required this.title, required this.model});

  @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(
              model.photoStates.where((ps) => ps.display).map((ps) => Photo(
                  state: ps,
                  model: ScopedModel.of<AppState>(context)
            )))),
      drawer: Drawer(
          child: ListView(
        children: List.of(model.tags.map((t) => ListTile(
              title: Text(t),
              onTap: () {
                model.selectTag(t);
                Navigator.of(context).pop();
              },
            ))),
      )),
    );
  }
}

class Photo extends StatelessWidget {
  final PhotoState state;
  final AppState model;

  Photo({required this.state, required this.model});

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

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

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

댓글