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

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

by 신나요 2022. 6. 8.

플러터 앱에서 스테이트풀 위젯과, 스테이트리스 위젯에 대해 알아보며 상태의 이야기를 해오고 있습니다. InheritedWidget을 사용해서 상태를 공유하는 방법에 대해 알아보겠습니다.

 

이번 포스트에서 이야기하는 핵심 내용

  • InheritedWidget을 이해하고 사용하는 법
  • InheritedWidget의 updateShouldNotify 메소드
  • BuildContext의 dependOnInheritedWidgetOfExactType 메소드

플러터 상태 공유

지난 예제에서 상위 위젯이 가지고 있는 상태를 하위 위젯에서 변경하기 위해서 많은 위젯 사이에서 데이터를 주고받아야 했습니다. 그 결과 많은 종속성을 위젯이 가지게 되었었고 좋은 접근 방식이 아니란 걸 알 수 있었죠.

 

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

상태 관리 관점에서 stateful widget 실습을 하고 있습니다. sateful widget 실습 1에서 체크박스로 선택 가능한 갤러리를 만들었는데요. 이 화면을 개선해서 태그로 필터 기능을 가지는 갤러리로 수정해

nayotutorial.tistory.com

 

우선 플러터 앱이 다음과 같은 구조를 가지고 있다고 가정해 보겠습니다.

App이 있고 하위 위젯으로 Gallery를 가지고 있습니다. 그리고 Gallery는 세 개의 하위 위젯 Filter, Video, Photo를 가지고 있습니다. 상태를 공유한다는 생각은 위젯 트리에서 상위에 어떤 상태가 있다고 생각할 수 있습니다.  App에서 상태를 가지고 있다고 해보겠습니다.

Infos안에는 하위 위젯들에서 참조하는 앱의 설정이 들어 있고 Photo 위젯으로 상태를 가져오고 싶습니다. App에서 시작해서 Photo까지 사이의 위젯 트리의 의 모든 곳에 전달해서 이용할 수 있을 것입니다. 아래 그림과 같은 모양이 되겠네요.

상태를 위젯 트리에 따라 연달아 공유

실제로 지난 포스트에서 했던 예제가 이런 모양인데, 물론 잘 작동하고 이해하기도 어렵지 않은 것 같습니다. 하지만 전달하기 위한 목적으로 이러한 상태 정보를 수신하기 때문에 중간에 끼인 위젯을 복잡하게 만드는 경우가 많습니다. 트리가 단계가 복잡해질수록 더욱 할 일이 많아집니다.

대신 원하는 구조는 해당 상태를 필요한 위치에 직접 공유하는 것입니다.

필요한 위젯에만 상태를 공유

데이터는 여전히 App에 있고 바로 Photo에 infos의 속성들을 노출이 됩니다. 그리고 이상적으로는 Infos속성을 읽을 수 있고 필요에 따라 Infos의 상태도 업데이트가 가능한 양방향 통신이 가능해야 하겠네요. 어떻게 하면 이러한 구조를 구현할 수 있을까요?


Inherited WIdget, ScopedModel, Provider

결론은 ScopedModel과 Provider를 사용하면 효과적으로 상태를 공유할 수 있게 됩니다. ScopedModel과 Provider는 Inherited Widget을 기반으로 하고 있습니다. 먼저 Inherited Widget을 먼저 공부해서 ScopedModel과 Provider가 어떻게 발전했는지 흐름을 파악해 보도록 하겠습니다.


InheritedWIdget을 이해하기 위한 개념들

1. 플러터 앱은 위젯 트리로 이루어져 있습니다. 위젯 트리도 자세히 이야기하면 꽤나 복잡한 이야기가 되지만, 여기서는 단순히 플러터 앱은 위젯들로 구성되어 있고 구성되는 모양이 최상위 위젯으로 시작해서 하위 위젯들로 뻗어나가는 트리 구조를 가지고 있다는 정도만 기억하시면 됩니다.

2. Stateful widget은 state를 가지며 상태를 갱신해주기 위해 setState를 호출하게 된다는 것 플러터의 기본 개념이죠, 이쪽을 참고해주세요. 

3. BuildContext는 위젯 트리 내에서 위젯의 위치를 나타내는 것 정도로만 기억해 주세요.


InheritedWIdget으로 개선

