Unverified Commit 23baae0e authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Fix RenderPointerListener so that callbacks aren't called at the wrong time. (#32142)

I recently added some code to keep hover events from being propagated when a mouse wasn't attached. While that works, there are times when it can fire callbacks during the building of other components, since they can now be called from detach/attach. This is not ideal, since it will assert then. This changes the code so that it won't update the annotations during attach/detach, but also won't push the annotation layer unless a mouse is connected, achieving the same result as before, but with better semantics.

The basic problem is that in the detach for RenderPointerListener, it would detach the annotation, which could cause onExit to be called on the annotation, since the widget was disappearing under the mouse, and thus needs to receive an onExit, but that onExit might be (and probably will be) calling setState, which marks the owning widget as needing to be built, sometimes when it already has been.

The fix creates a new _ListenerElement that overrides activate and deactivate in order to tell the render object ahead of the detach that it might be detached, and so the onExit gets called before the detach instead of during it.

In addition, I now avoid scheduling more than one check for mouse positions per frame.
parent 41d26b96
...@@ -131,12 +131,17 @@ class MouseTracker extends ChangeNotifier { ...@@ -131,12 +131,17 @@ class MouseTracker extends ChangeNotifier {
_trackedAnnotations.remove(annotation); _trackedAnnotations.remove(annotation);
} }
bool _postFrameCheckScheduled = false;
void _scheduleMousePositionCheck() { void _scheduleMousePositionCheck() {
// If we're not tracking anything, then there is no point in registering a // If we're not tracking anything, then there is no point in registering a
// frame callback or scheduling a frame. By definition there are no active // frame callback or scheduling a frame. By definition there are no active
// annotations that need exiting, either. // annotations that need exiting, either.
if (_trackedAnnotations.isNotEmpty) { if (_trackedAnnotations.isNotEmpty && !_postFrameCheckScheduled) {
SchedulerBinding.instance.addPostFrameCallback((Duration _) => collectMousePositions()); _postFrameCheckScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_postFrameCheckScheduled = false;
collectMousePositions();
});
SchedulerBinding.instance.scheduleFrame(); SchedulerBinding.instance.scheduleFrame();
} }
} }
......
...@@ -2571,8 +2571,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2571,8 +2571,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
changed = true; changed = true;
} }
if (RendererBinding.instance.mouseTracker.mouseIsConnected && if (_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null) {
(_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null)) {
_hoverAnnotation = MouseTrackerAnnotation( _hoverAnnotation = MouseTrackerAnnotation(
onEnter: _onPointerEnter, onEnter: _onPointerEnter,
onHover: _onPointerHover, onHover: _onPointerHover,
...@@ -2594,31 +2593,64 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2594,31 +2593,64 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
} }
} }
void _handleMouseTrackerChanged() {
if (attached)
markNeedsPaint();
}
@override @override
void attach(PipelineOwner owner) { void attach(PipelineOwner owner) {
super.attach(owner); super.attach(owner);
// Add a listener to listen for changes in mouseIsConnected. // Add a listener to listen for changes in mouseIsConnected.
RendererBinding.instance.mouseTracker.addListener(_updateAnnotations); RendererBinding.instance.mouseTracker.addListener(_handleMouseTrackerChanged);
postActivate();
}
/// Attaches the annotation for this render object, if any.
///
/// This is called by [attach] to attach and new annotations.
///
/// This is also called by the [Listener]'s [Element] to tell this
/// [RenderPointerListener] that it will shortly be attached. That way,
/// [MouseTrackerAnnotation.onEnter] isn't called during the build step for
/// the widget that provided the callback, and [State.setState] can safely be
/// called within that callback.
void postActivate() {
if (_hoverAnnotation != null) { if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
} }
} }
@override /// Detaches the annotation for this render object, if any.
void detach() { ///
/// This is called by the [Listener]'s [Element] to tell this
/// [RenderPointerListener] that it will shortly be attached. That way,
/// [MouseTrackerAnnotation.onExit] isn't called during the build step for the
/// widget that provided the callback, and [State.setState] can safely be
/// called within that callback.
void preDeactivate() {
if (_hoverAnnotation != null) { if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
} }
RendererBinding.instance.mouseTracker.removeListener(_updateAnnotations); }
@override
void detach() {
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChanged);
super.detach(); super.detach();
} }
bool get _hasActiveAnnotation {
return _hoverAnnotation != null
&& RendererBinding.instance.mouseTracker.mouseIsConnected;
}
@override @override
bool get needsCompositing => _hoverAnnotation != null; bool get needsCompositing => _hasActiveAnnotation;
@override @override
void paint(PaintingContext context, Offset offset) { void paint(PaintingContext context, Offset offset) {
if (_hoverAnnotation != null) { if (_hasActiveAnnotation) {
final AnnotatedRegionLayer<MouseTrackerAnnotation> layer = AnnotatedRegionLayer<MouseTrackerAnnotation>( final AnnotatedRegionLayer<MouseTrackerAnnotation> layer = AnnotatedRegionLayer<MouseTrackerAnnotation>(
_hoverAnnotation, _hoverAnnotation,
size: size, size: size,
......
...@@ -5401,6 +5401,9 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5401,6 +5401,9 @@ class Listener extends SingleChildRenderObjectWidget {
/// How to behave during hit testing. /// How to behave during hit testing.
final HitTestBehavior behavior; final HitTestBehavior behavior;
@override
_ListenerElement createElement() => _ListenerElement(this);
@override @override
RenderPointerListener createRenderObject(BuildContext context) { RenderPointerListener createRenderObject(BuildContext context) {
return RenderPointerListener( return RenderPointerListener(
...@@ -5455,6 +5458,24 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5455,6 +5458,24 @@ class Listener extends SingleChildRenderObjectWidget {
} }
} }
class _ListenerElement extends SingleChildRenderObjectElement {
_ListenerElement(SingleChildRenderObjectWidget widget) : super(widget);
@override
void activate() {
super.activate();
final RenderPointerListener renderPointerListener = renderObject;
renderPointerListener.postActivate();
}
@override
void deactivate() {
final RenderPointerListener renderPointerListener = renderObject;
renderPointerListener.preDeactivate();
super.deactivate();
}
}
/// A widget that creates a separate display list for its child. /// A widget that creates a separate display list for its child.
/// ///
/// This widget creates a separate display list for its child, which /// This widget creates a separate display list for its child, which
......
...@@ -8,6 +8,67 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -8,6 +8,67 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
class HoverClient extends StatefulWidget {
const HoverClient({Key key, this.onHover, this.child}) : super(key: key);
final ValueChanged<bool> onHover;
final Widget child;
@override
HoverClientState createState() => HoverClientState();
}
class HoverClientState extends State<HoverClient> {
static int numEntries = 0;
static int numExits = 0;
void _onExit(PointerExitEvent details) {
numExits++;
if (widget.onHover != null) {
widget.onHover(false);
}
}
void _onEnter(PointerEnterEvent details) {
numEntries++;
if (widget.onHover != null) {
widget.onHover(true);
}
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerEnter: _onEnter,
onPointerExit: _onExit,
child: widget.child,
);
}
}
class HoverFeedback extends StatefulWidget {
HoverFeedback({Key key}) : super(key: key);
@override
_HoverFeedbackState createState() => _HoverFeedbackState();
}
class _HoverFeedbackState extends State<HoverFeedback> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: HoverClient(
onHover: (bool hovering) => setState(() => _hovering = hovering),
child: Text(_hovering ? 'HOVERING' : 'not hovering'),
),
);
}
}
void main() { void main() {
testWidgets('Events bubble up the tree', (WidgetTester tester) async { testWidgets('Events bubble up the tree', (WidgetTester tester) async {
final List<String> log = <String>[]; final List<String> log = <String>[];
...@@ -44,6 +105,11 @@ void main() { ...@@ -44,6 +105,11 @@ void main() {
}); });
group('Listener hover detection', () { group('Listener hover detection', () {
setUp((){
HoverClientState.numExits = 0;
HoverClientState.numEntries = 0;
});
testWidgets('detects pointer enter', (WidgetTester tester) async { testWidgets('detects pointer enter', (WidgetTester tester) async {
PointerEnterEvent enter; PointerEnterEvent enter;
PointerHoverEvent move; PointerHoverEvent move;
...@@ -302,8 +368,8 @@ void main() { ...@@ -302,8 +368,8 @@ void main() {
testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async { testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async {
// Pretend that we have a mouse connected. // Pretend that we have a mouse connected.
final TestGesture gesture = await tester.startGesture(Offset.zero, kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.up(); await gesture.addPointer();
await tester.pumpWidget( await tester.pumpWidget(
Transform.scale( Transform.scale(
...@@ -348,5 +414,63 @@ void main() { ...@@ -348,5 +414,63 @@ void main() {
// executed directly on the canvas. // executed directly on the canvas.
expect(tester.layers.whereType<TransformLayer>(), hasLength(1)); expect(tester.layers.whereType<TransformLayer>(), hasLength(1));
}); });
testWidgets("Callbacks aren't called during build", (WidgetTester tester) async {
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await tester.pumpWidget(
Center(child: HoverFeedback()),
);
await gesture.moveTo(tester.getCenter(find.byType(Text)));
await tester.pumpAndSettle();
expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(0));
expect(find.text('HOVERING'), findsOneWidget);
await tester.pumpWidget(
Container(),
);
await tester.pump();
expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(1));
await tester.pumpWidget(
Center(child: HoverFeedback()),
);
await tester.pump();
expect(HoverClientState.numEntries, equals(2));
expect(HoverClientState.numExits, equals(1));
});
testWidgets("Listener activate/deactivate don't duplicate annotations", (WidgetTester tester) async {
final GlobalKey feedbackKey = GlobalKey();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await tester.pumpWidget(
Center(child: HoverFeedback(key: feedbackKey)),
);
await gesture.moveTo(tester.getCenter(find.byType(Text)));
await tester.pumpAndSettle();
expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(0));
expect(find.text('HOVERING'), findsOneWidget);
await tester.pumpWidget(
Center(child: Container(child: HoverFeedback(key: feedbackKey))),
);
await tester.pump();
expect(HoverClientState.numEntries, equals(2));
expect(HoverClientState.numExits, equals(1));
await tester.pumpWidget(
Container(),
);
await tester.pump();
expect(HoverClientState.numEntries, equals(2));
expect(HoverClientState.numExits, equals(2));
});
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment