Refactor mouse hit testing system: Direct mouse hit test (#59883)

This commit is contained in:
Tong Mu 2020-07-06 17:44:02 -07:00 committed by GitHub
parent 4b12050112
commit 6f4c4b3cf8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 441 additions and 163 deletions

View file

@ -908,7 +908,7 @@ class PointerEnterEvent extends PointerEvent {
down: event?.down, down: event?.down,
synthesized: event?.synthesized, synthesized: event?.synthesized,
transform: event?.transform, transform: event?.transform,
original: event?.original as PointerEnterEvent, original: null,
); );
@override @override
@ -1054,7 +1054,7 @@ class PointerExitEvent extends PointerEvent {
down: event?.down, down: event?.down,
synthesized: event?.synthesized, synthesized: event?.synthesized,
transform: event?.transform, transform: event?.transform,
original: event?.original as PointerExitEvent, original: null,
); );
@override @override

View file

@ -4,8 +4,6 @@
// @dart = 2.8 // @dart = 2.8
import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
@ -73,12 +71,51 @@ class HitTestEntry {
Matrix4 _transform; Matrix4 _transform;
} }
// A type of data that can be applied to a matrix by left-multiplication.
@immutable
abstract class _TransformPart {
const _TransformPart();
// Apply this transform part to `rhs` from the left.
//
// This should work as if this transform part is first converted to a matrix
// and then left-multiplied to `rhs`.
//
// For example, if this transform part is a vector `v1`, whose corresponding
// matrix is `m1 = Matrix4.translation(v1)`, then the result of
// `_VectorTransformPart(v1).multiply(rhs)` should equal to `m1 * rhs`.
Matrix4 multiply(Matrix4 rhs);
}
class _MatrixTransformPart extends _TransformPart {
const _MatrixTransformPart(this.matrix);
final Matrix4 matrix;
@override
Matrix4 multiply(Matrix4 rhs) {
return matrix * rhs as Matrix4;
}
}
class _OffsetTransformPart extends _TransformPart {
const _OffsetTransformPart(this.offset);
final Offset offset;
@override
Matrix4 multiply(Matrix4 rhs) {
return rhs.clone()..leftTranslate(offset.dx, offset.dy);
}
}
/// The result of performing a hit test. /// The result of performing a hit test.
class HitTestResult { class HitTestResult {
/// Creates an empty hit test result. /// Creates an empty hit test result.
HitTestResult() HitTestResult()
: _path = <HitTestEntry>[], : _path = <HitTestEntry>[],
_transforms = Queue<Matrix4>(); _transforms = <Matrix4>[Matrix4.identity()],
_localTransforms = <_TransformPart>[];
/// Wraps `result` (usually a subtype of [HitTestResult]) to create a /// Wraps `result` (usually a subtype of [HitTestResult]) to create a
/// generic [HitTestResult]. /// generic [HitTestResult].
@ -88,7 +125,8 @@ class HitTestResult {
/// structure to store [HitTestEntry]s). /// structure to store [HitTestEntry]s).
HitTestResult.wrap(HitTestResult result) HitTestResult.wrap(HitTestResult result)
: _path = result._path, : _path = result._path,
_transforms = result._transforms; _transforms = result._transforms,
_localTransforms = result._localTransforms;
/// An unmodifiable list of [HitTestEntry] objects recorded during the hit test. /// An unmodifiable list of [HitTestEntry] objects recorded during the hit test.
/// ///
@ -98,7 +136,40 @@ class HitTestResult {
Iterable<HitTestEntry> get path => _path; Iterable<HitTestEntry> get path => _path;
final List<HitTestEntry> _path; final List<HitTestEntry> _path;
final Queue<Matrix4> _transforms; // A stack of transform parts.
//
// The transform part stack leading from global to the current object is stored
// in 2 parts:
//
// * `_transforms` are globalized matrices, meaning they have been multiplied
// by the ancestors and are thus relative to the global coordinate space.
// * `_localTransforms` are local transform parts, which are relative to the
// parent's coordinate space.
//
// When new transform parts are added they're appended to `_localTransforms`,
// and are converted to global ones and moved to `_transforms` only when used.
final List<Matrix4> _transforms;
final List<_TransformPart> _localTransforms;
// Globalize all transform parts in `_localTransforms` and move them to
// _transforms.
void _globalizeTransforms() {
if (_localTransforms.isEmpty) {
return;
}
Matrix4 last = _transforms.last;
for (final _TransformPart part in _localTransforms) {
last = part.multiply(last);
_transforms.add(last);
}
_localTransforms.clear();
}
Matrix4 get _lastTransform {
_globalizeTransforms();
assert(_localTransforms.isEmpty);
return _transforms.last;
}
/// Add a [HitTestEntry] to the path. /// Add a [HitTestEntry] to the path.
/// ///
@ -107,7 +178,7 @@ class HitTestResult {
/// upward walk of the tree being hit tested. /// upward walk of the tree being hit tested.
void add(HitTestEntry entry) { void add(HitTestEntry entry) {
assert(entry._transform == null); assert(entry._transform == null);
entry._transform = _transforms.isEmpty ? null : _transforms.last; entry._transform = _lastTransform;
_path.add(entry); _path.add(entry);
} }
@ -125,6 +196,9 @@ class HitTestResult {
/// through [PointerEvent.removePerspectiveTransform] to remove /// through [PointerEvent.removePerspectiveTransform] to remove
/// the perspective component. /// the perspective component.
/// ///
/// If the provided `transform` is a translation matrix, it is much faster
/// to use [pushOffset] with the translation offset instead.
///
/// [HitTestable]s need to call this method indirectly through a convenience /// [HitTestable]s need to call this method indirectly through a convenience
/// method defined on a subclass before hit testing a child that does not /// method defined on a subclass before hit testing a child that does not
/// have the same origin as the parent. After hit testing the child, /// have the same origin as the parent. After hit testing the child,
@ -132,10 +206,10 @@ class HitTestResult {
/// ///
/// See also: /// See also:
/// ///
/// * [pushOffset], which is similar to [pushTransform] but is limited to
/// translations, and is faster in such cases.
/// * [BoxHitTestResult.addWithPaintTransform], which is a public wrapper /// * [BoxHitTestResult.addWithPaintTransform], which is a public wrapper
/// around this function for hit testing on [RenderBox]s. /// around this function for hit testing on [RenderBox]s.
/// * [SliverHitTestResult.addWithAxisOffset], which is a public wrapper
/// around this function for hit testing on [RenderSliver]s.
@protected @protected
void pushTransform(Matrix4 transform) { void pushTransform(Matrix4 transform) {
assert(transform != null); assert(transform != null);
@ -148,26 +222,60 @@ class HitTestResult {
'matrix through PointerEvent.removePerspectiveTransform? ' 'matrix through PointerEvent.removePerspectiveTransform? '
'The provided matrix is:\n$transform' 'The provided matrix is:\n$transform'
); );
_transforms.add(_transforms.isEmpty ? transform : (transform * _transforms.last as Matrix4)); _localTransforms.add(_MatrixTransformPart(transform));
} }
/// Removes the last transform added via [pushTransform]. /// Pushes a new translation offset that is to be applied to all future
/// [HitTestEntry]s added via [add] until it is removed via [popTransform].
///
/// This method is only to be used by subclasses, which must provide
/// coordinate space specific public wrappers around this function for their
/// users (see [BoxHitTestResult.addWithPaintOffset] for such an example).
///
/// The provided `offset` should describe how to transform [PointerEvent]s from
/// the coordinate space of the method caller to the coordinate space of its
/// children. Usually `offset` is the inverse of the offset of the child
/// relative to the parent.
///
/// [HitTestable]s need to call this method indirectly through a convenience
/// method defined on a subclass before hit testing a child that does not
/// have the same origin as the parent. After hit testing the child,
/// [popTransform] has to be called to remove the child-specific `transform`.
///
/// See also:
///
/// * [pushTransform], which is similar to [pushOffset] but allows general
/// transform besides translation.
/// * [BoxHitTestResult.addWithPaintOffset], which is a public wrapper
/// around this function for hit testing on [RenderBox]s.
/// * [SliverHitTestResult.addWithAxisOffset], which is a public wrapper
/// around this function for hit testing on [RenderSliver]s.
@protected
void pushOffset(Offset offset) {
assert(offset != null);
_localTransforms.add(_OffsetTransformPart(offset));
}
/// Removes the last transform added via [pushTransform] or [pushOffset].
/// ///
/// This method is only to be used by subclasses, which must provide /// This method is only to be used by subclasses, which must provide
/// coordinate space specific public wrappers around this function for their /// coordinate space specific public wrappers around this function for their
/// users (see [BoxHitTestResult.addWithPaintTransform] for such an example). /// users (see [BoxHitTestResult.addWithPaintTransform] for such an example).
/// ///
/// This method must be called after hit testing is done on a child that /// This method must be called after hit testing is done on a child that
/// required a call to [pushTransform]. /// required a call to [pushTransform] or [pushOffset].
/// ///
/// See also: /// See also:
/// ///
/// * [pushTransform], which describes the use case of this function pair in /// * [pushTransform] and [pushOffset], which describes the use case of this
/// more details. /// function pair in more details.
@protected @protected
void popTransform() { void popTransform() {
if (_localTransforms.isNotEmpty)
_localTransforms.removeLast();
else
_transforms.removeLast();
assert(_transforms.isNotEmpty); assert(_transforms.isNotEmpty);
_transforms.removeLast();
} }
bool _debugVectorMoreOrLessEquals(Vector4 a, Vector4 b, { double epsilon = precisionErrorTolerance }) { bool _debugVectorMoreOrLessEquals(Vector4 a, Vector4 b, { double epsilon = precisionErrorTolerance }) {

View file

@ -1148,12 +1148,12 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
}, },
); );
return IgnorePointer( return MouseRegion(
ignoring: !_isEnabled, cursor: effectiveMouseCursor,
child: MouseRegion( onEnter: (PointerEnterEvent event) => _handleHover(true),
cursor: effectiveMouseCursor, onExit: (PointerExitEvent event) => _handleHover(false),
onEnter: (PointerEnterEvent event) => _handleHover(true), child: IgnorePointer(
onExit: (PointerExitEvent event) => _handleHover(false), ignoring: !_isEnabled,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: controller, // changes the _currentLength animation: controller, // changes the _currentLength
builder: (BuildContext context, Widget child) { builder: (BuildContext context, Widget child) {

View file

@ -764,11 +764,17 @@ class BoxHitTestResult extends HitTestResult {
@required BoxHitTest hitTest, @required BoxHitTest hitTest,
}) { }) {
assert(hitTest != null); assert(hitTest != null);
return addWithRawTransform( final Offset transformedPosition = position == null || offset == null
transform: offset != null ? Matrix4.translationValues(-offset.dx, -offset.dy, 0.0) : null, ? position
position: position, : position - offset;
hitTest: hitTest, if (offset != null) {
); pushOffset(-offset);
}
final bool isHit = hitTest(this, transformedPosition);
if (offset != null) {
popTransform();
}
return isHit;
} }
/// Transforms `position` to the local coordinate system of a child for /// Transforms `position` to the local coordinate system of a child for

View file

@ -14,7 +14,6 @@ import 'package:flutter/painting.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
import 'debug.dart'; import 'debug.dart';
import 'mouse_tracking.dart';
/// Information collected for an annotation that is found in the layer tree. /// Information collected for an annotation that is found in the layer tree.
/// ///
@ -633,7 +632,6 @@ class PlatformViewLayer extends Layer {
PlatformViewLayer({ PlatformViewLayer({
@required this.rect, @required this.rect,
@required this.viewId, @required this.viewId,
this.hoverAnnotation,
}) : assert(rect != null), }) : assert(rect != null),
assert(viewId != null); assert(viewId != null);
@ -645,25 +643,6 @@ class PlatformViewLayer extends Layer {
/// A UIView with this identifier must have been created by [PlatformViewsServices.initUiKitView]. /// A UIView with this identifier must have been created by [PlatformViewsServices.initUiKitView].
final int viewId; final int viewId;
/// [MouseTrackerAnnotation] that handles mouse events for this layer.
///
/// If [hoverAnnotation] is non-null, [PlatformViewLayer] will annotate the
/// region of this platform view such that annotation callbacks will receive
/// mouse events, including mouse enter, exit, and hover, but not including
/// mouse down, move, and up. The layer will be treated as opaque during an
/// annotation search, which will prevent layers behind it from receiving
/// these events.
///
/// By default, [hoverAnnotation] is null, and [PlatformViewLayer] will not
/// receive mouse events, and will therefore appear translucent during the
/// annotation search.
///
/// See also:
///
/// * [MouseRegion], which explains more about the mouse events and opacity
/// during annotation search.
final MouseTrackerAnnotation hoverAnnotation;
@override @override
void addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { void addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) {
final Rect shiftedRect = layerOffset == Offset.zero ? rect : rect.shift(layerOffset); final Rect shiftedRect = layerOffset == Offset.zero ? rect : rect.shift(layerOffset);
@ -674,24 +653,6 @@ class PlatformViewLayer extends Layer {
height: shiftedRect.height, height: shiftedRect.height,
); );
} }
@override
@protected
bool findAnnotations<S>(AnnotationResult<S> result, Offset localPosition, { @required bool onlyFirst }) {
if (hoverAnnotation == null || !rect.contains(localPosition)) {
return false;
}
if (S == MouseTrackerAnnotation) {
final Object untypedValue = hoverAnnotation;
final S typedValue = untypedValue as S;
result.add(AnnotationEntry<S>(
annotation: typedValue,
localPosition: localPosition,
));
return true;
}
return false;
}
} }
/// A layer that indicates to the compositor that it should display /// A layer that indicates to the compositor that it should display

View file

@ -622,7 +622,6 @@ class PlatformViewRenderBox extends RenderBox with _PlatformViewGestureMixin {
context.addLayer(PlatformViewLayer( context.addLayer(PlatformViewLayer(
rect: offset & size, rect: offset & size,
viewId: _controller.viewId, viewId: _controller.viewId,
hoverAnnotation: _hoverAnnotation,
)); ));
} }
@ -636,32 +635,13 @@ class PlatformViewRenderBox extends RenderBox with _PlatformViewGestureMixin {
} }
/// The Mixin handling the pointer events and gestures of a platform view render box. /// The Mixin handling the pointer events and gestures of a platform view render box.
mixin _PlatformViewGestureMixin on RenderBox { mixin _PlatformViewGestureMixin on RenderBox implements MouseTrackerAnnotation {
/// How to behave during hit testing. /// How to behave during hit testing.
// The implicit setter is enough here as changing this value will just affect // The implicit setter is enough here as changing this value will just affect
// any newly arriving events there's nothing we need to invalidate. // any newly arriving events there's nothing we need to invalidate.
PlatformViewHitTestBehavior hitTestBehavior; PlatformViewHitTestBehavior hitTestBehavior;
/// [MouseTrackerAnnotation] associated with the platform view layer.
///
/// Gesture recognizers don't receive hover events due to the performance
/// cost associated with hit testing a sequence of potentially thousands of
/// events -- move events only hit-test the down event, then cache the result
/// and apply it to all subsequent move events, but there is no down event
/// for a hover. To support native hover gesture handling by platform views,
/// we attach/detach this layer annotation as necessary.
MouseTrackerAnnotation get _hoverAnnotation {
return _cachedHoverAnnotation ??= MouseTrackerAnnotation(
onHover: (PointerHoverEvent event) {
if (_handlePointerEvent != null)
_handlePointerEvent(event);
},
cursor: MouseCursor.uncontrolled,
);
}
MouseTrackerAnnotation _cachedHoverAnnotation;
_HandlePointerEvent _handlePointerEvent; _HandlePointerEvent _handlePointerEvent;
/// {@macro flutter.rendering.platformView.updateGestureRecognizers} /// {@macro flutter.rendering.platformView.updateGestureRecognizers}
@ -696,6 +676,22 @@ mixin _PlatformViewGestureMixin on RenderBox {
@override @override
bool hitTestSelf(Offset position) => hitTestBehavior != PlatformViewHitTestBehavior.transparent; bool hitTestSelf(Offset position) => hitTestBehavior != PlatformViewHitTestBehavior.transparent;
@override
PointerEnterEventListener get onEnter => null;
@override
PointerHoverEventListener get onHover => _handleHover;
void _handleHover(PointerHoverEvent event) {
if (_handlePointerEvent != null)
_handlePointerEvent(event);
}
@override
PointerExitEventListener get onExit => null;
@override
MouseCursor get cursor => MouseCursor.uncontrolled;
@override @override
void handleEvent(PointerEvent event, HitTestEntry entry) { void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent) { if (event is PointerDownEvent) {

View file

@ -2720,6 +2720,15 @@ class RenderMouseRegion extends RenderProxyBox implements MouseTrackerAnnotation
_annotationIsActive = false, _annotationIsActive = false,
super(child); super(child);
@protected
@override
bool hitTestSelf(Offset position) => true;
@override
bool hitTest(BoxHitTestResult result, { @required Offset position }) {
return super.hitTest(result, position: position) && _opaque;
}
/// Whether this object should prevent [RenderMouseRegion]s visually behind it /// Whether this object should prevent [RenderMouseRegion]s visually behind it
/// from detecting the pointer, thus affecting how their [onHover], [onEnter], /// from detecting the pointer, thus affecting how their [onHover], [onEnter],
/// and [onExit] behave. /// and [onExit] behave.
@ -2838,25 +2847,6 @@ class RenderMouseRegion extends RenderProxyBox implements MouseTrackerAnnotation
super.detach(); super.detach();
} }
@override
bool get needsCompositing => super.needsCompositing || _annotationIsActive;
@override
void paint(PaintingContext context, Offset offset) {
if (_annotationIsActive) {
// Annotated region layers are not retained because they do not create engine layers.
final AnnotatedRegionLayer<MouseTrackerAnnotation> layer = AnnotatedRegionLayer<MouseTrackerAnnotation>(
this,
size: size,
offset: offset,
opaque: opaque,
);
context.pushLayer(layer, super.paint, offset);
} else {
super.paint(context, offset);
}
}
@override @override
void performResize() { void performResize() {
size = constraints.biggest; size = constraints.biggest;

View file

@ -861,7 +861,7 @@ class SliverHitTestResult extends HitTestResult {
assert(crossAxisPosition != null); assert(crossAxisPosition != null);
assert(hitTest != null); assert(hitTest != null);
if (paintOffset != null) { if (paintOffset != null) {
pushTransform(Matrix4.translationValues(-paintOffset.dx, -paintOffset.dy, 0)); pushOffset(-paintOffset);
} }
final bool isHit = hitTest( final bool isHit = hitTest(
this, this,

View file

@ -9,6 +9,7 @@ import 'dart:io' show Platform;
import 'dart:ui' as ui show Scene, SceneBuilder, Window; import 'dart:ui' as ui show Scene, SceneBuilder, Window;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
@ -201,9 +202,17 @@ class RenderView extends RenderObject with RenderObjectWithChildMixin<RenderBox>
// Layer hit testing is done using device pixels, so we have to convert // Layer hit testing is done using device pixels, so we have to convert
// the logical coordinates of the event location back to device pixels // the logical coordinates of the event location back to device pixels
// here. // here.
return layer.findAllAnnotations<MouseTrackerAnnotation>( final BoxHitTestResult result = BoxHitTestResult();
position * configuration.devicePixelRatio if (child != null)
).annotations; child.hitTest(result, position: position);
result.add(HitTestEntry(this));
final List<MouseTrackerAnnotation> annotations = <MouseTrackerAnnotation>[];
for (final HitTestEntry entry in result.path) {
if (entry.target is MouseTrackerAnnotation) {
annotations.add(entry.target as MouseTrackerAnnotation);
}
}
return annotations;
} }
@override @override

View file

@ -36,6 +36,94 @@ void main() {
expect(wrapped.path, equals(<HitTestEntry>[entry1, entry2, entry3])); expect(wrapped.path, equals(<HitTestEntry>[entry1, entry2, entry3]));
expect(entry3.transform, transform); expect(entry3.transform, transform);
}); });
test('HitTestResult should correctly push and pop transforms', () {
Matrix4 currentTransform(HitTestResult targetResult) {
final HitTestEntry entry = HitTestEntry(_DummyHitTestTarget());
targetResult.add(entry);
return entry.transform;
}
final MyHitTestResult result = MyHitTestResult();
final Matrix4 m1 = Matrix4.translationValues(10, 20, 0);
final Matrix4 m2 = Matrix4.rotationZ(1);
final Matrix4 m3 = Matrix4.diagonal3Values(1.1, 1.2, 1.0);
result.publicPushTransform(m1);
expect(currentTransform(result), equals(m1));
result.publicPushTransform(m2);
expect(currentTransform(result), equals(m2 * m1));
expect(currentTransform(result), equals(m2 * m1)); // Test repeated add
// The `wrapped` is wrapped at [m1, m2]
final MyHitTestResult wrapped = MyHitTestResult.wrap(result);
expect(currentTransform(wrapped), equals(m2 * m1));
result.publicPushTransform(m3);
expect(currentTransform(result), equals(m3 * m2 * m1));
expect(currentTransform(wrapped), equals(m3 * m2 * m1));
result.publicPopTransform();
result.publicPopTransform();
expect(currentTransform(result), equals(m1));
result.publicPopTransform();
result.publicPushTransform(m3);
expect(currentTransform(result), equals(m3));
result.publicPushTransform(m2);
expect(currentTransform(result), equals(m2 * m3));
});
test('HitTestResult should correctly push and pop offsets', () {
Matrix4 currentTransform(HitTestResult targetResult) {
final HitTestEntry entry = HitTestEntry(_DummyHitTestTarget());
targetResult.add(entry);
return entry.transform;
}
final MyHitTestResult result = MyHitTestResult();
final Matrix4 m1 = Matrix4.rotationZ(1);
final Matrix4 m2 = Matrix4.diagonal3Values(1.1, 1.2, 1.0);
const Offset o3 = Offset(10, 20);
final Matrix4 m3 = Matrix4.translationValues(o3.dx, o3.dy, 0.0);
// Test pushing offset as the first element
result.publicPushOffset(o3);
expect(currentTransform(result), equals(m3));
result.publicPopTransform();
result.publicPushOffset(o3);
result.publicPushTransform(m1);
expect(currentTransform(result), equals(m1 * m3));
expect(currentTransform(result), equals(m1 * m3)); // Test repeated add
// The `wrapped` is wrapped at [m1, m2]
final MyHitTestResult wrapped = MyHitTestResult.wrap(result);
expect(currentTransform(wrapped), equals(m1 * m3));
result.publicPushTransform(m2);
expect(currentTransform(result), equals(m2 * m1 * m3));
expect(currentTransform(wrapped), equals(m2 * m1 * m3));
result.publicPopTransform();
result.publicPopTransform();
result.publicPopTransform();
expect(currentTransform(result), equals(Matrix4.identity()));
result.publicPushTransform(m2);
result.publicPushOffset(o3);
result.publicPushTransform(m1);
expect(currentTransform(result), equals(m1 * m3 * m2));
result.publicPopTransform();
expect(currentTransform(result), equals(m3 * m2));
});
} }
class _DummyHitTestTarget implements HitTestTarget { class _DummyHitTestTarget implements HitTestTarget {
@ -46,5 +134,10 @@ class _DummyHitTestTarget implements HitTestTarget {
} }
class MyHitTestResult extends HitTestResult { class MyHitTestResult extends HitTestResult {
MyHitTestResult();
MyHitTestResult.wrap(HitTestResult result) : super.wrap(result);
void publicPushTransform(Matrix4 transform) => pushTransform(transform); void publicPushTransform(Matrix4 transform) => pushTransform(transform);
void publicPushOffset(Offset offset) => pushOffset(offset);
void publicPopTransform() => popTransform();
} }

View file

@ -5,6 +5,7 @@
// @dart = 2.8 // @dart = 2.8
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -398,6 +399,162 @@ void main() {
); );
// TODO(Piinks): Remove skip once web goldens are supported, https://github.com/flutter/flutter/issues/40297 // TODO(Piinks): Remove skip once web goldens are supported, https://github.com/flutter/flutter/issues/40297
}, skip: isBrowser); }, skip: isBrowser);
testWidgets('IgnorePointer ignores pointers', (WidgetTester tester) async {
final List<String> logs = <String>[];
Widget target({bool ignoring}) => Align(
alignment: Alignment.topLeft,
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 100,
height: 100,
child: Listener(
onPointerDown: (_) { logs.add('down1'); },
child: MouseRegion(
onEnter: (_) { logs.add('enter1'); },
onExit: (_) { logs.add('exit1'); },
cursor: SystemMouseCursors.forbidden,
child: Stack(
children: <Widget>[
Listener(
onPointerDown: (_) { logs.add('down2'); },
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) { logs.add('enter2'); },
onExit: (_) { logs.add('exit2'); },
),
),
IgnorePointer(
ignoring: ignoring,
child: Listener(
onPointerDown: (_) { logs.add('down3'); },
child: MouseRegion(
cursor: SystemMouseCursors.text,
onEnter: (_) { logs.add('enter3'); },
onExit: (_) { logs.add('exit3'); },
),
),
)
],
),
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(pointer: 1, kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(200, 200));
addTearDown(gesture.removePointer);
await tester.pumpWidget(target(ignoring: true));
expect(logs, isEmpty);
await gesture.moveTo(const Offset(50, 50));
expect(logs, <String>['enter1', 'enter2']);
logs.clear();
await gesture.down(const Offset(50, 50));
expect(logs, <String>['down2', 'down1']);
logs.clear();
await gesture.up();
expect(logs, isEmpty);
await tester.pumpWidget(target(ignoring: false));
expect(logs, <String>['exit2', 'enter3']);
logs.clear();
await gesture.down(const Offset(50, 50));
expect(logs, <String>['down3', 'down1']);
logs.clear();
await gesture.up();
expect(logs, isEmpty);
await tester.pumpWidget(target(ignoring: true));
expect(logs, <String>['exit3', 'enter2']);
logs.clear();
});
testWidgets('AbsorbPointer absorbs pointers', (WidgetTester tester) async {
final List<String> logs = <String>[];
Widget target({bool absorbing}) => Align(
alignment: Alignment.topLeft,
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 100,
height: 100,
child: Listener(
onPointerDown: (_) { logs.add('down1'); },
child: MouseRegion(
onEnter: (_) { logs.add('enter1'); },
onExit: (_) { logs.add('exit1'); },
cursor: SystemMouseCursors.forbidden,
child: Stack(
children: <Widget>[
Listener(
onPointerDown: (_) { logs.add('down2'); },
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) { logs.add('enter2'); },
onExit: (_) { logs.add('exit2'); },
),
),
AbsorbPointer(
absorbing: absorbing,
child: Listener(
onPointerDown: (_) { logs.add('down3'); },
child: MouseRegion(
cursor: SystemMouseCursors.text,
onEnter: (_) { logs.add('enter3'); },
onExit: (_) { logs.add('exit3'); },
),
),
)
],
),
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(pointer: 1, kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(200, 200));
addTearDown(gesture.removePointer);
await tester.pumpWidget(target(absorbing: true));
expect(logs, isEmpty);
await gesture.moveTo(const Offset(50, 50));
expect(logs, <String>['enter1']);
logs.clear();
await gesture.down(const Offset(50, 50));
expect(logs, <String>['down1']);
logs.clear();
await gesture.up();
expect(logs, isEmpty);
await tester.pumpWidget(target(absorbing: false));
expect(logs, <String>['enter3']);
logs.clear();
await gesture.down(const Offset(50, 50));
expect(logs, <String>['down3', 'down1']);
logs.clear();
await gesture.up();
expect(logs, isEmpty);
await tester.pumpWidget(target(absorbing: true));
expect(logs, <String>['exit3']);
logs.clear();
});
} }
HitsRenderBox hits(RenderBox renderBox) => HitsRenderBox(renderBox); HitsRenderBox hits(RenderBox renderBox) => HitsRenderBox(renderBox);

