Skip to content

[Breaking Change] MouseRegion.onExit is no longer triggered on widget disposal (among other related changes) #44957

@dkwingsmt

Description

@dkwingsmt

Description

This is a proposal to change the onExit callback of MouseRegion, RenderMouseRegion, and MouseTrackerAnnotation, so that they're no longer triggered when the widget or the render object that owns the annotation is disposed when hovered by a pointer.

There are also some other breaking changes included in the proposed PR, majorly affecting the lifecycle of MouseTracker, which is unlikely to affect most people.

Motivation

Currently onExit is triggered whenever the region stops containing the pointer, which includes when a MouseRegion is unmounted under a pointer. This design has two flaws:

First, it encourages an incorrect design. Calling some setState in onExit in this case is essentially calling setState of a widget during dispose of the same widget, which should be discouraged. Currently a delicate workaround is used to bypass the restriction and avoid throwing an exception, as seen in _MouseRegionElement.activate and deactivate, but we always want to reduce such kind of hacks.

Second, despite the aforementioned complication, it brings little extra value. Most use cases of MouseRegion boils down to the following categories:

  1. The widgets that listens to the MouseRegion always creates the MouseRegion, in which case the owner widget is disposed with the MouseRegion, so the disposal callback is not used.
  2. The widgets that listens to the MouseRegion conditionally creates the MouseRegion, in which case owner widget has a boolean that turns false when the MouseRegion is disposed, therefore the disposal flag is redundant.

Also, this change is part of the change to improve MouseTracker's lifecycle (as described in #44631), which also solves other issues, but the aforementioned motivation alone should be enough for this breaking change.

Detailed changes and migration

The onExit is no longer called on disposal

After the change, onExit will no longer be called when a widget or the render object that owns the annotation is disposed when hovered by a pointer. This will be the only case that onEnter is not matched with onEnter. More specifically, after this change (and other changes), the onExit callback is triggered by the following cases:

  • This widget, which used to contain a pointer, has moved away.
  • A pointer that used to be within this widget has been removed.
  • A pointer that used to be within this widget has moved away.

And is not triggered by the following case,

  • This widget, which used to contain a pointer, has disappeared.

Whether a migration is needed depends on the situation. Define a widget as "mouse region equivalent", if the widget creates all of its enclosing MouseRegions unconditionally. For example,

// MyButton is a mouse region equivalent, because it creates a MouseRegion
// unconditionally.
class MyButton extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _MyButtonState();
}

class _MyButtonState extends State<MyButton> {
  bool hovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) { setState(() { hovered = true; }); }
      onExit: (_) { setState(() { hovered = false; }); }
    );
  }
}

// MyButtonColumn is a mouse region equivalent, because all of its enclosed
// MouseRegions are created unconditionally
class MyButtonColumn extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        MyButton(),
        MyButton(),
      ],
    );
  }
}

If the change of onExit does not go out of a mouse region equivalent, then this widget should not be affected at all, since by the time the MouseRegion is disposed, this widget is disposed too.

If the change of onExit goes out of a mouse region equivalent, then you should go to the stateful widget that conditionally creates the mouse region equivalent, find the condition that determines its presence, and call the callback when this condition turns false. For example,

// MyListenerButton displays a MouseRegion only when `hideButton` is true, and it also
// leaks the exit event to its parent. After this change `onExitButton` will no longer
// be called when the MouseRegion goes hidden while hovered by a pointer, so you
// should call `onExitButton` manually when `hideButton` goes true.
class MyListenerButton extends StatefulWidget {
  MyListenerButton({ this.onExitButton });

  final VoidCallback onExitButton;

  @override
  State<StatefulWidget> createState() => _MyListenerButtonState();
}

class _MyListenerButtonState extends State<MyListenerButton> {
  final bool hideButton = false;
  final bool hovered = false;
  @override
  Widget build(BuildContext context) {
    return MyNetworkListener(
      onRequestedToHideButton: () {
        setState(() {
          hideButton = true;
        });
        // MIGRATE: Add the following statement, because MyListenerButton is not a 
        // mouse region equivalent, and the effect of onExit goes outside.
        // if (hovered)
        //   widget.onExitButton();
      },
      child: hideButton ? null : MouseRegion(
        onEnter: (_) { setState(() { hovered = true; }); }
        onExit: (_) {
          setState(() { hovered = false; });
          widget.onExitButton();
        },
      ),
    );
  }
}

In the unlikely situation that this migration is insufficient or too complicated, you can always extend a widget class and override its dispose.

Lifecycle of MouseTracker

The PR also introduces some other changes to MouseTracker. While some changes are considered bug fixes, the following ones breaks current intended behavior:

  • Callbacks are no longer called during Element.activate and deactivate, but during the post frame calls.
    • This change should not affect widgets, because even though the callbacks are currently called during build, their setState won't take effect until the next frame.
  • MouseTracker.attachAnnotation and detachAnnotation no longer cause immediate collection (device update). They only change state that will be used in the upcoming post frame.

Since MouseTracker is for internal use, these changes are unlikely to affect most developers.

Alternative designs

We've considered leaving a callback that preserves the current behavior of onExit, which would be named onExitOrDispose, or separate the removed behavior into a callback called onDispose. But either way feels like we're encouraging calling setState during dispose, which no one should. Considering the feature that the disposal call can be replaced by other means (as illustrated above), we decided to completely remove the disposal call.


A proposed PR and more details of the MouseTracker lifecycle change can be found at #44631.

If you have any concerns, need help for migration, thinks an option should be provided, or otherwise feel that this change should not be made, please comment on the PR on on this issue.

Metadata

Metadata

Assignees

Labels

a: desktopRunning on desktopf: gesturesflutter/packages/flutter/gestures repository.frameworkflutter/packages/flutter repository. See also f: labels.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions