diff --git a/packages/flutter/lib/src/gestures/hit_test.dart b/packages/flutter/lib/src/gestures/hit_test.dart index 1f8e84e132..7b7b040505 100644 --- a/packages/flutter/lib/src/gestures/hit_test.dart +++ b/packages/flutter/lib/src/gestures/hit_test.dart @@ -54,7 +54,7 @@ class HitTestEntry { final HitTestTarget target; @override - String toString() => '$target'; + String toString() => '${describeIdentity(this)}($target)'; /// Returns a matrix describing how [PointerEvent]s delivered to this /// [HitTestEntry] should be transformed from the global coordinate space of diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 5813746d72..be03b5b484 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -878,9 +878,6 @@ class _TextFieldState extends State implements TextSelectionGestureDe } } - void _handleMouseEnter(PointerEnterEvent event) => _handleHover(true); - void _handleMouseExit(PointerExitEvent event) => _handleHover(false); - void _handleHover(bool hovering) { if (hovering != _isHovering) { setState(() { @@ -1007,8 +1004,8 @@ class _TextFieldState extends State implements TextSelectionGestureDe return IgnorePointer( ignoring: !_isEnabled, child: MouseRegion( - onEnter: _handleMouseEnter, - onExit: _handleMouseExit, + onEnter: (PointerEnterEvent event) => _handleHover(true), + onExit: (PointerExitEvent event) => _handleHover(false), child: AnimatedBuilder( animation: controller, // changes the _currentLength builder: (BuildContext context, Widget child) { diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart index 34fc8dd204..0daf7488ff 100644 --- a/packages/flutter/lib/src/rendering/layer.dart +++ b/packages/flutter/lib/src/rendering/layer.dart @@ -14,6 +14,69 @@ import 'package:vector_math/vector_math_64.dart'; import 'debug.dart'; +/// Information collected for an annotation that is found in the layer tree. +/// +/// See also: +/// +/// * [Layer.find], [Layer.findAll], and [Layer.findAnnotations], which create +/// and use objects of this class. +@immutable +class AnnotationEntry { + /// Create an entry of found annotation by providing the oject and related + /// information. + const AnnotationEntry({ + @required this.annotation, + @required this.localPosition, + }) : assert(localPosition != null); + + /// The annotation object that is found. + final T annotation; + + /// The target location described by the local coordinate space of the layer + /// that contains the annotation. + final Offset localPosition; + + @override + String toString() { + return '$runtimeType(annotation: $annotation, localPostion: $localPosition)'; + } +} + +/// Information collected about a list of annotations that are found in the +/// layer tree. +/// +/// See also: +/// +/// * [AnnotationEntry], which are members of this class. +/// * [Layer.findAll], and [Layer.findAnnotations], which create and use an +/// object of this class. +class AnnotationResult { + final List> _entries = >[]; + + /// Add a new entry to the end of the result. + /// + /// Usually, entries should be added in order from most specific to least + /// specific, typically during an upward walk of the tree. + void add(AnnotationEntry entry) => _entries.add(entry); + + /// An unmodifiable list of [AnnotationEntry] objects recorded. + /// + /// The first entry is the most specific, typically the one at the leaf of + /// tree. + Iterable> get entries => _entries; + + /// An unmodifiable list of annotations recorded. + /// + /// The first entry is the most specific, typically the one at the leaf of + /// tree. + /// + /// It is similar to [entries] but does not contain other information. + Iterable get annotations sync* { + for (AnnotationEntry entry in _entries) + yield entry.annotation; + } +} + /// A composited layer. /// /// During painting, the render tree generates a tree of composited layers that @@ -213,31 +276,105 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin { parent?._removeChild(this); } - /// Returns the value of [S] that corresponds to the point described by - /// [regionOffset]. + /// Search this layer and its subtree for annotations of type `S` at the + /// location described by `localPosition`. /// - /// Returns null if no matching region is found. + /// This method is called by the default implementation of [find] and + /// [findAll]. Override this method to customize how the layer should search + /// for annotations, or if the layer has its own annotations to add. /// - /// The main way for a value to be found here is by pushing an - /// [AnnotatedRegionLayer] into the layer tree. + /// ## About layer annotations /// - /// See also: + /// {@template flutter.rendering.layer.findAnnotations.aboutAnnotations} + /// Annotation is an optional object of any type that can be carried with a + /// layer. An annotation can be found at a location as long as the owner layer + /// contains the location and is walked to. /// - /// * [AnnotatedRegionLayer], for placing values in the layer tree. - S find(Offset regionOffset); + /// The annotations are searched by first visitng each child recursively, then + /// this layer, resulting in an order from visually front to back. Annotations + /// must meet the given restrictions, such as type and position. + /// + /// The common way for a value to be found here is by pushing an + /// [AnnotatedRegionLayer] into the layer tree, or by adding the desired + /// annotation by overriding `findAnnotations`. + /// {@endtemplate} + /// + /// ## Parameters and return value + /// + /// The [result] parameter is where the method outputs the resulting + /// annotations. New annotations found during the walk are added to the tail. + /// + /// The [onlyFirst] parameter indicates that, if true, the search will stop + /// when it finds the first qualified annotation; otherwise, it will walk the + /// entire subtree. + /// + /// The return value indicates the opacity of this layer and its subtree at + /// this position. If it returns true, then this layer's parent should skip + /// the children behind this layer. In other words, it is opaque to this type + /// of annotation and has absorbed the search so that its siblings behind it + /// are not aware of the search. If the return value is false, then the parent + /// might continue with other siblings. + /// + /// The return value does not affect whether the parent adds its own + /// annotations; in other words, if a layer is supposed to add an annotation, + /// it will always add it even if its children are opaque to this type of + /// annotation. However, the opacity that the parents return might be affected + /// by their children, hence making all of its ancestors opaque to this type + /// of annotation. + @protected + bool findAnnotations( + AnnotationResult result, + Offset localPosition, { + @required bool onlyFirst, + }); - /// Returns an iterable of [S] values that corresponds to the point described - /// by [regionOffset] on all layers under the point. + /// Search this layer and its subtree for the first annotation of type `S` + /// under the point described by `localPosition`. /// - /// Returns an empty list if no matching region is found. + /// Returns null if no matching annotations are found. /// - /// The main way for a value to be found here is by pushing an - /// [AnnotatedRegionLayer] into the layer tree. + /// By default this method simply calls [findAnnotations] with `onlyFirst: + /// true` and returns the first result. It is encouraged to override + /// [findAnnotations] instead of this method. + /// + /// ## About layer annotations + /// + /// {@macro flutter.rendering.layer.findAnnotations.aboutAnnotations} /// /// See also: /// + /// * [findAll], which is similar but returns all annotations found at the + /// given position. /// * [AnnotatedRegionLayer], for placing values in the layer tree. - Iterable findAll(Offset regionOffset); + AnnotationEntry find(Offset localPosition) { + final AnnotationResult result = AnnotationResult(); + findAnnotations(result, localPosition, onlyFirst: true); + return result.entries.isEmpty ? null : result.entries.first; + } + + /// Search this layer and its subtree for all annotations of type `S` under + /// the point described by `localPosition`. + /// + /// Returns a result with empty entries if no matching annotations are found. + /// + /// By default this method simply calls [findAnnotations] with `onlyFirst: + /// false` and returns its result. It is encouraged to override + /// [findAnnotations] instead of this method. + /// + /// ## About layer annotations + /// + /// {@macro flutter.rendering.layer.findAnnotations.aboutAnnotations} + /// + /// See also: + /// + /// * [find], which is similar but returns the first annotation found at the + /// given position. + /// * [AnnotatedRegionLayer], for placing values in the layer tree. + AnnotationResult findAll(Offset localPosition) { + final AnnotationResult result = AnnotationResult(); + findAnnotations(result, localPosition, onlyFirst: false); + return result; + } /// Override this method to upload this layer to the engine. /// @@ -359,10 +496,10 @@ class PictureLayer extends Layer { } @override - S find(Offset regionOffset) => null; - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return false; + } } /// A composited layer that maps a backend texture to a rectangle. @@ -430,10 +567,10 @@ class TextureLayer extends Layer { } @override - S find(Offset regionOffset) => null; - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return false; + } } /// A layer that shows an embedded [UIView](https://developer.apple.com/documentation/uikit/uiview) @@ -468,10 +605,10 @@ class PlatformViewLayer extends Layer { } @override - S find(Offset regionOffset) => null; - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return false; + } } /// A layer that indicates to the compositor that it should display @@ -544,10 +681,10 @@ class PerformanceOverlayLayer extends Layer { } @override - S find(Offset regionOffset) => null; - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return false; + } } /// A composited layer that has a list of children. @@ -728,31 +865,16 @@ class ContainerLayer extends Layer { } @override - S find(Offset regionOffset) { - Layer current = lastChild; - while (current != null) { - final Object value = current.find(regionOffset); - if (value != null) { - return value; - } - current = current.previousSibling; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + for (Layer child = lastChild; child != null; child = child.previousSibling) { + final bool isAbsorbed = child.findAnnotations(result, localPosition, onlyFirst: onlyFirst); + if (isAbsorbed) + return true; + if (onlyFirst && result.entries.isNotEmpty) + return isAbsorbed; } - return null; - } - - @override - Iterable findAll(Offset regionOffset) { - Iterable result = Iterable.empty(); - if (firstChild == null) - return result; - Layer child = lastChild; - while (true) { - result = result.followedBy(child.findAll(regionOffset)); - if (child == firstChild) - break; - child = child.previousSibling; - } - return result; + return false; } @override @@ -974,13 +1096,9 @@ class OffsetLayer extends ContainerLayer { } @override - S find(Offset regionOffset) { - return super.find(regionOffset - offset); - } - - @override - Iterable findAll(Offset regionOffset) { - return super.findAll(regionOffset - offset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return super.findAnnotations(result, localPosition - offset, onlyFirst: onlyFirst); } @override @@ -1102,17 +1220,11 @@ class ClipRectLayer extends ContainerLayer { } @override - S find(Offset regionOffset) { - if (!clipRect.contains(regionOffset)) - return null; - return super.find(regionOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - if (!clipRect.contains(regionOffset)) - return Iterable.empty(); - return super.findAll(regionOffset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + if (!clipRect.contains(localPosition)) + return false; + return super.findAnnotations(result, localPosition, onlyFirst: onlyFirst); } @override @@ -1188,17 +1300,11 @@ class ClipRRectLayer extends ContainerLayer { } @override - S find(Offset regionOffset) { - if (!clipRRect.contains(regionOffset)) - return null; - return super.find(regionOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - if (!clipRRect.contains(regionOffset)) - return Iterable.empty(); - return super.findAll(regionOffset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + if (!clipRRect.contains(localPosition)) + return false; + return super.findAnnotations(result, localPosition, onlyFirst: onlyFirst); } @override @@ -1274,17 +1380,11 @@ class ClipPathLayer extends ContainerLayer { } @override - S find(Offset regionOffset) { - if (!clipPath.contains(regionOffset)) - return null; - return super.find(regionOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - if (!clipPath.contains(regionOffset)) - return Iterable.empty(); - return super.findAll(regionOffset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + if (!clipPath.contains(localPosition)) + return false; + return super.findAnnotations(result, localPosition, onlyFirst: onlyFirst); } @override @@ -1400,7 +1500,7 @@ class TransformLayer extends OffsetLayer { builder.pop(); } - Offset _transformOffset(Offset regionOffset) { + Offset _transformOffset(Offset localPosition) { if (_inverseDirty) { _invertedTransform = Matrix4.tryInvert( PointerEvent.removePerspectiveTransform(transform) @@ -1409,24 +1509,18 @@ class TransformLayer extends OffsetLayer { } if (_invertedTransform == null) return null; - final Vector4 vector = Vector4(regionOffset.dx, regionOffset.dy, 0.0, 1.0); + final Vector4 vector = Vector4(localPosition.dx, localPosition.dy, 0.0, 1.0); final Vector4 result = _invertedTransform.transform(vector); return Offset(result[0], result[1]); } @override - S find(Offset regionOffset) { - final Offset transformedOffset = _transformOffset(regionOffset); - return transformedOffset == null ? null : super.find(transformedOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - final Offset transformedOffset = _transformOffset(regionOffset); - if (transformedOffset == null) { - return Iterable.empty(); - } - return super.findAll(transformedOffset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + final Offset transformedOffset = _transformOffset(localPosition); + if (transformedOffset == null) + return false; + return super.findAnnotations(result, transformedOffset, onlyFirst: onlyFirst); } @override @@ -1734,17 +1828,11 @@ class PhysicalModelLayer extends ContainerLayer { } @override - S find(Offset regionOffset) { - if (!clipPath.contains(regionOffset)) - return null; - return super.find(regionOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - if (!clipPath.contains(regionOffset)) - return Iterable.empty(); - return super.findAll(regionOffset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + if (!clipPath.contains(localPosition)) + return false; + return super.findAnnotations(result, localPosition, onlyFirst: onlyFirst); } @override @@ -1870,10 +1958,10 @@ class LeaderLayer extends ContainerLayer { Offset _lastOffset; @override - S find(Offset regionOffset) => super.find(regionOffset - offset); - - @override - Iterable findAll(Offset regionOffset) => super.findAll(regionOffset - offset); + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + return super.findAnnotations(result, localPosition - offset, onlyFirst: onlyFirst); + } @override void addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { @@ -1991,37 +2079,32 @@ class FollowerLayer extends ContainerLayer { Matrix4 _invertedTransform; bool _inverseDirty = true; - Offset _transformOffset(Offset regionOffset) { + Offset _transformOffset(Offset localPosition) { if (_inverseDirty) { _invertedTransform = Matrix4.tryInvert(getLastTransform()); _inverseDirty = false; } if (_invertedTransform == null) return null; - final Vector4 vector = Vector4(regionOffset.dx, regionOffset.dy, 0.0, 1.0); + final Vector4 vector = Vector4(localPosition.dx, localPosition.dy, 0.0, 1.0); final Vector4 result = _invertedTransform.transform(vector); return Offset(result[0] - linkedOffset.dx, result[1] - linkedOffset.dy); } @override - S find(Offset regionOffset) { + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { if (link.leader == null) { - return showWhenUnlinked ? super.find(regionOffset - unlinkedOffset) : null; + if (showWhenUnlinked) { + return super.findAnnotations(result, localPosition - unlinkedOffset, onlyFirst: onlyFirst); + } + return false; } - final Offset transformedOffset = _transformOffset(regionOffset); - return transformedOffset == null ? null : super.find(transformedOffset); - } - - @override - Iterable findAll(Offset regionOffset) { - if (link.leader == null) { - return showWhenUnlinked ? super.findAll(regionOffset - unlinkedOffset) : []; - } - final Offset transformedOffset = _transformOffset(regionOffset); + final Offset transformedOffset = _transformOffset(localPosition); if (transformedOffset == null) { - return []; + return false; } - return super.findAll(transformedOffset); + return super.findAnnotations(result, transformedOffset, onlyFirst: onlyFirst); } /// The transform that was used during the last composition phase. @@ -2160,67 +2243,127 @@ class FollowerLayer extends ContainerLayer { } } -/// A composited layer which annotates its children with a value. +/// A composited layer which annotates its children with a value. Pushing this +/// layer to the tree is the common way of adding an annotation. /// -/// These values can be retrieved using [Layer.find] with a given [Offset]. If -/// a [Size] is provided to this layer, then find will check if the provided -/// offset is within the bounds of the layer. +/// An annotation is an optional object of any type that, when attached with a +/// layer, can be retrieved using [Layer.find] or [Layer.findAll] with a +/// position. The search process is done recursively, controlled by a concept +/// of being opaque to a type of annotation, explained in the document of +/// [Layer.findAnnotations]. +/// +/// When an annotation search arrives, this layer defers the same search to each +/// of this layer's children, respecting their opacity. Then it adds this +/// layer's [annotation] if all of the following restrictions are met: +/// +/// {@template flutter.rendering.annotatedRegionLayer.restrictions} +/// * The target type must be identical to the annotated type `T`. +/// * If [size] is provided, the target position must be contained within the +/// rectangle formed by [size] and [offset]. +/// {@endtemplate} +/// +/// This layer is opaque to a type of annotation if any child is also opaque, or +/// if [opaque] is true and the layer's annotation is added. class AnnotatedRegionLayer extends ContainerLayer { - /// Creates a new layer annotated with [value] that clips to rectangle defined - /// by the [size] and [offset] if provided. + /// Creates a new layer that annotates its children with [value]. /// /// The [value] provided cannot be null. - AnnotatedRegionLayer(this.value, {this.size, Offset offset}) - : offset = offset ?? Offset.zero, - assert(value != null); + AnnotatedRegionLayer( + this.value, { + this.size, + Offset offset, + this.opaque = false, + }) : assert(value != null), + assert(opaque != null), + offset = offset ?? Offset.zero; - /// The value returned by [find] if the offset is contained within this layer. + /// The annotated object, which is added to the result if all restrictions are + /// met. final T value; - /// The [size] is optionally used to clip the hit-testing of [find]. + /// The size of an optional clipping rectangle, used to control whether a + /// position is contained by the annotation. /// - /// If not provided, all offsets are considered to be contained within this - /// layer, unless an ancestor layer applies a clip. - /// - /// If [offset] is set, then the offset is applied to the size region before - /// hit testing in [find]. + /// If [size] is provided, then the annotation is only added if the target + /// position is contained by the rectangle formed by [size] and [offset]. + /// Otherwise no such restriction is applied, and clipping can only be done by + /// the ancestor layers. final Size size; - /// The [offset] is optionally used to translate the clip region for the - /// hit-testing of [find] by [offset]. + /// The offset of the optional clipping rectangle that is indicated by [size]. /// - /// If not provided, offset defaults to [Offset.zero]. + /// The [offset] defaults to [Offset.zero] if not provided, and is ignored if + /// [size] is not set. /// - /// Ignored if [size] is not set. + /// The [offset] only offsets the the clipping rectagle, and does not affect + /// how the painting or annotation search is propagated to its children. final Offset offset; - @override - S find(Offset regionOffset) { - final S result = super.find(regionOffset); - if (result != null) - return result; - if (size != null && !(offset & size).contains(regionOffset)) - return null; - if (T == S) { - final Object untypedResult = value; - final S typedResult = untypedResult; - return typedResult; - } - return null; - } + /// Whether the annotation of this layer should be opaque during an annotation + /// search of type `T`, preventing siblings visually behind it from being + /// searched. + /// + /// If [opaque] is true, and this layer does add its annotation [value], + /// then the layer will always be opaque during the search. + /// + /// If [opaque] is false, or if this layer does not add its annotation, + /// then the opacity of this layer will be the one returned by the children, + /// meaning that it will be opaque if any child is opaque. + /// + /// The [opaque] defaults to false. + /// + /// The [opaque] is effectively useless during [Layer.find] (more + /// specifically, [Layer.findAnnotations] with `onlyFirst: true`), since the + /// search process then skips the remaining tree after finding the first + /// annotation. + /// + /// See also: + /// + /// * [Layer.findAnnotations], which explains the concept of being opaque + /// to a type of annotation as the return value. + /// * [HitTestBehavior], which controls similar logic when hit-testing in the + /// render tree. + final bool opaque; + /// Searches the subtree for annotations of type `S` at the location + /// `localPosition`, then adds the annotation [value] if applicable. + /// + /// This method always searches its children, and if any child returns `true`, + /// the remaining children are skipped. Regardless of what the children + /// return, this method then adds this layer's annotation if all of the + /// following restrictions are met: + /// + /// {@macro flutter.rendering.annotatedRegionLayer.restrictions} + /// + /// This search process respects `onlyFirst`, meaning that when `onlyFirst` is + /// true, the search will stop when it finds the first annotation from the + /// children, and the layer's own annotation is checked only when none is + /// given by the children. + /// + /// The return value is true if any child returns `true`, or if [opaque] is + /// true and the layer's annotation is added. + /// + /// For explanation of layer annotations, parameters and return value, refer + /// to [Layer.findAnnotations]. @override - Iterable findAll(Offset regionOffset) { - final Iterable childResults = super.findAll(regionOffset); - if (size != null && !(offset & size).contains(regionOffset)) { - return childResults; + @protected + bool findAnnotations(AnnotationResult result, Offset localPosition, { @required bool onlyFirst }) { + bool isAbsorbed = super.findAnnotations(result, localPosition, onlyFirst: onlyFirst); + if (result.entries.isNotEmpty && onlyFirst) + return isAbsorbed; + if (size != null && !(offset & size).contains(localPosition)) { + return isAbsorbed; } if (T == S) { - final Object untypedResult = value; - final S typedResult = untypedResult; - return childResults.followedBy([typedResult]); + isAbsorbed = isAbsorbed || opaque; + final Object untypedValue = value; + final S typedValue = untypedValue; + result.add(AnnotationEntry( + annotation: typedValue, + localPosition: localPosition, + )); } - return childResults; + return isAbsorbed; } @override @@ -2229,5 +2372,6 @@ class AnnotatedRegionLayer extends ContainerLayer { properties.add(DiagnosticsProperty('value', value)); properties.add(DiagnosticsProperty('size', size, defaultValue: null)); properties.add(DiagnosticsProperty('offset', offset, defaultValue: null)); + properties.add(DiagnosticsProperty('opaque', opaque, defaultValue: false)); } } diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 1a5895a25d..219921b591 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2591,8 +2591,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { /// Calls callbacks in response to pointer events that are exclusive to mice. /// -/// Simply put, it responds to events that are related to hovering, -/// i.e. when the mouse enters, exits or hovers a region without pressing. +/// It responds to events that are related to hovering, i.e. when the mouse +/// enters, exits (with or without pressing buttons), or moves over a region +/// without pressing buttons. /// /// It does not respond to common events that construct gestures, such as when /// the pointer is pressed, moved, then released or canceled. For these events, @@ -2601,14 +2602,21 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { /// If it has a child, it defers to the child for sizing behavior. /// /// If it does not have a child, it grows to fit the parent-provided constraints. +/// +/// See also: +/// +/// * [MouseRegion], a widget that listens to hover events using +/// [RenderMouseRegion]. class RenderMouseRegion extends RenderProxyBox { /// Creates a render object that forwards pointer events to callbacks. RenderMouseRegion({ PointerEnterEventListener onEnter, PointerHoverEventListener onHover, PointerExitEventListener onExit, + this.opaque = true, RenderBox child, - }) : _onEnter = onEnter, + }) : assert(opaque != null), + _onEnter = onEnter, _onHover = onHover, _onExit = onExit, _annotationIsActive = false, @@ -2620,10 +2628,24 @@ class RenderMouseRegion extends RenderProxyBox { ); } - /// Called when a hovering pointer enters the region for this widget. + /// Whether this object should prevent [RenderMouseRegion]s visually behind it + /// from detecting the pointer, thus affecting how their [onHover], [onEnter], + /// and [onExit] behave. /// - /// If this is a mouse pointer, this will fire when the mouse pointer enters - /// the region defined by this widget. + /// If [opaque] is true, this object will absorb the mouse pointer and + /// prevent this object's siblings (or any other objects that are not + /// ancestors or descendants of this object) from detecting the mouse + /// pointer even when the pointer is within their areas. + /// + /// If [opaque] is false, this object will not affect how [RenderMouseRegion]s + /// behind it behave, which will detect the mouse pointer as long as the + /// pointer is within their areas. + /// + /// This defaults to true. + bool opaque; + + /// Called when a mouse pointer enters the region (with or without buttons + /// pressed). PointerEnterEventListener get onEnter => _onEnter; set onEnter(PointerEnterEventListener value) { if (_onEnter != value) { @@ -2637,10 +2659,8 @@ class RenderMouseRegion extends RenderProxyBox { _onEnter(event); } - /// Called when a pointer that has not triggered an [onPointerDown] changes - /// position. - /// - /// Typically only triggered for mouse pointers. + /// Called when a pointer changes position without buttons pressed and the end + /// position is within the region. PointerHoverEventListener get onHover => _onHover; set onHover(PointerHoverEventListener value) { if (_onHover != value) { @@ -2654,10 +2674,7 @@ class RenderMouseRegion extends RenderProxyBox { _onHover(event); } - /// Called when a hovering pointer leaves the region for this widget. - /// - /// If this is a mouse pointer, this will fire when the mouse pointer leaves - /// the region defined by this widget. + /// Called when a pointer leaves the region (with or without buttons pressed). PointerExitEventListener get onExit => _onExit; set onExit(PointerExitEventListener value) { if (_onExit != value) { @@ -2754,6 +2771,7 @@ class RenderMouseRegion extends RenderProxyBox { _hoverAnnotation, size: size, offset: offset, + opaque: opaque, ); context.pushLayer(layer, super.paint, offset); } else { @@ -2778,6 +2796,7 @@ class RenderMouseRegion extends RenderProxyBox { }, ifEmpty: '', )); + properties.add(DiagnosticsProperty('opaque', opaque, defaultValue: true)); } } diff --git a/packages/flutter/lib/src/rendering/view.dart b/packages/flutter/lib/src/rendering/view.dart index 2769f307be..ed3d318a1f 100644 --- a/packages/flutter/lib/src/rendering/view.dart +++ b/packages/flutter/lib/src/rendering/view.dart @@ -196,7 +196,9 @@ class RenderView extends RenderObject with RenderObjectWithChildMixin // Layer hit testing is done using device pixels, so we have to convert // the logical coordinates of the event location back to device pixels // here. - return layer.findAll(position * configuration.devicePixelRatio); + return layer.findAll( + position * configuration.devicePixelRatio + ).annotations; } @override @@ -241,12 +243,12 @@ class RenderView extends RenderObject with RenderObjectWithChildMixin final Rect bounds = paintBounds; final Offset top = Offset(bounds.center.dx, _window.padding.top / _window.devicePixelRatio); final Offset bottom = Offset(bounds.center.dx, bounds.center.dy - _window.padding.bottom / _window.devicePixelRatio); - final SystemUiOverlayStyle upperOverlayStyle = layer.find(top); + final SystemUiOverlayStyle upperOverlayStyle = layer.find(top)?.annotation; // Only android has a customizable system navigation bar. SystemUiOverlayStyle lowerOverlayStyle; switch (defaultTargetPlatform) { case TargetPlatform.android: - lowerOverlayStyle = layer.find(bottom); + lowerOverlayStyle = layer.find(bottom)?.annotation; break; case TargetPlatform.iOS: case TargetPlatform.fuchsia: diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 3b9c69bc4f..e9de1e9df8 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5575,11 +5575,11 @@ class Listener extends StatelessWidget { // TODO(tongmu): After it goes stable, remove these 3 parameters from Listener // and Listener should no longer need an intermediate class _PointerListener. // https://github.com/flutter/flutter/issues/36085 - @Deprecated('Use MouseRegion.onEnter instead') + @Deprecated('Use MouseRegion.onEnter instead. See MouseRegion.opaque for behavioral difference.') this.onPointerEnter, // ignore: deprecated_member_use_from_same_package - @Deprecated('Use MouseRegion.onExit instead') + @Deprecated('Use MouseRegion.onExit instead. See MouseRegion.opaque for behavioral difference.') this.onPointerExit, // ignore: deprecated_member_use_from_same_package - @Deprecated('Use MouseRegion.onHover instead') + @Deprecated('Use MouseRegion.onHover instead. See MouseRegion.opaque for behavioral difference.') this.onPointerHover, // ignore: deprecated_member_use_from_same_package this.onPointerUp, this.onPointerCancel, @@ -5656,6 +5656,7 @@ class Listener extends StatelessWidget { onEnter: onPointerEnter, onExit: onPointerExit, onHover: onPointerHover, + opaque: false, child: result, ); } @@ -5815,8 +5816,10 @@ class MouseRegion extends SingleChildRenderObjectWidget { this.onEnter, this.onExit, this.onHover, + this.opaque = true, Widget child, - }) : super(key: key, child: child); + }) : assert(opaque != null), + super(key: key, child: child); /// Called when a mouse pointer (with or without buttons pressed) enters the /// region defined by this widget, or when the widget appears under the @@ -5832,6 +5835,22 @@ class MouseRegion extends SingleChildRenderObjectWidget { /// the pointer. final PointerExitEventListener onExit; + /// Whether this widget should prevent other [MouseRegion]s visually behind it + /// from detecting the pointer, thus affecting how their [onHover], [onEnter], + /// and [onExit] behave. + /// + /// If [opaque] is true, this widget will absorb the mouse pointer and + /// prevent this widget's siblings (or any other widgets that are not + /// ancestors or descendants of this widget) from detecting the mouse + /// pointer even when the pointer is within their areas. + /// + /// If [opaque] is false, this object will not affect how [MouseRegion]s + /// behind it behave, which will detect the mouse pointer as long as the + /// pointer is within their areas. + /// + /// This defaults to true. + final bool opaque; + @override _MouseRegionElement createElement() => _MouseRegionElement(this); @@ -5841,6 +5860,7 @@ class MouseRegion extends SingleChildRenderObjectWidget { onEnter: onEnter, onHover: onHover, onExit: onExit, + opaque: opaque, ); } @@ -5849,7 +5869,8 @@ class MouseRegion extends SingleChildRenderObjectWidget { renderObject ..onEnter = onEnter ..onHover = onHover - ..onExit = onExit; + ..onExit = onExit + ..opaque = opaque; } @override @@ -5863,6 +5884,7 @@ class MouseRegion extends SingleChildRenderObjectWidget { if (onHover != null) listeners.add('hover'); properties.add(IterableProperty('listeners', listeners, ifEmpty: '')); + properties.add(DiagnosticsProperty('opaque', opaque, defaultValue: true)); } } diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 44be3f30d3..14a5566aa2 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -57,10 +57,14 @@ class _ProxyLayer extends Layer { } @override - S find(Offset regionOffset) => _layer.find(regionOffset); - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations( + AnnotationResult result, + Offset localPosition, { + @required bool onlyFirst, + }) { + return _layer.findAnnotations(result, localPosition, onlyFirst: onlyFirst); + } } /// A [Canvas] that multicasts all method calls to a main canvas and a @@ -2662,10 +2666,14 @@ class _InspectorOverlayLayer extends Layer { } @override - S find(Offset regionOffset) => null; - - @override - Iterable findAll(Offset regionOffset) => []; + @protected + bool findAnnotations( + AnnotationResult result, + Offset localPosition, { + bool onlyFirst, + }) { + return false; + } } const double _kScreenEdgeMargin = 10.0; diff --git a/packages/flutter/test/rendering/annotated_region_test.dart b/packages/flutter/test/rendering/annotated_region_test.dart index 204eee69ad..dc8a9102d8 100644 --- a/packages/flutter/test/rendering/annotated_region_test.dart +++ b/packages/flutter/test/rendering/annotated_region_test.dart @@ -22,9 +22,9 @@ void main() { i += 1; } - expect(containerLayer.find(const Offset(0.0, 1.0)), 0); - expect(containerLayer.find(const Offset(0.0, 101.0)), 1); - expect(containerLayer.find(const Offset(0.0, 201.0)), 2); + expect(containerLayer.find(const Offset(0.0, 1.0)).annotation, 0); + expect(containerLayer.find(const Offset(0.0, 101.0)).annotation, 1); + expect(containerLayer.find(const Offset(0.0, 201.0)).annotation, 2); }); test('finds a value within the clip in a ClipRectLayer', () { @@ -41,9 +41,9 @@ void main() { i += 1; } - expect(containerLayer.find(const Offset(0.0, 1.0)), 0); - expect(containerLayer.find(const Offset(0.0, 101.0)), 1); - expect(containerLayer.find(const Offset(0.0, 201.0)), 2); + expect(containerLayer.find(const Offset(0.0, 1.0)).annotation, 0); + expect(containerLayer.find(const Offset(0.0, 101.0)).annotation, 1); + expect(containerLayer.find(const Offset(0.0, 201.0)).annotation, 2); }); @@ -61,9 +61,9 @@ void main() { i += 1; } - expect(containerLayer.find(const Offset(5.0, 5.0)), 0); - expect(containerLayer.find(const Offset(5.0, 105.0)), 1); - expect(containerLayer.find(const Offset(5.0, 205.0)), 2); + expect(containerLayer.find(const Offset(5.0, 5.0)).annotation, 0); + expect(containerLayer.find(const Offset(5.0, 105.0)).annotation, 1); + expect(containerLayer.find(const Offset(5.0, 205.0)).annotation, 2); }); test('finds a value under a TransformLayer', () { @@ -87,11 +87,11 @@ void main() { i += 1; } - expect(transformLayer.find(const Offset(0.0, 100.0)), 0); - expect(transformLayer.find(const Offset(0.0, 200.0)), 0); - expect(transformLayer.find(const Offset(0.0, 270.0)), 1); - expect(transformLayer.find(const Offset(0.0, 400.0)), 1); - expect(transformLayer.find(const Offset(0.0, 530.0)), 2); + expect(transformLayer.find(const Offset(0.0, 100.0)).annotation, 0); + expect(transformLayer.find(const Offset(0.0, 200.0)).annotation, 0); + expect(transformLayer.find(const Offset(0.0, 270.0)).annotation, 1); + expect(transformLayer.find(const Offset(0.0, 400.0)).annotation, 1); + expect(transformLayer.find(const Offset(0.0, 530.0)).annotation, 2); }); test('looks for child AnnotatedRegions before parents', () { @@ -101,7 +101,7 @@ void main() { parent.append(child); layer.append(parent); - expect(parent.find(Offset.zero), 2); + expect(parent.find(Offset.zero).annotation, 2); }); test('looks for correct type', () { @@ -111,7 +111,7 @@ void main() { layer.append(child2); layer.append(child1); - expect(layer.find(Offset.zero), 'hello'); + expect(layer.find(Offset.zero).annotation, 'hello'); }); test('does not clip Layer.find on an AnnotatedRegion with an unrelated type', () { @@ -121,7 +121,7 @@ void main() { parent.append(child); layer.append(parent); - expect(layer.find(const Offset(100.0, 100.0)), 1); + expect(layer.find(const Offset(100.0, 100.0)).annotation, 1); }); test('handles non-invertable transforms', () { @@ -133,7 +133,7 @@ void main() { parent.transform = Matrix4.diagonal3Values(1.0, 1.0, 1.0); - expect(parent.find(const Offset(0.0, 0.0)), 1); + expect(parent.find(const Offset(0.0, 0.0)).annotation, 1); }); }); group('$AnnotatedRegion findAll', () { @@ -151,9 +151,9 @@ void main() { i += 1; } - expect(containerLayer.findAll(const Offset(0.0, 1.0)), equals([0])); - expect(containerLayer.findAll(const Offset(0.0, 101.0)),equals([1])); - expect(containerLayer.findAll(const Offset(0.0, 201.0)), equals([2])); + expect(containerLayer.findAll(const Offset(0.0, 1.0)).annotations.toList(), equals([0])); + expect(containerLayer.findAll(const Offset(0.0, 101.0)).annotations.toList(), equals([1])); + expect(containerLayer.findAll(const Offset(0.0, 201.0)).annotations.toList(), equals([2])); }); test('finds a value within the clip in a ClipRectLayer', () { @@ -170,9 +170,9 @@ void main() { i += 1; } - expect(containerLayer.findAll(const Offset(0.0, 1.0)), equals([0])); - expect(containerLayer.findAll(const Offset(0.0, 101.0)), equals([1])); - expect(containerLayer.findAll(const Offset(0.0, 201.0)), equals([2])); + expect(containerLayer.findAll(const Offset(0.0, 1.0)).annotations.toList(), equals([0])); + expect(containerLayer.findAll(const Offset(0.0, 101.0)).annotations.toList(), equals([1])); + expect(containerLayer.findAll(const Offset(0.0, 201.0)).annotations.toList(), equals([2])); }); @@ -190,9 +190,9 @@ void main() { i += 1; } - expect(containerLayer.findAll(const Offset(5.0, 5.0)), equals([0])); - expect(containerLayer.findAll(const Offset(5.0, 105.0)), equals([1])); - expect(containerLayer.findAll(const Offset(5.0, 205.0)), equals([2])); + expect(containerLayer.findAll(const Offset(5.0, 5.0)).annotations.toList(), equals([0])); + expect(containerLayer.findAll(const Offset(5.0, 105.0)).annotations.toList(), equals([1])); + expect(containerLayer.findAll(const Offset(5.0, 205.0)).annotations.toList(), equals([2])); }); test('finds a value under a TransformLayer', () { @@ -216,11 +216,11 @@ void main() { i += 1; } - expect(transformLayer.findAll(const Offset(0.0, 100.0)), equals([0])); - expect(transformLayer.findAll(const Offset(0.0, 200.0)), equals([0])); - expect(transformLayer.findAll(const Offset(0.0, 270.0)), equals([1])); - expect(transformLayer.findAll(const Offset(0.0, 400.0)), equals([1])); - expect(transformLayer.findAll(const Offset(0.0, 530.0)), equals([2])); + expect(transformLayer.findAll(const Offset(0.0, 100.0)).annotations.toList(), equals([0])); + expect(transformLayer.findAll(const Offset(0.0, 200.0)).annotations.toList(), equals([0])); + expect(transformLayer.findAll(const Offset(0.0, 270.0)).annotations.toList(), equals([1])); + expect(transformLayer.findAll(const Offset(0.0, 400.0)).annotations.toList(), equals([1])); + expect(transformLayer.findAll(const Offset(0.0, 530.0)).annotations.toList(), equals([2])); }); test('finds multiple nested, overlapping regions', () { @@ -237,7 +237,7 @@ void main() { parent.append(layer); } - expect(parent.findAll(const Offset(0.0, 0.0)), equals([3, 1, 2, 0,])); + expect(parent.findAll(const Offset(0.0, 0.0)).annotations.toList(), equals([3, 1, 2, 0,])); }); test('looks for child AnnotatedRegions before parents', () { @@ -251,7 +251,7 @@ void main() { parent.append(child3); layer.append(parent); - expect(parent.findAll(Offset.zero), equals([4, 3, 2, 1])); + expect(parent.findAll(Offset.zero).annotations.toList(), equals([4, 3, 2, 1])); }); test('looks for correct type', () { @@ -261,7 +261,7 @@ void main() { layer.append(child2); layer.append(child1); - expect(layer.findAll(Offset.zero), equals(['hello'])); + expect(layer.findAll(Offset.zero).annotations.toList(), equals(['hello'])); }); test('does not clip Layer.find on an AnnotatedRegion with an unrelated type', () { @@ -271,7 +271,7 @@ void main() { parent.append(child); layer.append(parent); - expect(layer.findAll(const Offset(100.0, 100.0)), equals([1])); + expect(layer.findAll(const Offset(100.0, 100.0)).annotations.toList(), equals([1])); }); test('handles non-invertable transforms', () { @@ -279,11 +279,11 @@ void main() { final TransformLayer parent = TransformLayer(transform: Matrix4.diagonal3Values(0.0, 1.0, 1.0)); parent.append(child); - expect(parent.findAll(const Offset(0.0, 0.0)), equals([])); + expect(parent.findAll(const Offset(0.0, 0.0)).annotations.toList(), equals([])); parent.transform = Matrix4.diagonal3Values(1.0, 1.0, 1.0); - expect(parent.findAll(const Offset(0.0, 0.0)), equals([1])); + expect(parent.findAll(const Offset(0.0, 0.0)).annotations.toList(), equals([1])); }); }); } diff --git a/packages/flutter/test/rendering/layer_annotations_test.dart b/packages/flutter/test/rendering/layer_annotations_test.dart new file mode 100644 index 0000000000..ef403f6cea --- /dev/null +++ b/packages/flutter/test/rendering/layer_annotations_test.dart @@ -0,0 +1,758 @@ +// Copyright 2019 The Chromium 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 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + test('ContainerLayer.findAll returns all results from its children', () { + final Layer root = _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(1, opaque: false), + _TestAnnotatedLayer(2, opaque: false), + _TestAnnotatedLayer(3, opaque: false), + ] + ).build(); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 3, localPosition: Offset.zero), + const AnnotationEntry(annotation: 2, localPosition: Offset.zero), + const AnnotationEntry(annotation: 1, localPosition: Offset.zero), + ]), + ); + }); + + test('ContainerLayer.find returns the first result from its children', () { + final Layer root = _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(1, opaque: false), + _TestAnnotatedLayer(2, opaque: false), + _TestAnnotatedLayer(3, opaque: false), + ] + ).build(); + + final AnnotationEntry result = root.find(Offset.zero); + expect(result.annotation, 3); + expect(result.localPosition, Offset.zero); + }); + + test('ContainerLayer.findAll returns empty result when finding nothing', () { + final Layer root = _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(1, opaque: false), + _TestAnnotatedLayer(2, opaque: false), + _TestAnnotatedLayer(3, opaque: false), + ] + ).build(); + + expect(root.findAll(Offset.zero).entries.isEmpty, isTrue); + }); + + test('ContainerLayer.find returns null when finding nothing', () { + final Layer root = _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(1, opaque: false), + _TestAnnotatedLayer(2, opaque: false), + _TestAnnotatedLayer(3, opaque: false), + ] + ).build(); + + expect(root.find(Offset.zero), isNull); + }); + + test('ContainerLayer.findAll stops at the first opaque child', () { + final Layer root = _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(1, opaque: false), + _TestAnnotatedLayer(2, opaque: true), + _TestAnnotatedLayer(3, opaque: false), + ] + ).build(); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 3, localPosition: Offset(0, 0)), + const AnnotationEntry(annotation: 2, localPosition: Offset(0, 0)), + ]), + ); + }); + + test('ContainerLayer.findAll returns children\'s opacity (true)', () { + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(2, opaque: true), + ] + ).build(), + ); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: Offset(0, 0)), + ]), + ); + }); + + test('ContainerLayer.findAll returns children\'s opacity (false)', () { + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ], + ).build(), + ); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: Offset(0, 0)), + const AnnotationEntry(annotation: 1000, localPosition: Offset(0, 0)), + ]), + ); + }); + + test('ContainerLayer.findAll returns false as opacity when finding nothing', () { + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(2, opaque: false, size: Size.zero), + ], + ).build(), + ); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: Offset(0, 0)), + ]), + ); + }); + + test('OffsetLayer.findAll respects offset', () { + const Offset insidePosition = Offset(-5, 5); + const Offset outsidePosition = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + OffsetLayer(offset: const Offset(-10, 0)), + children: [ + _TestAnnotatedLayer(1, opaque: true, size: const Size(10, 10)), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: Offset(5, 5)), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: Offset(5, 5)), + ]), + ); + }); + + test('ClipRectLayer.findAll respects clipRect', () { + const Offset insidePosition = Offset(11, 11); + const Offset outsidePosition = Offset(19, 19); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ClipRectLayer(clipRect: const Offset(10, 10) & const Size(5, 5)), + children: [ + _TestAnnotatedLayer( + 1, + opaque: true, + size: const Size(10, 10), + offset: const Offset(10, 10), + ), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: insidePosition), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + test('ClipRRectLayer.findAll respects clipRRect', () { + // For a curve of radius 4 centered at (4, 4), + // location (1, 1) is outside, while (2, 2) is inside. + // Here we shift this RRect by (10, 10). + final RRect rrect = RRect.fromRectAndRadius( + const Offset(10, 10) & const Size(10, 10), + const Radius.circular(4), + ); + const Offset insidePosition = Offset(12, 12); + const Offset outsidePosition = Offset(11, 11); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ClipRRectLayer(clipRRect: rrect), + children: [ + _TestAnnotatedLayer( + 1, + opaque: true, + size: const Size(10, 10), + offset: const Offset(10, 10), + ), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: insidePosition), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + test('ClipPathLayer.findAll respects clipPath', () { + // For this triangle, location (1, 1) is inside, while (2, 2) is outside. + // 2 + // ————— + // | / + // | / + // 2 |/ + final Path originalPath = Path(); + originalPath.lineTo(2, 0); + originalPath.lineTo(0, 2); + originalPath.close(); + // Shift this clip path by (10, 10). + final Path path = originalPath.shift(const Offset(10, 10)); + const Offset insidePosition = Offset(11, 11); + const Offset outsidePosition = Offset(12, 12); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + ClipPathLayer(clipPath: path), + children: [ + _TestAnnotatedLayer( + 1, + opaque: true, + size: const Size(10, 10), + offset: const Offset(10, 10), + ), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: insidePosition), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + test('TransformLayer.findAll respects transform', () { + // Matrix `transform` enlarges the target by (2x, 4x), then shift it by + // (10, 20). + final Matrix4 transform = Matrix4.diagonal3Values(2, 4, 1) + ..setTranslation(Vector3(10, 20, 0)); + // The original region is Offset(10, 10) & Size(10, 10) + // The transformed region is Offset(30, 60) & Size(20, 40) + const Offset insidePosition = Offset(40, 80); + const Offset outsidePosition = Offset(20, 40); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + TransformLayer(transform: transform), + children: [ + _TestAnnotatedLayer( + 1, + opaque: true, + size: const Size(10, 10), + offset: const Offset(10, 10), + ), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: Offset(15, 15)), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + test('TransformLayer.findAll skips when transform is irreversible', () { + final Matrix4 transform = Matrix4.diagonal3Values(1, 0, 1); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + TransformLayer(transform: transform), + children: [ + _TestAnnotatedLayer(1, opaque: true), + ] + ).build(), + ); + + expect( + root.findAll(Offset.zero).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: Offset.zero), + ]), + ); + }); + + test('PhysicalModelLayer.findAll respects clipPath', () { + // For this triangle, location (1, 1) is inside, while (2, 2) is outside. + // 2 + // ————— + // | / + // | / + // 2 |/ + final Path originalPath = Path(); + originalPath.lineTo(2, 0); + originalPath.lineTo(0, 2); + originalPath.close(); + // Shift this clip path by (10, 10). + final Path path = originalPath.shift(const Offset(10, 10)); + const Offset insidePosition = Offset(11, 11); + const Offset outsidePosition = Offset(12, 12); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + PhysicalModelLayer( + clipPath: path, + elevation: 10, + color: const Color.fromARGB(0, 0, 0, 0), + shadowColor: const Color.fromARGB(0, 0, 0, 0), + ), + children: [ + _TestAnnotatedLayer( + 1, + opaque: true, + size: const Size(10, 10), + offset: const Offset(10, 10), + ), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: insidePosition), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + + test('LeaderLayer.findAll respects offset', () { + const Offset insidePosition = Offset(-5, 5); + const Offset outsidePosition = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + LeaderLayer( + link: LayerLink(), + offset: const Offset(-10, 0), + ), + children: [ + _TestAnnotatedLayer(1, opaque: true, size: const Size(10, 10)), + ] + ).build(), + ); + + expect( + root.findAll(insidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1, localPosition: Offset(5, 5)), + ]), + ); + expect( + root.findAll(outsidePosition).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 1000, localPosition: outsidePosition), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should append to the list ' + 'and return the given opacity (false) during a successful hit', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: false), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build(), + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should append to the list ' + 'and return the given opacity (true) during a successful hit', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: true), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build(), + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll has default opacity as false', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build(), + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should still check children and return' + 'children\'s opacity (false) during a failed hit', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: true, size: Size.zero), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build(), + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should still check children and return' + 'children\'s opacity (true) during a failed hit', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: false, size: Size.zero), + children: [ + _TestAnnotatedLayer(2, opaque: true), + ] + ).build() + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should not add to children\'s opacity ' + 'during a successful hit if it is not opaque', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: false), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build() + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should add to children\'s opacity ' + 'during a successful hit if it is opaque', () { + const Offset position = Offset(5, 5); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer(1, opaque: true), + children: [ + _TestAnnotatedLayer(2, opaque: false), + ] + ).build() + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should clip its annotation ' + 'using size and offset (positive)', () { + // The target position would have fallen outside if not for the offset. + const Offset position = Offset(100, 100); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer( + 1, + size: const Size(20, 20), + offset: const Offset(90, 90), + ), + children: [ + _TestAnnotatedLayer( + 2, + opaque: false, + // Use this offset to make sure AnnotatedRegionLayer's offset + // does not affect its children. + offset: const Offset(20, 20), + size: const Size(110, 110), + ), + ] + ).build() + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); + + test('AnnotatedRegionLayer.findAll should clip its annotation ' + 'using size and offset (negative)', () { + // The target position would have fallen inside if not for the offset. + const Offset position = Offset(10, 10); + + final Layer root = _appendAnnotationIfNotOpaque(1000, + _Layers( + AnnotatedRegionLayer( + 1, + size: const Size(20, 20), + offset: const Offset(90, 90), + ), + children: [ + _TestAnnotatedLayer(2, opaque: false, size: const Size(110, 110)), + ] + ).build() + ); + + expect( + root.findAll(position).entries.toList(), + _equalToAnnotationResult(>[ + const AnnotationEntry(annotation: 2, localPosition: position), + const AnnotationEntry(annotation: 1000, localPosition: position), + ]), + ); + }); +} + +/// Append `value` to the result of the annotations test of `layer` if and only +/// if it is opaque at the given location. +/// +/// It is a utility function that helps checking the opacity returned by +/// [Layer.findAnnotations]. +/// Technically it is a [ContainerLayer] that contains `layer` followed by +/// another layer annotated with `value`. +Layer _appendAnnotationIfNotOpaque(int value, Layer layer) { + return _Layers( + ContainerLayer(), + children: [ + _TestAnnotatedLayer(value, opaque: false), + layer, + ], + ).build(); +} + +// A utility class that helps building a layer tree. +class _Layers { + _Layers(this.root, {this.children}); + + final ContainerLayer root; + // Each element must be instance of Layer or _Layers. + final List children; + bool _assigned = false; + + // Build the layer tree by calling each child's `build`, then append children + // to [root]. Returns the root. + Layer build() { + assert(!_assigned); + _assigned = true; + if (children != null) { + for (Object child in children) { + Layer layer; + if (child is Layer) { + layer = child; + } else if (child is _Layers) { + layer = child.build(); + } else { + assert(false, 'Element of _Layers.children must be instance of Layer or _Layers'); + } + root.append(layer); + } + } + return root; + } +} + +// This layer's [findAnnotation] can be controlled by the given arguments. +class _TestAnnotatedLayer extends Layer { + _TestAnnotatedLayer(this.value, { + @required this.opaque, + this.offset = Offset.zero, + this.size, + }); + + // The value added to result in [findAnnotations] during a successful hit. + final int value; + + // The return value of [findAnnotations] during a successful hit. + final bool opaque; + + /// The [offset] is optionally used to translate the clip region for the + /// hit-testing of [find] by [offset]. + /// + /// If not provided, offset defaults to [Offset.zero]. + /// + /// Ignored if [size] is not set. + final Offset offset; + + /// The [size] is optionally used to clip the hit-testing of [find]. + /// + /// If not provided, all offsets are considered to be contained within this + /// layer, unless an ancestor layer applies a clip. + /// + /// If [offset] is set, then the offset is applied to the size region before + /// hit testing in [find]. + final Size size; + + @override + EngineLayer addToScene(SceneBuilder builder, [Offset layerOffset = Offset.zero]) { + return null; + } + + // This implementation is hit when the type is `int` and position is within + // [offset] & [size]. If it is hit, it adds [value] to result and returns + // [opaque]; otherwise it directly returns false. + @override + bool findAnnotations( + AnnotationResult result, + Offset localPosition, { + bool onlyFirst, + }) { + if (S != int) + return false; + if (size != null && !(offset & size).contains(localPosition)) + return false; + final Object untypedValue = value; + final S typedValue = untypedValue; + result.add(AnnotationEntry(annotation: typedValue, localPosition: localPosition)); + return opaque; + } +} + +Matcher _equalToAnnotationResult(List> list) { + return pairwiseCompare, AnnotationEntry>( + list, + (AnnotationEntry a, AnnotationEntry b) { + return a.annotation == b.annotation && a.localPosition == b.localPosition; + }, + 'equal to', + ); +} diff --git a/packages/flutter/test/widgets/annotated_region_test.dart b/packages/flutter/test/widgets/annotated_region_test.dart index 7b37167b9c..e4f5b66adc 100644 --- a/packages/flutter/test/widgets/annotated_region_test.dart +++ b/packages/flutter/test/widgets/annotated_region_test.dart @@ -34,12 +34,12 @@ void main() { int result = RendererBinding.instance.renderView.debugLayer.find(Offset( 10.0 * window.devicePixelRatio, 10.0 * window.devicePixelRatio, - )); + ))?.annotation; expect(result, null); result = RendererBinding.instance.renderView.debugLayer.find(Offset( 50.0 * window.devicePixelRatio, 50.0 * window.devicePixelRatio, - )); + )).annotation; expect(result, 1); }); } diff --git a/packages/flutter/test/widgets/mouse_region_test.dart b/packages/flutter/test/widgets/mouse_region_test.dart index 7d1b15b721..c5f6683736 100644 --- a/packages/flutter/test/widgets/mouse_region_test.dart +++ b/packages/flutter/test/widgets/mouse_region_test.dart @@ -752,6 +752,196 @@ void main() { expect(paintCount, 1); }); + 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: [ + 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 logs = []; + 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, ['enterA', 'enterC', 'enterB']); + logs.clear(); + + // Move to the B only area + await gesture.moveTo(const Offset(25, 75)); + await tester.pumpAndSettle(); + expect(logs, ['exitC']); + logs.clear(); + + // Move back to the overlapping area + await gesture.moveTo(const Offset(75, 75)); + await tester.pumpAndSettle(); + expect(logs, ['enterC']); + logs.clear(); + + // Move to the C only area + await gesture.moveTo(const Offset(125, 75)); + await tester.pumpAndSettle(); + expect(logs, ['exitB']); + logs.clear(); + + // Move back to the overlapping area + await gesture.moveTo(const Offset(75, 75)); + await tester.pumpAndSettle(); + expect(logs, ['enterB']); + logs.clear(); + + // Move out + await gesture.moveTo(const Offset(160, 160)); + await tester.pumpAndSettle(); + expect(logs, ['exitA', 'exitB', 'exitC']); + }); + + testWidgets('an opaque one should prevent MouseRegions behind it receiving pointers', (WidgetTester tester) async { + final List logs = []; + 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, ['enterA', 'enterC']); + logs.clear(); + + // Move to the B only area + await gesture.moveTo(const Offset(25, 75)); + await tester.pumpAndSettle(); + expect(logs, ['enterB', 'exitC']); + logs.clear(); + + // Move back to the overlapping area + await gesture.moveTo(const Offset(75, 75)); + await tester.pumpAndSettle(); + expect(logs, ['enterC', 'exitB']); + logs.clear(); + + // Move to the C only area + await gesture.moveTo(const Offset(125, 75)); + await tester.pumpAndSettle(); + expect(logs, []); + logs.clear(); + + // Move back to the overlapping area + await gesture.moveTo(const Offset(75, 75)); + await tester.pumpAndSettle(); + expect(logs, []); + logs.clear(); + + // Move out + await gesture.moveTo(const Offset(160, 160)); + await tester.pumpAndSettle(); + expect(logs, ['exitA', 'exitC']); + }); + + testWidgets('opaque should default to true', (WidgetTester tester) async { + final List logs = []; + 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, ['enterA', 'enterC']); + logs.clear(); + + // Move out + await gesture.moveTo(const Offset(160, 160)); + await tester.pumpAndSettle(); + expect(logs, ['exitA', 'exitC']); + }); + }); + testWidgets('RenderMouseRegion\'s debugFillProperties when default', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); RenderMouseRegion().debugFillProperties(builder);