지난 포스트의 예제를 InheritedWidget을 사용하는 방식으로 수정하면서 공부해 보도록 하겠습니다. 현재는 하위 위젯인 Photo에서 상태를 갱신하기 위해서 상태를 트리를 따라 상위 위젯에서 하위 위젯으로 연속으로 전달해주는 구조를 가지고 있습니다. 이것을 Inherited Widget을 통해 개선해야 합니다.

App, Gallery, Photo 위젯이 있습니다. 우선 App 내에서 InheritedWidget을 만들 것입니다. InheritedWIdget에는 상태와 해당 상태를 조작할 수 있는 함수가 포함된 State가 포함됩니다. 이제 BuildContext를 사용하게 됩니다. 빌드 메서드 내부에서 buildContext를 수신할 것이며, BuildContext를 사용해서 InheritedWidget에서 하위 위젯으로 직접 통신을 하게 될 것입니다. 

 

한 가지 헷갈리수도 있는 부분이 있는데 Gallery 위젯에서도 앱의 상태를 이용해야 하므로 Gallery위젯도 Inherited WIdget과 통신을 하게 해줘야 합니다.

예제 앱은 위와 같은 구조를 가지게 될 것입니다.

이제 실제 코드를 변경해보면서 알아보겠습니다.

 

1. InheritedWidget 클래스만들기

InheritedWidget을 만드는 방법은 간단합니다. InheritedWidget을 Extends 하면 됩니다.

InheritedWidget을 상속받아 AppInheritedWIdget을 만들고 있습니다. InheritedWidget에는 재 정의해야 하는 메서드 updateshouldNotify가 존재합니다. 예제에서는 상태가 바뀌면 항상 위젯 요소의 변경 사항을 반영하고 다시 렌더링 해야 하므로 updateshouldNotify 메서드를 ture로 리턴해주고 있습니다.(아래 추가 설명이 이어집니다) 생성자에서는 하위 요소로 공유해주고 싶은 state와 하위 위젯인 child를 초기화해주고 있습니다. InheritedWIdget은 자식 위젯을 래핑하고 범위를 정의해야 합니다. super로 child 위젯을 다시 호출해주고 있습니다. 이제 InheritedWidget은 상태(데이터)를 InheritedWidget의 자식인 모든 것과 공유할 수 있게 됩니다.

InheritedWidget을 역할을 첫 번째로 하위 위젯에서 BuildContext로 state 프로퍼티에 접근하는 것이고, 두 번쨰로 상태가 변경되었을 때  하위 위젯에 업데이트를 전달해 갱신시키는 것입니다.

*InheritedWidget의 updateShouldNotify 메소드

위젯의 갱신이 일어나면 InheritedWidget도 갱신이 되어야 하는데, InheritedWidget의 인스턴스는 내용을 변경하는 대신에  변경된 내용으로 새 인스턴스를 만들어 교체됩니다. 이때 InheritedWidget의 하위 위젯들에게도 업데이트가 필요하다고 전파를 해줘야 할 것입니다. InheritedWidget의 교체가 이뤄질 때 updateShouldNotify가 호출되는데 실제로 하위 위젯에 갱신이 전파되는 때는 updateShouldNotify 메서드의 리턴 값이 true일 때만 입니다. updateShouldNotify 메서드에서 old 인스턴스도 같이 얻을 수 있기 때문에 값을 비교하여 하위 위젯 요소 업데이트 여부를 제어하는 로직을 구현할 수 있습니다.

 

2. AppState에서 InheritedWidget 사용하기

상위 위젯인 스테이트풀 위젯 App 위젯의 AppState에서 위에서 만든 AppInheritedWIdget을 사용하고 있습니다. InheritedWidget을 생성할 때 필수 값으로 state에는 this를 주어 AppState자체가 하위 요소에 공유될 수 있도록 설정해 주고 있습니다. AppInheritedWIdget의 child 속성에는 하위 위젯인 Gallery를 전달해야 합니다.

 

언급했듯이 Gallery에서도 앱의 state인 AppState를 사용해야 하므로 AppInheritedWidget을 통해 공유해 주고 싶습니다. 그러기 위해서 사용하는 것이 BuildContext입니다. BuildContext는 현재 위젯의 위치 정보를 가지고 있다고 했었는데요. 우리가 사용해야 할 context는 Gallery의 context입니다. build 메소드를 호출할때는 AppInheritedWidget이 아직 빌드 컨텍스트를 구성하지 않은 상태이기 때문에 빌드 컨텍스트의 일부가 아니므로 새롭게 내부의 빌드 컨텍스트를 가져와야 합니다. 새 빌드 컨텍스트를 얻는 가장 쉬운 방법은 Builder위젯으로 제공해 주는것 입니다. Builder 위젯의 builder에 innerContext를 호출할 다른 빌드 컨텍스트를 수신하는 메소드를 설정해줍니다. builer에 설정한 메소드 안에서 Gallery 위젯을 생성하고 리턴해 주게 되는데 Gallery의 model 속성에 innerContext를 사용하여 InheritedWidget에 접근해 state를 가져와 넣어주고 있습니다.

