Navigator.push로 띄운 route에서 InheritedWidget에 접근할 수 없는 문제

Joey·2024년 5월 25일

flutter

목록 보기
1/1
post-thumbnail

Overview

  • InheritedWidget을 사용한 shared state management를 구현하던 도중 Navigator.push로 띄운 route에서 상위 widget인 InheritedWidget에 접근하지 못하는 문제
  • BuildContext.dependOnInheritedWidgetOfExactTypenull을 반환하여 state 및 action method에 접근할 수 없음
  • Navigator.push를 사용할 때, BuildContext에 따라 route로 사용되는 widget이 widget tree에 추가되는 위치가 달라지는 것을 이해하여, InheritedWidget이 widget tree에서 자신에 접근하려는 child widget들의 공통 ancestor widget이 되도록 만들어 문제를 해결한다.

Summary

  • MaterialApp은 내부에서 routing을 담당하는 최상위 Navigator를 만들고 widget tree에 추가한다.
  • Navigator.of(context)는 현재 widget의 widget tree에서 가장 가까운 ancestor Navigator widget을 얻는다.
  • build() method로 전달되는 BuildContext를 통해 Navigator.of(context)를 얻으면 MaterialApp 아래에 있는 최상위 Navigator를 얻는다.
  • Screen A에서 Screen B로 Navigator.push를 통해 화면을 전환하면, Screen A와 B는 같은 level로 widget tree에 추가된다. (sibling 관계)
  • 이 때, Screen B가 A의 child widget으로 만들려면 Screen A가 자신이 직접 관리하는 Navigator를 가지고 routing 해야 한다. 따라서, Screen A widget이 내부에서 Navigator widget을 별도로 생성하고, MaterialPageRoutebuilder 함수로 Screen A의 UI를 구성하는 widget을 반환한다.
  • Navigator를 추가하지 않으면서 Screen B가 Screen A와 InheritedWidget을 통한 shared state에 접근하려면, InheritedWidgetMaterialApp보다 상위에 있어야 한다.
    • 실제로 Riverpod package의 ProviderScopeMyApp을 감싸는 widget tree의 root로 추가해야 하는데,
    • ProviderScope는 내부 build() method에서 InheritedWidget을 상속받는 UncontrolledProviderScope를 반환한다.
    • Riverpod도 비슷한 문제를 해결하기 위해 최상위에 InheritedWidget을 놓으려는 시도를 한 것 같다.

예시 코드

문제를 재현하는 간단한 counter app

  • HomeScreenDetailScreen은 버튼을 눌러서 count 증가

  • HomeScreenDetailScreen은 count 값(state) 공유

  • 공유 상태를 하위 widget에 공유하기 위한 CounterProviderInheritedWidget으로 생성

     class CounterProvider extends InheritedWidget {
       const CounterProvider({
         super.key,
         required super.child,
         required this.counter,
         required this.incrementCounter,
       });
    
       final int counter; // ✅ count 값 공유
       final void Function() incrementCounter; // ✅ count action 공유
    
       
       bool updateShouldNotify(covariant InheritedWidget oldWidget) => true;
    
        static CounterProvider? of(BuildContext context) => 
          context.dependOnInheritedWidgetOfExactType<CounterProvider>();
    }
  • 공유 상태를 관리하기 위한 CounterScope widget을 StatefulWidget으로 생성

    class CounterScope extends StatefulWidget {
      const CounterScope({
        super.key,
        required this.child,
      });
    
      final Widget child;
    
      
      State<CounterScope> createState() => _CounterScopeState();
    }
    
    class _CounterScopeState extends State<CounterScope> {
    
      // ✅ 실제 상태 값 관리 : 상태 저장 및 갱신(setState)
      int counter = 0;
      void incrementCounter() {
        setState(() {
          counter++;
        });
      }
    
      
      Widget build(BuildContext context) {
        return CounterProvider(
          counter: counter,
          incrementCounter: incrementCounter,
          child: widget.child,
        );
      }
    }
  • MyApphome에서 CounterScopeHomeScreen의 상위 widget으로 추가

    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      // This widget is the root of your application.
      
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Navigator Inherited Example',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          // ✅ CounterScope가 HomeScreen의 상위 widget이 됨
          home: const CounterScope(child: HomeScreen()),
        );
      }
    }

