Flutter (50): Data Sharing (InheritedWidget)

Time: Column:Mobile & Frontend views:202

50.1 InheritedWidget

First, we will introduce the InheritedWidget component and then focus on the relationship between the didChangeDependencies callback in the State class and the InheritedWidget.

1. Introduction

InheritedWidget is a crucial functional component in Flutter that provides a way to share data down the widget tree. For instance, if we share data through an InheritedWidget at the root of the application, we can access that shared data in any descendant widget! This feature is especially convenient in scenarios where data needs to be shared across the entire widget tree. For example, the Flutter SDK uses InheritedWidget to share application themes and locale (current language) information.

InheritedWidget is similar to the context feature in React. Compared to passing data down step-by-step, it allows components to pass data across multiple levels. The data flow direction of InheritedWidget is top-down, which is the opposite of the notification direction (to be introduced in the next chapter).

Now, let’s look at the InheritedWidget version of the previous “Counter” example. Note that this example is primarily to demonstrate the functionality of InheritedWidget and is not the recommended implementation for a counter.

First, we extend InheritedWidget to store the current counter click count in the data property of ShareDataWidget:

class ShareDataWidget extends InheritedWidget {
  ShareDataWidget({
    Key? key,
    required this.data,
    required Widget child,
  }) : super(key: key, child: child);

  final int data; // Data to be shared in the subtree, storing click count

  // Convenience method for widgets in the subtree to access shared data
  static ShareDataWidget? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ShareDataWidget>();
  }

  // This callback decides whether to notify the subtree to rebuild when data changes
  @override
  bool updateShouldNotify(ShareDataWidget old) {
    return old.data != data;
  }
}

Next, we implement a child component _TestWidget, which references the data in ShareDataWidget within its build method. Additionally, it logs information in the didChangeDependencies() callback:

class _TestWidget extends StatefulWidget {
  @override
  __TestWidgetState createState() => __TestWidgetState();
}

class __TestWidgetState extends State<_TestWidget> {
  @override
  Widget build(BuildContext context) {
    // Use shared data from InheritedWidget
    return Text(ShareDataWidget.of(context)!.data.toString());
  }

  @override // Detailed explanation to follow.
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Called when the InheritedWidget in the parent or ancestor changes (when updateShouldNotify returns true).
    // This callback won't be called if build does not depend on InheritedWidget.
    print("Dependencies change");
  }
}

2. didChangeDependencies

When we previously introduced StatefulWidget, we mentioned that the State object has a didChangeDependencies callback, which is called by the Flutter framework when "dependencies" change. This "dependency" refers to whether a child widget is using data from an InheritedWidget in the parent widget! If it is used, the child widget is considered dependent; if not, it is considered independent. This mechanism allows child components to update themselves when their dependent InheritedWidget changes! For instance, when themes, locales, etc., change, the didChangeDependencies method of dependent child widgets will be called.

Finally, we create a button that increments the value of ShareDataWidget each time it is pressed:

class InheritedWidgetTestRoute extends StatefulWidget {
  @override
  _InheritedWidgetTestRouteState createState() => _InheritedWidgetTestRouteState();
}

class _InheritedWidgetTestRouteState extends State<InheritedWidgetTestRoute> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ShareDataWidget( // Use ShareDataWidget
        data: count,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.only(bottom: 20.0),
              child: _TestWidget(), // Child widget depends on ShareDataWidget
            ),
            ElevatedButton(
              child: Text("Increment"),
              // Each click increments count and rebuilds; ShareDataWidget's data will be updated
              onPressed: () => setState(() => ++count),
            )
          ],
        ),
      ),
    );
  }
}

After running, the interface is shown in Figure :

Flutter (50): Data Sharing (InheritedWidget)

Each time the button is clicked, the counter increments, and the console prints the following log:

I/flutter ( 8513): Dependencies change

It is evident that when dependencies change, the didChangeDependencies() method is called. However, readers should note that if the build method of _TestWidget does not use the data from ShareDataWidget, its didChangeDependencies() will not be called, as it does not depend on ShareDataWidget. For example, if we modify the code of __TestWidgetState as shown below, didChangeDependencies() will not be called:

class __TestWidgetState extends State<_TestWidget> {
  @override
  Widget build(BuildContext context) {
    // Use shared data from InheritedWidget
    // return Text(ShareDataWidget.of(context)!.data.toString());
    return Text("text");
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // The build method does not depend on InheritedWidget, so this callback will not be called.
    print("Dependencies change");
  }
}

