Flutter (61): Notification

Time: Column:Mobile & Frontend views:218

Notifications are an important mechanism in Flutter. In the widget tree, each node can dispatch notifications, which propagate upward through the current node, allowing all parent nodes to listen to these notifications via NotificationListener. Flutter refers to this mechanism of child-to-parent notification propagation as Notification Bubbling. Notification bubbling is similar to user touch event bubbling, but with one key difference: notification bubbling can be aborted, whereas user touch events cannot.

Note: The principle of notification bubbling is similar to event bubbling in web development, where events propagate upwards from the source. We can listen to notifications/events at any level of the tree and can also terminate the bubbling process. Once bubbling is stopped, notifications will no longer propagate upward.

61.1 Listening to Notifications

Flutter uses notifications in many places. For example, the Scrollable component dispatches scroll notifications (ScrollNotification) while scrolling, and the Scrollbar uses these notifications to determine its position.

Here is an example of listening to scroll notifications from a scrollable component:

NotificationListener(
  onNotification: (notification) {
    switch (notification.runtimeType) {
      case ScrollStartNotification: print("Started scrolling"); break;
      case ScrollUpdateNotification: print("Scrolling"); break;
      case ScrollEndNotification: print("Scrolling stopped"); break;
      case OverscrollNotification: print("Reached the boundary"); break;
    }
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(title: Text("$index"));
    }
  ),
);

In the example, scroll notifications such as ScrollStartNotification and ScrollUpdateNotification are subclasses of ScrollNotification. Different types of notifications contain different information; for instance, ScrollUpdateNotification has a scrollDelta property that records the amount of movement. Other notification properties can be found in the SDK documentation.

In this example, we use NotificationListener to listen for scroll notifications from the child ListView. The definition of NotificationListener is as follows:

class NotificationListener<T extends Notification> extends StatelessWidget {
  const NotificationListener({
    Key? key,
    required this.child,
    this.onNotification,
  }) : super(key: key);
  // ... omitted irrelevant code 
}

We can see that NotificationListener inherits from StatelessWidget, so it can be directly nested in the widget tree.

NotificationListener can specify a type parameter that must be a subclass of Notification. When explicitly specifying the type parameter, NotificationListener will only receive notifications of that type. For example, if we modify the previous code as follows:

// Specify the notification type to listen for: ScrollEndNotification
NotificationListener<ScrollEndNotification>(
  onNotification: (notification) {
    // This callback will only be triggered when scrolling ends
    print(notification);
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(title: Text("$index"));
    }
  ),
);

After running this code, the console will only print notification information when scrolling ends.

The onNotification callback is for handling notifications and has the following function signature:

typedef NotificationListenerCallback<T extends Notification> = bool Function(T notification);

Its return type is a boolean. When it returns true, it prevents bubbling, meaning the parent widget will no longer receive that notification; when it returns false, the notification continues to bubble up.

In the implementation of Flutter's UI framework, in addition to scroll components dispatching ScrollNotification during scrolling, there are other notifications such as SizeChangedLayoutNotification, KeepAliveNotification, and LayoutChangedNotification. Flutter uses this notification mechanism to allow parent elements to perform actions at specific moments.

61.2 Custom Notifications

In addition to internal notifications in Flutter, we can also create custom notifications. Here’s how to implement custom notifications:

Define a notification class that inherits from Notification:

class MyNotification extends Notification {
  MyNotification(this.msg);
  final String msg;
}

To dispatch a notification, use the dispatch(context) method from Notification. The context is effectively an interface for manipulating elements and corresponds to nodes in the element tree. Notifications will bubble up from the element node corresponding to the context.

Here’s a complete example:

class NotificationRoute extends StatefulWidget {
  @override
  NotificationRouteState createState() {
    return NotificationRouteState();
  }
}

class NotificationRouteState extends State<NotificationRoute> {
  String _msg = "";
  @override
  Widget build(BuildContext context) {
    // Listen for notifications
    return NotificationListener<MyNotification>(
      onNotification: (notification) {
        setState(() {
          _msg += notification.msg + "  ";
        });
        return true;
      },
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Builder(
              builder: (context) {
                return ElevatedButton(
                  // Dispatch notification when button is clicked
                  onPressed: () => MyNotification("Hi").dispatch(context),
                  child: Text("Send Notification"),
                );
              },
            ),
            Text(_msg)
          ],
        ),
      ),
    );
  }
}