View file

@ -436,7 +436,7 @@ void main() {
events.clear(); events.clear();
}); });
testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async { testWidgets('needsCompositing is always false', (WidgetTester tester) async {
// Pretend that we have a mouse connected. // Pretend that we have a mouse connected.
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero); await gesture.addPointer(location: Offset.zero);
@ -467,22 +467,9 @@ void main() {
), ),
), ),
); );
expect(listener.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: Listener(
onPointerDown: (PointerDownEvent _) { },
),
),
);
expect(listener.needsCompositing, isFalse); expect(listener.needsCompositing, isFalse);
// TransformLayer for `Transform.scale` is removed again as transform is // If compositing was required, a dedicated TransformLayer for
// executed directly on the canvas. // `Transform.scale` would be added.
expect(tester.layers.whereType<TransformLayer>(), hasLength(1)); expect(tester.layers.whereType<TransformLayer>(), hasLength(1));
}); });

View file

@ -168,10 +168,6 @@ void main() {
await gesture.addPointer(location: const Offset(400, 300)); await gesture.addPointer(location: const Offset(400, 300));
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
expect(move, isNull); expect(move, isNull);
expect(enter, isNull);
expect(exit, isNull);
await tester.pump();
expect(move, isNull);
expect(enter, isNotNull); expect(enter, isNotNull);
expect(enter.position, equals(const Offset(400.0, 300.0))); expect(enter.position, equals(const Offset(400.0, 300.0)));
expect(exit, isNull); expect(exit, isNull);
@ -585,13 +581,13 @@ void main() {
} }
await tester.pumpWidget(hoverableContainer( await tester.pumpWidget(hoverableContainer(
onEnter: (PointerEnterEvent details) => logs.add('enter1'), onEnter: (PointerEnterEvent details) { logs.add('enter1'); },
onHover: (PointerHoverEvent details) => logs.add('hover1'), onHover: (PointerHoverEvent details) { logs.add('hover1'); },
onExit: (PointerExitEvent details) => logs.add('exit1'), onExit: (PointerExitEvent details) { logs.add('exit1'); },
)); ));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(); await gesture.addPointer(location: const Offset(150.0, 150.0));
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
// Start outside, move inside, then move outside // Start outside, move inside, then move outside
@ -709,7 +705,7 @@ void main() {
events.clear(); events.clear();
}); });
testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async { testWidgets('needsCompositing is always false', (WidgetTester tester) async {
// Pretend that we have a mouse connected. // Pretend that we have a mouse connected.
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(); await gesture.addPointer();
@ -729,7 +725,7 @@ void main() {
// transform.) // transform.)
expect(tester.layers.whereType<TransformLayer>(), hasLength(1)); expect(tester.layers.whereType<TransformLayer>(), hasLength(1));
// Test that needsCompositing updates correctly with callback change // Test that needsCompositing stays false with callback change
await tester.pumpWidget( await tester.pumpWidget(
Transform.scale( Transform.scale(
scale: 2.0, scale: 2.0,
@ -739,35 +735,10 @@ void main() {
), ),
), ),
); );
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); expect(mouseRegion.needsCompositing, isFalse);
// TransformLayer for `Transform.scale` is removed again as transform is // If compositing was required, a dedicated TransformLayer for
// executed directly on the canvas. // `Transform.scale` would be added.
expect(tester.layers.whereType<TransformLayer>(), hasLength(1)); 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 { testWidgets("Callbacks aren't called during build", (WidgetTester tester) async {