In the code above, we commented out the part of the build() method that depends on ShareDataWidget and returned a fixed Text. As a result, when the Increment button is clicked, although the data of ShareDataWidget changes, since __TestWidgetState does not depend on ShareDataWidget, the didChangeDependencies method of __TestWidgetState will not be called. This mechanism is quite understandable because it is reasonable and performance-friendly to only update the widgets that use the data when the data changes.

Question: How does the Flutter framework know whether a child widget depends on the parent InheritedWidget?

What Should be Done in didChangeDependencies()?

Generally, child widgets rarely override this method, as the Flutter framework also calls the build() method to rebuild the component tree when dependencies change. However, if you need to perform some expensive operations, such as network requests, after dependencies change, the best approach is to execute them in this method to avoid executing these expensive operations every time build() is called.

50.2 In-Depth Understanding of InheritedWidget

Now, let’s consider what to do if, in the above example, we only want to reference the data from ShareDataWidget in __TestWidgetState, but do not want the didChangeDependencies() method of __TestWidgetState to be called when ShareDataWidget changes. The answer is straightforward: we just need to modify the implementation of ShareDataWidget.of():

// Convenience method for widgets in the subtree to access shared data
static ShareDataWidget of(BuildContext context) {
  // return context.dependOnInheritedWidgetOfExactType<ShareDataWidget>();
  return context.getElementForInheritedWidgetOfExactType<ShareDataWidget>()!.widget as ShareDataWidget;
}

The only change is the way to obtain the ShareDataWidget object, replacing dependOnInheritedWidgetOfExactType() with context.getElementForInheritedWidgetOfExactType<ShareDataWidget>().widget. What is the difference between these two methods? Let’s look at the source code of these two methods (the implementation code is in the Element class; we will introduce the relationship between Context and Element later):

@override
InheritedElement getElementForInheritedWidgetOfExactType<T extends InheritedWidget>() {
  final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
  return ancestor;
}

@override
InheritedWidget dependOnInheritedWidgetOfExactType({ Object aspect }) {
  assert(_debugCheckStateIsActiveForAncestorLookup());
  final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
  // Additional code
  if (ancestor != null) {
    return dependOnInheritedElement(ancestor, aspect: aspect) as T;
  }
  _hadUnsatisfiedDependencies = true;
  return null;
}

We can see that dependOnInheritedWidgetOfExactType() calls the dependOnInheritedElement method, which has the following source code:

@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object aspect }) {
  assert(ancestor != null);
  _dependencies ??= HashSet<InheritedElement>();
  _dependencies.add(ancestor);
  ancestor.updateDependencies(this, aspect);
  return ancestor.widget;
}

In the dependOnInheritedElement method, the main function is to register the dependency! With this, we can clearly understand the difference: calling dependOnInheritedWidgetOfExactType() registers a dependency, while getElementForInheritedWidgetOfExactType() does not. Therefore, when dependOnInheritedWidgetOfExactType() is called, the relationship between InheritedWidget and its dependent descendant components is registered. Afterward, when InheritedWidget changes, it will update the dependent descendant components, which means calling their didChangeDependencies() and build() methods. In contrast, when getElementForInheritedWidgetOfExactType() is called, there is no registered dependency, so when InheritedWidget changes, it will not update the corresponding descendant widgets.

Note that if we change the implementation of the ShareDataWidget.of() method in the above example to call getElementForInheritedWidgetOfExactType(), after running the example and clicking the "Increment" button, we will find that the didChangeDependencies() method of __TestWidgetState will indeed not be called. However, its build() will still be called! The reason for this is that clicking the "Increment" button calls the setState() method of _InheritedWidgetTestRouteState, which will rebuild the entire page. Since the __TestWidget in this example does not have any caching, it will also be rebuilt, hence its build() method will be called.

Now, this raises a question: we actually only want to update the components in the subtree that depend on ShareDataWidget, but now every time we call the setState() method of _InheritedWidgetTestRouteState, all child nodes will be rebuilt, which is unnecessary. Is there a way to avoid this? The answer is caching! A simple approach is to encapsulate a StatefulWidget to cache the subtree of child widgets. In the next section, we will demonstrate how to cache and use InheritedWidget to achieve global state sharing in Flutter by implementing a Provider widget.