-
Notifications
You must be signed in to change notification settings - Fork 30.1k
Description
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:
- 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.
- 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.activateanddeactivate, but during the post frame calls.- This change should not affect widgets, because even though the callbacks are currently called during build, their
setStatewon't take effect until the next frame.
- This change should not affect widgets, because even though the callbacks are currently called during build, their
MouseTracker.attachAnnotationanddetachAnnotationno 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.