blob: 25b3fc921d857cc0531c1675eb4073f258d5d2ed [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/gestures.dart';
class HoverClient extends StatefulWidget {
const HoverClient({
Key key,
this.onHover,
this.child,
this.onEnter,
this.onExit,
}) : super(key: key);
final ValueChanged<bool> onHover;
final Widget child;
final VoidCallback onEnter;
final VoidCallback onExit;
@override
HoverClientState createState() => HoverClientState();
}
class HoverClientState extends State<HoverClient> {
void _onExit(PointerExitEvent details) {
if (widget.onExit != null) {
widget.onExit();
}
if (widget.onHover != null) {
widget.onHover(false);
}
}
void _onEnter(PointerEnterEvent details) {
if (widget.onEnter != null) {
widget.onEnter();
}
if (widget.onHover != null) {
widget.onHover(true);
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: _onEnter,
onExit: _onExit,
child: widget.child,
);
}
}
class HoverFeedback extends StatefulWidget {
const HoverFeedback({Key key, this.onEnter, this.onExit}) : super(key: key);
final VoidCallback onEnter;
final VoidCallback onExit;
@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),
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Text(_hovering ? 'HOVERING' : 'not hovering'),
),
);
}
}
void main() {
testWidgets('detects pointer enter', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
color: const Color.fromARGB(0xff, 0xff, 0x00, 0x00),
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
move = null;
enter = null;
exit = null;
await gesture.moveTo(const Offset(400.0, 300.0));
expect(move, isNotNull);
expect(move.position, equals(const Offset(400.0, 300.0)));
expect(enter, isNotNull);
expect(enter.position, equals(const Offset(400.0, 300.0)));
expect(exit, isNull);
});
testWidgets('detects pointer exiting', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(400.0, 300.0));
await tester.pump();
move = null;
enter = null;
exit = null;
await gesture.moveTo(const Offset(1.0, 1.0));
expect(move, isNull);
expect(enter, isNull);
expect(exit, isNotNull);
expect(exit.position, equals(const Offset(1.0, 1.0)));
});
testWidgets('triggers pointer enter when a mouse is connected', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
await tester.pump();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(400, 300));
addTearDown(gesture.removePointer);
expect(move, isNull);
expect(enter, isNull);
expect(exit, isNull);
await tester.pump();
expect(move, isNull);
expect(enter, isNotNull);
expect(enter.position, equals(const Offset(400.0, 300.0)));
expect(exit, isNull);
});
testWidgets('triggers pointer exit when a mouse is disconnected', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
await tester.pump();
TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(400, 300));
addTearDown(() => gesture?.removePointer);
await tester.pump();
move = null;
enter = null;
exit = null;
await gesture.removePointer();
gesture = null;
expect(move, isNull);
expect(enter, isNull);
expect(exit, isNotNull);
expect(exit.position, equals(const Offset(400.0, 300.0)));
exit = null;
await tester.pump();
expect(move, isNull);
expect(enter, isNull);
expect(exit, isNull);
});
testWidgets('triggers pointer enter when widget appears', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: Container(
width: 100.0,
height: 100.0,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(400.0, 300.0));
await tester.pump();
expect(enter, isNull);
expect(move, isNull);
expect(exit, isNull);
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
await tester.pump();
expect(move, isNull);
expect(enter, isNotNull);
expect(enter.position, equals(const Offset(400.0, 300.0)));
expect(exit, isNull);
});
testWidgets("doesn't trigger pointer exit when widget disappears", (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(400.0, 300.0));
await tester.pump();
move = null;
enter = null;
exit = null;
await tester.pumpWidget(Center(
child: Container(
width: 100.0,
height: 100.0,
),
));
expect(enter, isNull);
expect(move, isNull);
expect(exit, isNull);
});
testWidgets('triggers pointer enter when widget moves in', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Container(
alignment: Alignment.center,
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(1.0, 1.0));
addTearDown(gesture.removePointer);
await tester.pump();
expect(enter, isNull);
expect(move, isNull);
expect(exit, isNull);
await tester.pumpWidget(Container(
alignment: Alignment.topLeft,
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
await tester.pump();
expect(enter, isNotNull);
expect(enter.position, equals(const Offset(1.0, 1.0)));
expect(move, isNull);
expect(exit, isNull);
});
testWidgets('triggers pointer exit when widget moves out', (WidgetTester tester) async {
PointerEnterEvent enter;
PointerHoverEvent move;
PointerExitEvent exit;
await tester.pumpWidget(Container(
alignment: Alignment.center,
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(400, 300));
addTearDown(gesture.removePointer);
await tester.pump();
enter = null;
move = null;
exit = null;
await tester.pumpWidget(Container(
alignment: Alignment.topLeft,
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter = details,
onHover: (PointerHoverEvent details) => move = details,
onExit: (PointerExitEvent details) => exit = details,
),
));
await tester.pump();
expect(enter, isNull);
expect(move, isNull);
expect(exit, isNotNull);
expect(exit.position, equals(const Offset(400, 300)));
});
testWidgets('Hover works with nested listeners', (WidgetTester tester) async {
final UniqueKey key1 = UniqueKey();
final UniqueKey key2 = UniqueKey();
final List<PointerEnterEvent> enter1 = <PointerEnterEvent>[];
final List<PointerHoverEvent> move1 = <PointerHoverEvent>[];
final List<PointerExitEvent> exit1 = <PointerExitEvent>[];
final List<PointerEnterEvent> enter2 = <PointerEnterEvent>[];
final List<PointerHoverEvent> move2 = <PointerHoverEvent>[];
final List<PointerExitEvent> exit2 = <PointerExitEvent>[];
void clearLists() {
enter1.clear();
move1.clear();
exit1.clear();
enter2.clear();
move2.clear();
exit2.clear();
}
await tester.pumpWidget(Container());
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(400.0, 0.0));
await tester.pump();
await tester.pumpWidget(
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
MouseRegion(
onEnter: (PointerEnterEvent details) => enter1.add(details),
onHover: (PointerHoverEvent details) => move1.add(details),
onExit: (PointerExitEvent details) => exit1.add(details),
key: key1,
child: Container(
width: 200,
height: 200,
padding: const EdgeInsets.all(50.0),
child: MouseRegion(
key: key2,
onEnter: (PointerEnterEvent details) => enter2.add(details),
onHover: (PointerHoverEvent details) => move2.add(details),
onExit: (PointerExitEvent details) => exit2.add(details),
child: Container(),
),
),
),
],
),
);
Offset center = tester.getCenter(find.byKey(key2));
await gesture.moveTo(center);
await tester.pump();
expect(move2, isNotEmpty);
expect(enter2, isNotEmpty);
expect(exit2, isEmpty);
expect(move1, isNotEmpty);
expect(move1.last.position, equals(center));
expect(enter1, isNotEmpty);
expect(enter1.last.position, equals(center));
expect(exit1, isEmpty);
clearLists();
// Now make sure that exiting the child only triggers the child exit, not
// the parent too.
center = center - const Offset(75.0, 0.0);
await gesture.moveTo(center);
await tester.pumpAndSettle();
expect(move2, isEmpty);
expect(enter2, isEmpty);
expect(exit2, isNotEmpty);
expect(move1, isNotEmpty);
expect(move1.last.position, equals(center));
expect(enter1, isEmpty);
expect(exit1, isEmpty);
clearLists();
});
testWidgets('Hover transfers between two listeners', (WidgetTester tester) async {
final UniqueKey key1 = UniqueKey();
final UniqueKey key2 = UniqueKey();
final List<PointerEnterEvent> enter1 = <PointerEnterEvent>[];
final List<PointerHoverEvent> move1 = <PointerHoverEvent>[];
final List<PointerExitEvent> exit1 = <PointerExitEvent>[];
final List<PointerEnterEvent> enter2 = <PointerEnterEvent>[];
final List<PointerHoverEvent> move2 = <PointerHoverEvent>[];
final List<PointerExitEvent> exit2 = <PointerExitEvent>[];
void clearLists() {
enter1.clear();
move1.clear();
exit1.clear();
enter2.clear();
move2.clear();
exit2.clear();
}
await tester.pumpWidget(Container());
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(400.0, 0.0));
await tester.pump();
await tester.pumpWidget(
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
MouseRegion(
key: key1,
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter1.add(details),
onHover: (PointerHoverEvent details) => move1.add(details),
onExit: (PointerExitEvent details) => exit1.add(details),
),
MouseRegion(
key: key2,
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) => enter2.add(details),
onHover: (PointerHoverEvent details) => move2.add(details),
onExit: (PointerExitEvent details) => exit2.add(details),
),
],
),
);
final Offset center1 = tester.getCenter(find.byKey(key1));
final Offset center2 = tester.getCenter(find.byKey(key2));
await gesture.moveTo(center1);
await tester.pump();
expect(move1, isNotEmpty);
expect(move1.last.position, equals(center1));
expect(enter1, isNotEmpty);
expect(enter1.last.position, equals(center1));
expect(exit1, isEmpty);
expect(move2, isEmpty);
expect(enter2, isEmpty);
expect(exit2, isEmpty);
clearLists();
await gesture.moveTo(center2);
await tester.pump();
expect(move1, isEmpty);
expect(enter1, isEmpty);
expect(exit1, isNotEmpty);
expect(exit1.last.position, equals(center2));
expect(move2, isNotEmpty);
expect(move2.last.position, equals(center2));
expect(enter2, isNotEmpty);
expect(enter2.last.position, equals(center2));
expect(exit2, isEmpty);
clearLists();
await gesture.moveTo(const Offset(400.0, 450.0));
await tester.pump();
expect(move1, isEmpty);
expect(enter1, isEmpty);
expect(exit1, isEmpty);
expect(move2, isEmpty);
expect(enter2, isEmpty);
expect(exit2, isNotEmpty);
expect(exit2.last.position, equals(const Offset(400.0, 450.0)));
clearLists();
await tester.pumpWidget(Container());
expect(move1, isEmpty);
expect(enter1, isEmpty);
expect(exit1, isEmpty);
expect(move2, isEmpty);
expect(enter2, isEmpty);
expect(exit2, isEmpty);
});
testWidgets('MouseRegion uses updated callbacks', (WidgetTester tester) async {
final List<String> logs = <String>[];
Widget hoverableContainer({
PointerEnterEventListener onEnter,
PointerHoverEventListener onHover,
PointerExitEventListener onExit,
}) {
return Container(
alignment: Alignment.topLeft,
child: MouseRegion(
child: Container(
color: const Color.fromARGB(0xff, 0xff, 0x00, 0x00),
width: 100.0,
height: 100.0,
),
onEnter: onEnter,
onHover: onHover,
onExit: onExit,
),
);
}
await tester.pumpWidget(hoverableContainer(
onEnter: (PointerEnterEvent details) => logs.add('enter1'),
onHover: (PointerHoverEvent details) => logs.add('hover1'),
onExit: (PointerExitEvent details) => logs.add('exit1'),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
// Start outside, move inside, then move outside
await gesture.moveTo(const Offset(150.0, 150.0));
await tester.pump();
await gesture.moveTo(const Offset(50.0, 50.0));
await tester.pump();
await gesture.moveTo(const Offset(150.0, 150.0));
await tester.pump();
expect(logs, <String>['enter1', 'hover1', 'exit1']);
logs.clear();
// Same tests but with updated callbacks
await tester.pumpWidget(hoverableContainer(
onEnter: (PointerEnterEvent details) => logs.add('enter2'),
onHover: (PointerHoverEvent details) => logs.add('hover2'),
onExit: (PointerExitEvent details) => logs.add('exit2'),
));
await gesture.moveTo(const Offset(150.0, 150.0));
await tester.pump();
await gesture.moveTo(const Offset(50.0, 50.0));
await tester.pump();
await gesture.moveTo(const Offset(150.0, 150.0));
await tester.pump();
expect(logs, <String>['enter2', 'hover2', 'exit2']);
});
testWidgets('needsCompositing set when parent class needsCompositing is set', (WidgetTester tester) async {
await tester.pumpWidget(
MouseRegion(
onEnter: (PointerEnterEvent _) {},
child: const Opacity(opacity: 0.5, child: Placeholder()),
),
);
RenderMouseRegion listener = tester.renderObject(find.byType(MouseRegion).first);
expect(listener.needsCompositing, isTrue);
await tester.pumpWidget(
MouseRegion(
onEnter: (PointerEnterEvent _) {},
child: const Placeholder(),
),
);
listener = tester.renderObject(find.byType(MouseRegion).first);
expect(listener.needsCompositing, isFalse);
});
testWidgets('works with transform', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/31986.
final Key key = UniqueKey();
const double scaleFactor = 2.0;
const double localWidth = 150.0;
const double localHeight = 100.0;
final List<PointerEvent> events = <PointerEvent>[];
await tester.pumpWidget(
MaterialApp(
home: Center(
child: Transform.scale(
scale: scaleFactor,
child: MouseRegion(
onEnter: (PointerEnterEvent event) {
events.add(event);
},
onHover: (PointerHoverEvent event) {
events.add(event);
},
onExit: (PointerExitEvent event) {
events.add(event);
},
child: Container(
key: key,
color: Colors.blue,
height: localHeight,
width: localWidth,
child: const Text('Hi'),
),
),
),
),
),
);
final Offset topLeft = tester.getTopLeft(find.byKey(key));
final Offset topRight = tester.getTopRight(find.byKey(key));
final Offset bottomLeft = tester.getBottomLeft(find.byKey(key));
expect(topRight.dx - topLeft.dx, scaleFactor * localWidth);
expect(bottomLeft.dy - topLeft.dy, scaleFactor * localHeight);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(topLeft - const Offset(1, 1));
await tester.pump();
expect(events, isEmpty);
await gesture.moveTo(topLeft + const Offset(1, 1));
await tester.pump();
expect(events, hasLength(2));
expect(events.first, isA<PointerEnterEvent>());
expect(events.last, isA<PointerHoverEvent>());
events.clear();
await gesture.moveTo(bottomLeft + const Offset(1, -1));
await tester.pump();
expect(events.single, isA<PointerHoverEvent>());
expect(events.single.delta, const Offset(0.0, scaleFactor * localHeight - 2));
events.clear();
await gesture.moveTo(bottomLeft + const Offset(1, 1));
await tester.pump();
expect(events.single, isA<PointerExitEvent>());
events.clear();
});
testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async {
// Pretend that we have a mouse connected.
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await tester.pumpWidget(
Transform.scale(
scale: 2.0,
child: const MouseRegion(opaque: false),
),
);
final RenderMouseRegion mouseRegion = tester.renderObject(find.byType(MouseRegion));
expect(mouseRegion.needsCompositing, isFalse);
// No TransformLayer for `Transform.scale` is added because composting is
// not required and therefore the transform is executed on the canvas
// directly. (One TransformLayer is always present for the root
// transform.)
expect(tester.layers.whereType<TransformLayer>(), hasLength(1));
// Test that needsCompositing updates correctly with callback change
await tester.pumpWidget(
Transform.scale(
scale: 2.0,
child: MouseRegion(
opaque: false,
onHover: (PointerHoverEvent _) {},
),
),
);
expect(mouseRegion.needsCompositing, isTrue);
// Compositing is required, therefore a dedicated TransformLayer for
// `Transform.scale` is added.
expect(tester.layers.whereType<TransformLayer>(), hasLength(2));
await tester.pumpWidget(
Transform.scale(
scale: 2.0,
child: const MouseRegion(opaque: false),
),
);
expect(mouseRegion.needsCompositing, isFalse);
// TransformLayer for `Transform.scale` is removed again as transform is
// executed directly on the canvas.
expect(tester.layers.whereType<TransformLayer>(), hasLength(1));
// Test that needsCompositing updates correctly with `opaque` change
await tester.pumpWidget(
Transform.scale(
scale: 2.0,
child: const MouseRegion(
opaque: true,
),
),
);
expect(mouseRegion.needsCompositing, isTrue);
// Compositing is required, therefore a dedicated TransformLayer for
// `Transform.scale` is added.
expect(tester.layers.whereType<TransformLayer>(), hasLength(2));
});
testWidgets("Callbacks aren't called during build", (WidgetTester tester) async {
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: Offset.zero);
int numEntrances = 0;
int numExits = 0;
await tester.pumpWidget(
Center(
child: HoverFeedback(
onEnter: () { numEntrances += 1; },
onExit: () { numExits += 1; },
)),
);
await gesture.moveTo(tester.getCenter(find.byType(Text)));
await tester.pumpAndSettle();
expect(numEntrances, equals(1));
expect(numExits, equals(0));
expect(find.text('HOVERING'), findsOneWidget);
await tester.pumpWidget(
Container(),
);
await tester.pump();
expect(numEntrances, equals(1));
expect(numExits, equals(0));
await tester.pumpWidget(
Center(
child: HoverFeedback(
onEnter: () { numEntrances += 1; },
onExit: () { numExits += 1; },
)),
);
await tester.pump();
expect(numEntrances, equals(2));
expect(numExits, equals(0));
});
testWidgets("MouseRegion 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();
addTearDown(gesture.removePointer);
int numEntrances = 0;
int numExits = 0;
await tester.pumpWidget(
Center(
child: HoverFeedback(
key: feedbackKey,
onEnter: () { numEntrances += 1; },
onExit: () { numExits += 1; },
)),
);
await gesture.moveTo(tester.getCenter(find.byType(Text)));
await tester.pumpAndSettle();
expect(numEntrances, equals(1));
expect(numExits, equals(0));
expect(find.text('HOVERING'), findsOneWidget);
await tester.pumpWidget(
Center(
child: Container(
child: HoverFeedback(
key: feedbackKey,
onEnter: () { numEntrances += 1; },
onExit: () { numExits += 1; },
),
),
),
);
await tester.pump();
expect(numEntrances, equals(1));
expect(numExits, equals(0));
await tester.pumpWidget(
Container(),
);
await tester.pump();
expect(numEntrances, equals(1));
expect(numExits, equals(0));
});
testWidgets('Exit event when unplugging mouse should have a position', (WidgetTester tester) async {
final List<PointerEnterEvent> enter = <PointerEnterEvent>[];
final List<PointerHoverEvent> hover = <PointerHoverEvent>[];
final List<PointerExitEvent> exit = <PointerExitEvent>[];
await tester.pumpWidget(
Center(
child: MouseRegion(
onEnter: (PointerEnterEvent e) => enter.add(e),
onHover: (PointerHoverEvent e) => hover.add(e),
onExit: (PointerExitEvent e) => exit.add(e),
child: Container(
height: 100.0,
width: 100.0,
),
),
),
);
// Plug-in a mouse and move it to the center of the container.
TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(() => gesture?.removePointer());
await tester.pumpAndSettle();
await gesture.moveTo(tester.getCenter(find.byType(Container)));
expect(enter.length, 1);
expect(enter.single.position, const Offset(400.0, 300.0));
expect(hover.length, 1);
expect(hover.single.position, const Offset(400.0, 300.0));
expect(exit.length, 0);
enter.clear();
hover.clear();
exit.clear();
// Unplug the mouse.
await gesture.removePointer();
gesture = null;
await tester.pumpAndSettle();
expect(enter.length, 0);
expect(hover.length, 0);
expect(exit.length, 1);
expect(exit.single.position, const Offset(400.0, 300.0));
expect(exit.single.delta, Offset.zero);
});
testWidgets('detects pointer enter with closure arguments', (WidgetTester tester) async {
await tester.pumpWidget(_HoverClientWithClosures());
expect(find.text('not hovering'), findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer();
// Move to a position out of MouseRegion
await gesture.moveTo(tester.getBottomRight(find.byType(MouseRegion)) + const Offset(10, -10));
await tester.pumpAndSettle();
expect(find.text('not hovering'), findsOneWidget);
// Move into MouseRegion
await gesture.moveBy(const Offset(-20, 0));
await tester.pumpAndSettle();
expect(find.text('HOVERING'), findsOneWidget);
});
testWidgets('MouseRegion paints child once and only once when MouseRegion is inactive', (WidgetTester tester) async {
int paintCount = 0;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MouseRegion(
onEnter: (PointerEnterEvent e) {},
child: CustomPaint(
painter: _DelegatedPainter(onPaint: () { paintCount += 1; }),
child: const Text('123'),
),
),
),
);
expect(paintCount, 1);
});
testWidgets('MouseRegion paints child once and only once when MouseRegion is active', (WidgetTester tester) async {
int paintCount = 0;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MouseRegion(
onEnter: (PointerEnterEvent e) {},
child: CustomPaint(
painter: _DelegatedPainter(onPaint: () { paintCount += 1; }),
child: const Text('123'),
),
),
),
);
expect(paintCount, 1);
});
testWidgets('A MouseRegion mounted under the pointer should should take effect in the next postframe', (WidgetTester tester) async {
bool hovered = false;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover outer'), findsOneWidget);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
HoverClient(
onHover: (bool value) { setState(() { hovered = value; }); },
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover outer'), findsOneWidget);
expect(find.text('unhover inner'), findsOneWidget);
await tester.pump();
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('A MouseRegion unmounted under the pointer should not trigger state change', (WidgetTester tester) async {
bool hovered = true;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
HoverClient(
onHover: (bool value) { setState(() { hovered = value; }); },
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget> [
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('hover outer'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('A MouseRegion moved into the mouse should take effect in the next postframe', (WidgetTester tester) async {
bool hovered = false;
final List<bool> logHovered = <bool>[];
bool moved = false;
StateSetter mySetState;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
mySetState = setState;
return _ColumnContainer(
children: <Widget>[
Container(
height: 100,
width: 10,
alignment: moved ? Alignment.topLeft : Alignment.bottomLeft,
child: Container(
height: 10,
width: 10,
child: HoverClient(
onHover: (bool value) {
setState(() { hovered = value; });
logHovered.add(value);
},
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover inner'), findsOneWidget);
expect(find.text('unhover outer'), findsOneWidget);
expect(logHovered, isEmpty);
expect(tester.binding.hasScheduledFrame, isFalse);
mySetState(() { moved = true; });
// The first frame is for the widget movement to take effect.
await tester.pump();
expect(find.text('unhover inner'), findsOneWidget);
expect(find.text('unhover outer'), findsOneWidget);
expect(logHovered, <bool>[true]);
logHovered.clear();
// The second frame is for the mouse hover to take effect.
await tester.pump();
expect(find.text('hover inner'), findsOneWidget);
expect(find.text('hover outer'), findsOneWidget);
expect(logHovered, isEmpty);
expect(tester.binding.hasScheduledFrame, isFalse);
});
group('MouseRegion respects opacity:', () {
// A widget that contains 3 MouseRegions:
// y
// —————————————————————— 0
// | ——————————— A | 20
// | | B | |
// | | ——————————— | 50
// | | | C | |
// | ——————| | | 100
// | | | |
// | ——————————— | 130
// —————————————————————— 150
// x 0 20 50 100 130 150
Widget tripleRegions({bool opaqueC, void Function(String) addLog}) {
// Same as MouseRegion, but when opaque is null, use the default value.
Widget mouseRegionWithOptionalOpaque({
void Function(PointerEnterEvent e) onEnter,
void Function(PointerExitEvent e) onExit,
Widget child,
bool opaque,
}) {
if (opaque == null) {
return MouseRegion(onEnter: onEnter, onExit: onExit, child: child);
}
return MouseRegion(onEnter: onEnter, onExit: onExit, child: child, opaque: opaque);
}
return Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: MouseRegion(
onEnter: (PointerEnterEvent e) { addLog('enterA'); },
onExit: (PointerExitEvent e) { addLog('exitA'); },
child: SizedBox(
width: 150,
height: 150,
child: Stack(
children: <Widget>[
Positioned(
left: 20,
top: 20,
width: 80,
height: 80,
child: MouseRegion(
onEnter: (PointerEnterEvent e) { addLog('enterB'); },
onExit: (PointerExitEvent e) { addLog('exitB'); },
),
),
Positioned(
left: 50,
top: 50,
width: 80,
height: 80,
child: mouseRegionWithOptionalOpaque(
opaque: opaqueC,
onEnter: (PointerEnterEvent e) { addLog('enterC'); },
onExit: (PointerExitEvent e) { addLog('exitC'); },
),
),
],
),
),
),
),
);
}
testWidgets('a transparent one should allow MouseRegions behind it to receive pointers', (WidgetTester tester) async {
final List<String> logs = <String>[];
await tester.pumpWidget(tripleRegions(
opaqueC: false,
addLog: (String log) => logs.add(log),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
// Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterB', 'enterC']);
logs.clear();
// Move to the B only area.
await gesture.moveTo(const Offset(25, 75));
await tester.pumpAndSettle();
expect(logs, <String>['exitC']);
logs.clear();
// Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['enterC']);
logs.clear();
// Move to the C only area.
await gesture.moveTo(const Offset(125, 75));
await tester.pumpAndSettle();
expect(logs, <String>['exitB']);
logs.clear();
// Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['enterB']);
logs.clear();
// Move out.
await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitB', 'exitA']);
});
testWidgets('an opaque one should prevent MouseRegions behind it receiving pointers', (WidgetTester tester) async {
final List<String> logs = <String>[];
await tester.pumpWidget(tripleRegions(
opaqueC: true,
addLog: (String log) => logs.add(log),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
// Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterC']);
logs.clear();
// Move to the B only area.
await gesture.moveTo(const Offset(25, 75));
await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'enterB']);
logs.clear();
// Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['exitB', 'enterC']);
logs.clear();
// Move to the C only area.
await gesture.moveTo(const Offset(125, 75));
await tester.pumpAndSettle();
expect(logs, <String>[]);
logs.clear();
// Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>[]);
logs.clear();
// Move out.
await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitA']);
});
testWidgets('opaque should default to true', (WidgetTester tester) async {
final List<String> logs = <String>[];
await tester.pumpWidget(tripleRegions(
opaqueC: null,
addLog: (String log) => logs.add(log),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
// Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterC']);
logs.clear();
// Move out.
await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitA']);
});
});
testWidgets('an empty opaque MouseRegion is effective', (WidgetTester tester) async {
bool bottomRegionIsHovered = false;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
Align(
alignment: Alignment.topLeft,
child: MouseRegion(
onEnter: (_) { bottomRegionIsHovered = true; },
onHover: (_) { bottomRegionIsHovered = true; },
onExit: (_) { bottomRegionIsHovered = true; },
child: Container(
width: 10,
height: 10,
),
),
),
const MouseRegion(opaque: true),
],
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(20, 20));
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(5, 5));
await tester.pump();
await gesture.moveTo(const Offset(20, 20));
await tester.pump();
expect(bottomRegionIsHovered, isFalse);
});
testWidgets("Changing MouseRegion's callbacks is effective and doesn't repaint", (WidgetTester tester) async {
final List<String> logs = <String>[];
const Key key = ValueKey<int>(1);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(20, 20));
addTearDown(gesture.removePointer);
await tester.pumpWidget(_Scaffold(
topLeft: Container(
height: 10,
width: 10,
child: MouseRegion(
onEnter: (_) { logs.add('enter1'); },
onHover: (_) { logs.add('hover1'); },
onExit: (_) { logs.add('exit1'); },
child: CustomPaint(
painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key),
),
),
),
));
expect(logs, <String>['paint']);
logs.clear();
await gesture.moveTo(const Offset(5, 5));
expect(logs, <String>['enter1', 'hover1']);
logs.clear();
await tester.pumpWidget(_Scaffold(
topLeft: Container(
height: 10,
width: 10,
child: MouseRegion(
onEnter: (_) { logs.add('enter2'); },
onHover: (_) { logs.add('hover2'); },
onExit: (_) { logs.add('exit2'); },
child: CustomPaint(
painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key),
),
),
),
));
expect(logs, isEmpty);
await gesture.moveTo(const Offset(6, 6));
expect(logs, <String>['hover2']);
logs.clear();
// Compare: It repaints if the MouseRegion is unactivated.
await tester.pumpWidget(_Scaffold(
topLeft: Container(
height: 10,
width: 10,
child: MouseRegion(
opaque: false,
child: CustomPaint(
painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key),
),
),
),
));
expect(logs, <String>['paint']);
});
testWidgets('Changing MouseRegion.opaque is effective and repaints', (WidgetTester tester) async {
final List<String> logs = <String>[];
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
final PointerHoverEventListener onHover = (_) {};
final VoidCallback onPaintChild = () { logs.add('paint'); };
await tester.pumpWidget(_Scaffold(
topLeft: Container(
height: 10,
width: 10,
child: MouseRegion(
opaque: true,
// Dummy callback so that MouseRegion stays affective after opaque
// turns false.
onHover: onHover,
child: CustomPaint(painter: _DelegatedPainter(onPaint: onPaintChild)),
),
),
background: MouseRegion(onEnter: (_) { logs.add('hover-enter'); })
));
expect(logs, <String>['paint']);
logs.clear();
expect(logs, isEmpty);
logs.clear();
await tester.pumpWidget(_Scaffold(
topLeft: Container(
height: 10,
width: 10,
child: MouseRegion(
opaque: false,
onHover: onHover,
child: CustomPaint(painter: _DelegatedPainter(onPaint: onPaintChild)),
),
),
background: MouseRegion(onEnter: (_) { logs.add('hover-enter'); })
));
expect(logs, <String>['paint', 'hover-enter']);
});
testWidgets("RenderMouseRegion's debugFillProperties when default", (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
RenderMouseRegion().debugFillProperties(builder);
final List<String> description = builder.properties.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode node) => node.toString()).toList();
expect(description, <String>[
'parentData: MISSING',
'constraints: MISSING',
'size: MISSING',
'listeners: <none>',
]);
});
testWidgets("RenderMouseRegion's debugFillProperties when full", (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
RenderMouseRegion(
onEnter: (PointerEnterEvent event) {},
onExit: (PointerExitEvent event) {},
onHover: (PointerHoverEvent event) {},
child: RenderErrorBox(),
).debugFillProperties(builder);
final List<String> description = builder.properties.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode node) => node.toString()).toList();
expect(description, <String>[
'parentData: MISSING',
'constraints: MISSING',
'size: MISSING',
'listeners: enter, hover, exit',
]);
});
testWidgets('No new frames are scheduled when mouse moves without triggering callbacks', (WidgetTester tester) async {
await tester.pumpWidget(Center(
child: MouseRegion(
child: Container(
width: 100.0,
height: 100.0,
),
onEnter: (PointerEnterEvent details) {},
onHover: (PointerHoverEvent details) {},
onExit: (PointerExitEvent details) {},
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(400.0, 300.0));
addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
await gesture.moveBy(const Offset(10.0, 10.0));
expect(tester.binding.hasScheduledFrame, isFalse);
});
}
// Render widget `topLeft` at the top-left corner, stacking on top of the widget
// `background`.
class _Scaffold extends StatelessWidget {
const _Scaffold({this.topLeft, this.background});
final Widget topLeft;
final Widget background;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
if (background != null) background,
Align(
alignment: Alignment.topLeft,
child: topLeft,
),
],
),
);
}
}
class _DelegatedPainter extends CustomPainter {
_DelegatedPainter({this.key, this.onPaint});
final Key key;
final VoidCallback onPaint;
@override
void paint(Canvas canvas, Size size) {
onPaint();
}
@override
bool shouldRepaint(CustomPainter oldDelegate) =>
!(oldDelegate is _DelegatedPainter && key == oldDelegate.key);
}
class _HoverClientWithClosures extends StatefulWidget {
@override
_HoverClientWithClosuresState createState() => _HoverClientWithClosuresState();
}
class _HoverClientWithClosuresState extends State<_HoverClientWithClosures> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: MouseRegion(
onEnter: (PointerEnterEvent _) {
setState(() {
_hovering = true;
});
},
onExit: (PointerExitEvent _) {
setState(() {
_hovering = false;
});
},
child: Text(_hovering ? 'HOVERING' : 'not hovering'),
),
);
}
}
// A column that aligns to the top left.
class _ColumnContainer extends StatelessWidget {
const _ColumnContainer({
@required this.children,
}) : assert(children != null);
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
}