class MyNotification extends Notification {
  MyNotification(this.msg);
  final String msg;
}

In this code, each time the button is clicked, a MyNotification type notification is dispatched. We listen for notifications at the root of the widget, and upon receiving a notification, we display its message on the screen.

Note: The commented-out portion of the code would not work correctly because the context is the root context, while the NotificationListener listens to a subtree. Therefore, we use a Builder to construct the ElevatedButton and obtain the context at the button's position.

Flutter (61): Notification

61.3 Preventing Notification Bubbling

Let's modify the previous example:

class NotificationRouteState extends State<NotificationRoute> {
  String _msg = "";
  @override
  Widget build(BuildContext context) {
    // Listen for notifications
    return NotificationListener<MyNotification>(
      onNotification: (notification) {
        print(notification.msg); // Print notification
        return false;
      },
      child: NotificationListener<MyNotification>(
        onNotification: (notification) {
          setState(() {
            _msg += notification.msg + "  ";
          });
          return false; 
        },
        child: ... // Omitted repetitive code
      ),
    );
  }
}

In the above code, two NotificationListeners are nested. The onNotification callback of the child NotificationListener returns false, indicating that bubbling is not prevented, so the parent NotificationListener will still receive the notification, and the console will print the notification message. If we change the return value of the child NotificationListener's onNotification callback to true, the parent NotificationListener will not print the notification anymore because the child NotificationListener has terminated the bubbling.

61.4 Bubbling Mechanism

We have introduced the phenomenon and usage of notification bubbling; now let’s delve deeper into how the Flutter framework implements notification bubbling. To understand this, we need to look at the source code, starting from the origin of notification dispatching and tracing it upwards. Notifications are sent via the dispatch(context) method of Notification, so let's examine what happens in dispatch(context). Here’s the relevant source code:

void dispatch(BuildContext target) {
  target?.visitAncestorElements(visitAncestor);
}

The dispatch(context) method calls the visitAncestorElements method of the current context, which traverses the parent elements starting from the current Element. The visitAncestorElements method has a callback parameter that executes for each ancestor element during traversal. The traversal stops when it reaches the root Element or when a callback returns false. The traversal callback passed to visitAncestorElements is the visitAncestor method. Let’s look at the implementation of visitAncestor:

// Traversal callback, executed for each ancestor Element
bool visitAncestor(Element element) {
  // Check if the current element's corresponding Widget is NotificationListener.
  
  // Since NotificationListener inherits from StatelessWidget,
  // first check if it is a StatelessElement.
  if (element is StatelessElement) {
    // If it is a StatelessElement, get the corresponding Widget
    // and check if it is a NotificationListener.
    final StatelessWidget widget = element.widget;
    if (widget is NotificationListener<Notification>) {
      // If it is a NotificationListener, call its _dispatch method
      if (widget._dispatch(this, element)) 
        return false;
    }
  }
  return true;
}

The visitAncestor method checks whether each visited ancestor widget is a NotificationListener. If not, it returns true to continue the traversal; if it is, it calls the _dispatch method of NotificationListener. Let’s take a look at the source code of _dispatch:

bool _dispatch(Notification notification, Element element) {
  // If the notification listener is not null and the current notification type
  // matches the type listened for by this NotificationListener,
  // call the current NotificationListener's onNotification.
  if (onNotification != null && notification is T) {
    final bool result = onNotification(notification);
    // The return value determines whether to continue traversing upwards.
    return result == true; 
  }
  return false;
}

We can see that the onNotification callback of NotificationListener is ultimately executed in the _dispatch method, and the return value determines whether to continue bubbling upward. The source code implementation is not complex; by reading this code, readers can note a few additional points:

  • The context also provides methods to traverse the Element tree.

  • We can obtain the widget corresponding to an element node using Element.widget. We have repeatedly discussed the relationship between Widgets and Elements, and readers can deepen their understanding through this source code.

61.5 Summary

In Flutter, notification bubbling implements a bottom-up message passing mechanism, which is similar to the event bubbling principle in web development. Web developers can draw analogies to enhance their understanding. Additionally, we explored the flow and principles of Flutter's notification bubbling through the source code, facilitating a deeper comprehension of Flutter's framework design philosophy. Once again, we encourage readers to review source code during their learning process, as it can be immensely beneficial.