문제 상황

  1. HomeScreen에서는 CounterProvider에 접근할 수 있음

    class HomeScreen extends StatelessWidget {
      const HomeScreen({super.key});
    
      
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: ...,
          body: Center(
            child: CountLabel(
              // ✅
              counter: CounterProvider.of(context)?.counter ?? -1,
            ),
          ),
          floatingActionButton: IncrementButton(
            // ✅
            onPressed: CounterProvider.of(context)?.incrementCounter,
          ),
        );
      }
    }
  2. Navigator.pushDetailScreen으로 이동

    // 버튼 callback
    onPressed: () => Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => const DetailScreen(),
      ),
    ),
  3. DetailScreen에서는 CounterProvider에 접근할 수 없음

    // 버튼 callback
    class DetailScreen extends StatelessWidget {
      const DetailScreen({super.key});
    
      
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: const Text('Detail Page'),
          ),
          body: Center(
            child: CountLabel(
            	// ❌ : null을 반환하므로 label에 '-1' 표시
              counter: CounterProvider.of(context)?.counter ?? -1,
            ),
          ),
          floatingActionButton: IncrementButton(
            // ❌ : null을 반환하므로 counter가 동작하지 않음
            onPressed: CounterProvider.of(context)?.incrementCounter,
          ),
        );
      }
    }

원인 분석

상황

  • BuildContext.dependOnInheritedWidgetOfExactTypenull을 반환한다는 것은 ancestor에 해당 type의 InheritedWidget이 존재하지 않는다는 것
  • Widget tree를 보면, DetailScreenCounterProvider는 ancestor-descendant 관계가 아닌 sibling 관계
  • 즉, CounterProviderDetailScreen의 ancestor가 아니기 때문에 null을 반환했다.
  • HomeScreenCounterProvider의 child widget이므로 HomeScreen에서 BuildContext.dependOnInheritedWidgetOfExactTypeCounterProvider 객체를 정상적으로 반환할 수 있었음

원인

  • HomeScreen에서 Navigator.push로 전달한 route에서 사용되는 DetailScreen은 왜 HomeScreen의 child widget이 아닌 sibling widget이 되었는가? (왜 다른 widget tree를 형성했을까?)
  • Flutter app의 최상위에는 MaterialApp 등의 WidgetsApp이 위치해야 함
  • MaterialApp은 widget tree의 최상위에 Navigator를 갖고 있고, home에 전달되는 widget을 Navigator가 관리하는 route stack의 첫 번째 element로 설정함
    MaterialApp
    ㄴ Navigator
    	ㄴ HomeScreen
  • HomeScreen에서 Navigator.of(context)로 현재 widget tree 상에서 가장 가까운 ancestor navigator를 가져오게 되는데, 이 때 최상위 MaterialApp 아래의 최상위 Navigator를 사용함
  • 즉, HomeScreen에서 최상위 Navigator를 사용하여 DetailScreen으로 routing 하기 때문에 HomeScreenDetailScreen은 최상위 Navigator의 children이 된다.
    MaterialApp
    ㄴ Navigator
    	ㄴ HomeScreen
      ㄴ DetailScreen  // => push된 DetailScreen이 HomeScreen의 sibling이 됨

해결 방법

CounterProviderDetailScreen을 ancestor-descendant 관계로 만들어야 한다.

  1. HomeScreen에서 DetailScreen routing을 위해 사용하는 Navigator를 하위에 추가하여 사용한다.
  2. CounterProvider를 최상위 Navigator보다 상위 level로 추가한다. (MaterialApp 위)

HomeScreen 하위 Navigator가 DetailScreen을 push하게 만들기

관련 코드

HomeScreen을 아래와 같이 수정한다.

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context) {
    // ✅ HomeScreen 하위에 Navigator 추가
    // 이제 `Navigator.of(context)`는 이 Navigator를 반환한다.
    return Navigator(
      onGenerateRoute: (settings) => MaterialPageRoute(
        builder: (context) => ..., // 원래 widget 덩어리 반환
      ),
    );
  }
}
  1. HomeScreenbuild() method에서 Navigator를 반환한다.
  2. NavigatoronGenerateRoute 함수에서 MaterialPageRoute를 반환한다.
  3. MaterialPageRoutebuilder 함수에서 HomeScreen을 구성하는 Scaffold widget 덩어리를 반환한다.

이제 HomeScreen에서 Navigator.of(context)를 호출하면 최상위 Navigator가 아닌 HomeScreen의 하위 Navigator를 사용하므로 DetailScreenHomeScreen의 child widget으로 추가된다.

InheritedWidget의 위치 변경

관련 코드

DetailScreenMaterialApp 아래에서 CounterProviderHomeScreen과 병렬적으로 widget tree를 형성하는 것이 문제이므로, CounterProviderMaterialApp의 상위 widget이 될 수 있도록 CounterScope의 위치를 최상위로 변경한다.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
  	// ✅ CounterScope와 CounterProvider가 MaterialApp보다 상위 widget이 된다.
    return CounterScope(
      child: MaterialApp(
        title: 'Navigator Inherited Example',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        // ✅ `HomeScreen`을 감싸고 있던 CounterScope는 제거한다.
        home: const HomeScreen(),
      ),
    );
  }
}

이제 HomeScreenDetailScreen이 sigling 관계여도 CounterProvider를 공통 ancestor로 가지므로, 두 widget 모두 BuildContext를 통해 CounterProvider에 접근할 수 있다.

참고

profile
software engineer

0개의 댓글