BuildContext의 dependOnInheritedWidgetOfExactType

InheritedWidget에 접근하기 위해 사용하는 것은 dependOnInheritedWidgetOfExactType메소드 입니다. 이 메서드는 BuildContext에 있는 메서드입니다. 이 엄청나게 긴 이름의 메서드의 역할은 위젯 트리를 조회하고 우리가 원하는 유형의 위젯 AppInheritedWidget를 찾는 것입니다.  예를 들어 context.deppendOnInheritedWidgetOfExactType<FindMe>라고 쓰면 context는 현재 위젯으로부터 Widget트리를 거슬러 올라갔을 때에 가장 가까이 있는 FindeMe라는 위젯을 돌려줍니다. 이것을 이용해서 InheritedWidget을 찾고 접근자로 상태 프로퍼티인 state를 참조해 오는 것이 가능해집니다.

 

이제 Gallery 위젯을 살펴보겠습니다.

3. Gallery 위젯 수정

1. 이전 생성자에서는 여러 속성들이 필요했던 반면 이제 InheritedWidget에서 가져온 상태를 초기화해주고 있습니다. Gallery를 생성할 때 InheritedWidget을 사용하지 않고 하위 위젯에 모든걸 전달해주었던 소스와 비교해 보면 확연히 줄어든 모습입니다.

수정전 Gallery위젯 생성
수정후 Gallery위젯 생성

2. 이제 GridView에서 건네받은 상태로 photoState에 접근하여 Photo위젯 리스트를 생성할 수 있게 되었습니다.

3. Photo를 생성할때 Gallery를 생성할 때와 마찬가지로 Photo에서 필요한 상태를 AppInheritedWidget으로부터 가져와 건네주고 있습니다.

 

마지막으로 Photo위젯의 변경점을 살펴보겠습니다.

4. Photo 위젯 수정

Photo 위젯에서 변경된 점은 상위 위젯에서 전달되었던 상태 정보들이 제거되었고, 대신에 InheritedWidget에서 가져온 state(AppState)를 건네받게 되었습니다. 이제 AppState인 model을 이용하여 콜백과 프로퍼티들을 접근할 수 있게 되었습니다.

 

많은 코드의 변경이 있었지만 앱의 움직임은 이전과 동일합니다.


 

여기까지 수고하셨습니다.

조금 이해하기 까다로운 InheritedWidget에 대해 알아보았는데요. InheritedWidget을 이해하는 것이 상태 관리에서 ScopeModel과 Provider를 이해하는 중요한 열쇠가 될 것입니다. 다음 포스트는 ScopeModel입니다.

 

 

--예제 코드--

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;
  bool display;
  Set<String> tags = {};

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

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

class AppInheritedWidget extends InheritedWidget {
  final AppState state;

  const AppInheritedWidget(
      {Key? key, required Widget child, required this.state})
      : super(key: key, child: child);

  @override
  bool updateShouldNotify(covariant InheritedWidget oldWidget) {
    return true;
  }
}

class AppState extends State<App> {
  bool isTaggingMod = false;
  List<PhotoState> photoStates = List.of(urls.map((url) => PhotoState(url)));
  Set<String> tags = {"all", "dog", "cat"};

  void toggleTagging(url) {
    setState(() {
      isTaggingMod = !isTaggingMod;
      for (var element in photoStates) {
        if (isTaggingMod && element.url == url) {
          element.selected = true;
        } else {
          element.selected = false;
        }
      }
    });
  }

  void selectTag(String tag) {
    setState(() {
      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);
        }
      }
    });
  }

  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: AppInheritedWidget(
          state: this,
          child: Builder(builder: (innerContext) {
            return Gallery(
              title: 'Gallery',
              model: innerContext
                  .dependOnInheritedWidgetOfExactType<AppInheritedWidget>()!
                  .state,
            );
          }),
        ));
  }
}

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: Builder(builder: (innerContext) {
        return 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: innerContext
                      .dependOnInheritedWidgetOfExactType<AppInheritedWidget>()!
                      .state)),
            ));
      }),
      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,));
  }
}

댓글