Text selection handles track scrolled text fields (#10805)
Introduce CompositedTransformTarget and CompositedTransformFollower
widgets, corresponding render objects, and corresponding layers.
Adjust the way text fields work to use this.
Various changes I needed to debug the issues that came up.
diff --git a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart
index 3c63414..cb69a74 100644
--- a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart
+++ b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart
@@ -4,23 +4,71 @@
import 'package:meta/meta.dart';
+import 'print.dart';
+
/// A mixin that helps dump string representations of trees.
abstract class TreeDiagnosticsMixin {
// This class is intended to be used as a mixin, and should not be
// extended directly.
factory TreeDiagnosticsMixin._() => null;
+ /// A brief description of this object, usually just the [runtimeType] and the
+ /// [hashCode].
+ ///
+ /// See also:
+ ///
+ /// * [toStringShallow], for a detailed description of the object.
+ /// * [toStringDeep], for a description of the subtree rooted at this object.
@override
String toString() => '$runtimeType#$hashCode';
+ /// Returns a one-line detailed description of the object.
+ ///
+ /// This description includes everything from [debugFillDescription], but does
+ /// not recurse to any children.
+ ///
+ /// The [toStringShallow] method can take an argument, which is the string to
+ /// place between each part obtained from [debugFillDescription]. Passing a
+ /// string such as `'\n '` will result in a multiline string that indents the
+ /// properties of the object below its name (as per [toString]).
+ ///
+ /// See also:
+ ///
+ /// * [toString], for a brief description of the object.
+ /// * [toStringDeep], for a description of the subtree rooted at this object.
+ String toStringShallow([String joiner = '; ']) {
+ final StringBuffer result = new StringBuffer();
+ result.write(toString());
+ result.write(joiner);
+ final List<String> description = <String>[];
+ debugFillDescription(description);
+ result.write(description.join(joiner));
+ return result.toString();
+ }
+
/// Returns a string representation of this node and its descendants.
+ ///
+ /// This includes the information from [debugFillDescription], and then
+ /// recurses into the children using [debugDescribeChildren].
+ ///
+ /// The [toStringDeep] method takes arguments, but those are intended for
+ /// internal use when recursing to the descendants, and so can be ignored.
+ ///
+ /// See also:
+ ///
+ /// * [toString], for a brief description of the object but not its children.
+ /// * [toStringShallow], for a detailed description of the object but not its
+ /// children.
String toStringDeep([String prefixLineOne = '', String prefixOtherLines = '']) {
String result = '$prefixLineOne$this\n';
final String childrenDescription = debugDescribeChildren(prefixOtherLines);
final String descriptionPrefix = childrenDescription != '' ? '$prefixOtherLines \u2502 ' : '$prefixOtherLines ';
final List<String> description = <String>[];
debugFillDescription(description);
- result += description.map((String description) => '$descriptionPrefix$description\n').join();
+ result += description
+ .expand((String description) => debugWordWrap(description, 65, wrapIndent: ' '))
+ .map<String>((String line) => "$descriptionPrefix$line\n")
+ .join();
if (childrenDescription == '') {
final String prefix = prefixOtherLines.trimRight();
if (prefix != '')
@@ -31,7 +79,8 @@
return result;
}
- /// Add additional information to the given description for use by [toStringDeep].
+ /// Add additional information to the given description for use by
+ /// [toStringDeep] and [toStringShallow].
@protected
@mustCallSuper
void debugFillDescription(List<String> description) { }
diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart
index f696c41..719f466 100644
--- a/packages/flutter/lib/src/material/text_selection.dart
+++ b/packages/flutter/lib/src/material/text_selection.dart
@@ -6,6 +6,7 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
+import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'flat_button.dart';
@@ -90,8 +91,17 @@
/// Centers the toolbar around the given position, ensuring that it remains on
/// screen.
class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate {
- _TextSelectionToolbarLayout(this.position);
+ _TextSelectionToolbarLayout(this.screenSize, this.globalEditableRegion, this.position);
+ /// The size of the screen at the time that the toolbar was last laid out.
+ final Size screenSize;
+
+ /// Size and position of the editing region at the time the toolbar was last
+ /// laid out, in global coordinates.
+ final Rect globalEditableRegion;
+
+ /// Anchor position of the toolbar, relative to the top left of the
+ /// [globalEditableRegion].
final Offset position;
@override
@@ -101,17 +111,20 @@
@override
Offset getPositionForChild(Size size, Size childSize) {
- double x = position.dx - childSize.width / 2.0;
- double y = position.dy - childSize.height;
+ final Offset globalPosition = globalEditableRegion.topLeft + position;
+
+ double x = globalPosition.dx - childSize.width / 2.0;
+ double y = globalPosition.dy - childSize.height;
if (x < _kToolbarScreenPadding)
x = _kToolbarScreenPadding;
- else if (x + childSize.width > size.width - 2 * _kToolbarScreenPadding)
- x = size.width - childSize.width - _kToolbarScreenPadding;
+ else if (x + childSize.width > screenSize.width - _kToolbarScreenPadding)
+ x = screenSize.width - childSize.width - _kToolbarScreenPadding;
+
if (y < _kToolbarScreenPadding)
y = _kToolbarScreenPadding;
- else if (y + childSize.height > size.height - 2 * _kToolbarScreenPadding)
- y = size.height - childSize.height - _kToolbarScreenPadding;
+ else if (y + childSize.height > screenSize.height - _kToolbarScreenPadding)
+ y = screenSize.height - childSize.height - _kToolbarScreenPadding;
return new Offset(x, y);
}
@@ -149,15 +162,17 @@
/// Builder for material-style copy/paste text selection toolbar.
@override
- Widget buildToolbar(
- BuildContext context, Offset position, TextSelectionDelegate delegate) {
+ Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate) {
assert(debugCheckHasMediaQuery(context));
- final Size screenSize = MediaQuery.of(context).size;
return new ConstrainedBox(
- constraints: new BoxConstraints.loose(screenSize),
+ constraints: new BoxConstraints.tight(globalEditableRegion.size),
child: new CustomSingleChildLayout(
- delegate: new _TextSelectionToolbarLayout(position),
- child: new _TextSelectionToolbar(delegate)
+ delegate: new _TextSelectionToolbarLayout(
+ MediaQuery.of(context).size,
+ globalEditableRegion,
+ position,
+ ),
+ child: new _TextSelectionToolbar(delegate),
)
);
}
diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart
index 065e5da..270820f 100644
--- a/packages/flutter/lib/src/rendering/box.dart
+++ b/packages/flutter/lib/src/rendering/box.dart
@@ -1829,7 +1829,7 @@
/// Subclasses that apply transforms during painting should override this
/// function to factor those transforms into the calculation.
///
- /// The RenderBox implementation takes care of adjusting the matrix for the
+ /// The [RenderBox] implementation takes care of adjusting the matrix for the
/// position of the given child as determined during layout and stored on the
/// child's [parentData] in the [BoxParentData.offset] field.
@override
diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart
index 54610e1..6183b4b 100644
--- a/packages/flutter/lib/src/rendering/debug.dart
+++ b/packages/flutter/lib/src/rendering/debug.dart
@@ -123,8 +123,13 @@
bool debugProfilePaintsEnabled = false;
-/// Returns a list of strings representing the given transform in a format useful for [RenderObject.debugFillDescription].
+/// Returns a list of strings representing the given transform in a format
+/// useful for [RenderObject.debugFillDescription].
+///
+/// If the argument is null, returns a list with the single string "null".
List<String> debugDescribeTransform(Matrix4 transform) {
+ if (transform == null)
+ return const <String>['null'];
final List<String> matrix = transform.toString().split('\n').map((String s) => ' $s').toList();
matrix.removeLast();
return matrix;
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index aa5c516..b28f39d 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -30,8 +30,9 @@
/// Used by [RenderEditable.onCaretChanged].
typedef void CaretChangedHandler(Rect caretRect);
-/// Represents a global screen coordinate of the point in a selection, and the
-/// text direction at that point.
+/// Represents the coordinates of the point in a selection, and the text
+/// direction at that point, relative to top left of the [RenderEditable] that
+/// holds the selection.
@immutable
class TextSelectionPoint {
/// Creates a description of a point in a text selection.
@@ -40,7 +41,8 @@
const TextSelectionPoint(this.point, this.direction)
: assert(point != null);
- /// Screen coordinates of the lower left or lower right corner of the selection.
+ /// Coordinates of the lower left or lower right corner of the selection,
+ /// relative to the top left of the [RenderEditable] object.
final Offset point;
/// Direction of the text at this edge of the selection.
@@ -316,7 +318,7 @@
bool _hasVisualOverflow = false;
- /// Returns the global coordinates of the endpoints of the given selection.
+ /// Returns the local coordinates of the endpoints of the given selection.
///
/// If the selection is collapsed (and therefore occupies a single point), the
/// returned list is of length one. Otherwise, the selection is not collapsed
@@ -333,14 +335,14 @@
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
final Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
final Offset start = new Offset(0.0, _preferredLineHeight) + caretOffset + paintOffset;
- return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
+ return <TextSelectionPoint>[new TextSelectionPoint(start, null)];
} else {
final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
final Offset start = new Offset(boxes.first.start, boxes.first.bottom) + paintOffset;
final Offset end = new Offset(boxes.last.end, boxes.last.bottom) + paintOffset;
return <TextSelectionPoint>[
- new TextSelectionPoint(localToGlobal(start), boxes.first.direction),
- new TextSelectionPoint(localToGlobal(end), boxes.last.direction),
+ new TextSelectionPoint(start, boxes.first.direction),
+ new TextSelectionPoint(end, boxes.last.direction),
];
}
}
diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart
index da25813..453fe50 100644
--- a/packages/flutter/lib/src/rendering/layer.dart
+++ b/packages/flutter/lib/src/rendering/layer.dart
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:collection';
import 'dart:ui' as ui show ImageFilter, Picture, SceneBuilder;
import 'dart:ui' show Offset;
@@ -90,7 +91,7 @@
/// Override this method to upload this layer to the engine.
///
- /// The layerOffset is the accumulated offset of this layer's parent from the
+ /// The `layerOffset` is the accumulated offset of this layer's parent from the
/// origin of the builder's coordinate system.
void addToScene(ui.SceneBuilder builder, Offset layerOffset);
@@ -117,6 +118,16 @@
///
/// Picture layers are always leaves in the layer tree.
class PictureLayer extends Layer {
+ PictureLayer(this.canvasBounds);
+
+ /// The bounds that were used for the canvas that drew this layer's [picture].
+ ///
+ /// This is purely advisory. It is included in the information dumped with
+ /// [dumpLayerTree] (which can be triggered by pressing "L" when using
+ /// "flutter run" at the console), which can help debug why certain drawing
+ /// commands are being culled.
+ final Rect canvasBounds;
+
/// The picture recorded for this layer.
///
/// The picture's coodinate system matches this layer's coodinate system.
@@ -150,6 +161,12 @@
void addToScene(ui.SceneBuilder builder, Offset layerOffset) {
builder.addPicture(layerOffset, picture, isComplexHint: isComplexHint, willChangeHint: willChangeHint);
}
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ description.add('paint bounds: $canvasBounds');
+ }
}
/// A layer that indicates to the compositor that it should display
@@ -357,6 +374,44 @@
}
}
+ /// Applies the transform that would be applied when compositing the given
+ /// child to the given matrix.
+ ///
+ /// Specifically, this should apply the transform that is applied to child's
+ /// _origin_. When using [applyTransform] with a chain of layers, results will
+ /// be unreliable unless the deepest layer in the chain collapses the
+ /// `layerOffset` in [addToScene] to zero, meaning that it passes
+ /// [Offset.zero] to its children, and bakes any incoming `layerOffset` into
+ /// the [SceneBuilder] as (for instance) a transform (which is then also
+ /// included in the transformation applied by [applyTransform]).
+ ///
+ /// For example, if [addToScene] applies the `layerOffset` and then
+ /// passes [Offset.zero] to the children, then it should be included in the
+ /// transform applied here, whereas if [addToScene] just passes the
+ /// `layerOffset` to the child, then it should not be included in the
+ /// transform applied here.
+ ///
+ /// This method is only valid immediately after [addToScene] has been called,
+ /// before any of the properties have been changed.
+ ///
+ /// The default implementation does nothing, since [ContainerLayer], by
+ /// default, composits its children at the origin of the [ContainerLayer]
+ /// itself.
+ ///
+ /// The `child` argument should generally not be null, since in principle a
+ /// layer could transform each child independently. However, certain layers
+ /// may explicitly allow null as a value, for example if they know that they
+ /// transform all their children identically.
+ ///
+ /// The `transform` argument must not be null.
+ ///
+ /// Used by [FollowerLayer] to transform its child to a [LeaderLayer]'s
+ /// position.
+ void applyTransform(Layer child, Matrix4 transform) {
+ assert(child != null);
+ assert(transform != null);
+ }
+
@override
String debugDescribeChildren(String prefix) {
if (firstChild == null)
@@ -391,13 +446,17 @@
class OffsetLayer extends ContainerLayer {
/// Creates an offset layer.
///
- /// By default, [offset] is zero.
+ /// By default, [offset] is zero. It must be non-null before the compositing
+ /// phase of the pipeline.
OffsetLayer({ this.offset: Offset.zero });
/// Offset from parent in the parent's coordinate system.
///
/// The scene must be explicitly recomposited after this property is changed
/// (as described at [Layer]).
+ ///
+ /// The [offset] property must be non-null before the compositing phase of the
+ /// pipeline.
Offset offset;
@override
@@ -412,7 +471,6 @@
}
}
-
/// A composite layer that clips its children using a rectangle.
class ClipRectLayer extends ContainerLayer {
/// Creates a layer with a rectangular clip.
@@ -497,36 +555,52 @@
}
}
-/// A composited layer that applies a transformation matrix to its children.
+/// A composited layer that applies a given transformation matrix to its
+/// children.
+///
+/// This class inherits from [OffsetLayer] to make it one of the layers that
+/// can be used at the root of a [RenderObject] hierarchy.
class TransformLayer extends OffsetLayer {
/// Creates a transform layer.
///
- /// The [transform] property must be non-null before the compositing phase of
- /// the pipeline.
- TransformLayer({
- this.transform
- });
+ /// The [transform] and [offset] properties must be non-null before the
+ /// compositing phase of the pipeline.
+ TransformLayer({ this.transform, Offset offset: Offset.zero }) : super(offset: offset);
/// The matrix to apply.
///
/// The scene must be explicitly recomposited after this property is changed
/// (as described at [Layer]).
+ ///
+ /// This transform is applied before [offset], if both are set.
+ ///
+ /// The [transform] property must be non-null before the compositing phase of
+ /// the pipeline.
Matrix4 transform;
+ Matrix4 _lastEffectiveTransform;
+
@override
void addToScene(ui.SceneBuilder builder, Offset layerOffset) {
- assert(offset == Offset.zero);
- Matrix4 effectiveTransform = transform;
- if (layerOffset != Offset.zero) {
- effectiveTransform = new Matrix4.translationValues(layerOffset.dx, layerOffset.dy, 0.0)
- ..multiply(transform);
+ _lastEffectiveTransform = transform;
+ final Offset totalOffset = offset + layerOffset;
+ if (totalOffset != Offset.zero) {
+ _lastEffectiveTransform = new Matrix4.translationValues(totalOffset.dx, totalOffset.dy, 0.0)
+ ..multiply(_lastEffectiveTransform);
}
- builder.pushTransform(effectiveTransform.storage);
+ builder.pushTransform(_lastEffectiveTransform.storage);
addChildrenToScene(builder, Offset.zero);
builder.pop();
}
@override
+ void applyTransform(Layer child, Matrix4 transform) {
+ assert(child != null);
+ assert(transform != null);
+ transform.multiply(_lastEffectiveTransform);
+ }
+
+ @override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('transform:');
@@ -565,7 +639,7 @@
}
}
-/// A composited layer that applies a shader to hits children.
+/// A composited layer that applies a shader to its children.
class ShaderMaskLayer extends ContainerLayer {
/// Creates a shader mask layer.
///
@@ -682,3 +756,303 @@
description.add('clipRRect: $clipRRect');
}
}
+
+/// An object that a [LeaderLayer] can register with.
+///
+/// An instance of this class should be provided as the [LeaderLayer.link] and
+/// the [FollowerLayer.link] properties to cause the [FollowerLayer] to follow
+/// the [LeaderLayer].
+///
+/// See also:
+///
+/// * [CompositedTransformTarget], the widget that creates a [LeaderLayer].
+/// * [CompositedTransformFollower], the widget that creates a [FollowerLayer].
+/// * [RenderLeaderLayer] and [RenderFollowerLayer], the corresponding
+/// render objects.
+class LayerLink {
+ /// The currently-registered [LeaderLayer], if any.
+ LeaderLayer get leader => _leader;
+ LeaderLayer _leader;
+
+ @override
+ String toString() => '$runtimeType#$hashCode(${ _leader != null ? "<linked>" : "<dangling>" })';
+}
+
+/// A composited layer that can be followed by a [FollowerLayer].
+///
+/// This layer collapses the accumulated offset into a transform and passes
+/// [Offset.zero] to its child layers in the [addToScene]/[addChildrenToScene]
+/// methods, so that [applyTransform] will work reliably.
+class LeaderLayer extends ContainerLayer {
+ /// Creates a leader layer.
+ ///
+ /// The [link] property must not be null, and must not have been provided to
+ /// any other [LeaderLayer] layers that are [attached] to the layer tree at
+ /// the same time.
+ ///
+ /// The [offset] property must be non-null before the compositing phase of the
+ /// pipeline.
+ LeaderLayer({ @required this.link, this.offset: Offset.zero }) : assert(link != null);
+
+ /// The object with which this layer should register.
+ ///
+ /// The link will be established when this layer is [attach]ed, and will be
+ /// cleared when this layer is [detach]ed.
+ final LayerLink link;
+
+ /// Offset from parent in the parent's coordinate system.
+ ///
+ /// The scene must be explicitly recomposited after this property is changed
+ /// (as described at [Layer]).
+ ///
+ /// The [offset] property must be non-null before the compositing phase of the
+ /// pipeline.
+ Offset offset;
+
+ @override
+ void attach(Object owner) {
+ super.attach(owner);
+ assert(link.leader == null);
+ _lastOffset = null;
+ link._leader = this;
+ }
+
+ @override
+ void detach() {
+ assert(link.leader == this);
+ link._leader = null;
+ _lastOffset = null;
+ super.detach();
+ }
+
+ /// The offset the last time this layer was composited.
+ ///
+ /// This is reset to null when the layer is attached or detached, to help
+ /// catch cases where the follower layer ends up before the leader layer, but
+ /// not every case can be detected.
+ Offset _lastOffset;
+
+ @override
+ void addToScene(ui.SceneBuilder builder, Offset layerOffset) {
+ assert(offset != null);
+ _lastOffset = offset + layerOffset;
+ if (_lastOffset != Offset.zero)
+ builder.pushTransform(new Matrix4.translationValues(_lastOffset.dx, _lastOffset.dy, 0.0).storage);
+ addChildrenToScene(builder, Offset.zero);
+ if (_lastOffset != Offset.zero)
+ builder.pop();
+ }
+
+ /// Applies the transform that would be applied when compositing the given
+ /// child to the given matrix.
+ ///
+ /// See [ContainerLayer.applyTransform] for details.
+ ///
+ /// The `child` argument may be null, as the same transform is applied to all
+ /// children.
+ @override
+ void applyTransform(Layer child, Matrix4 transform) {
+ assert(_lastOffset != null);
+ if (_lastOffset != Offset.zero)
+ transform.translate(_lastOffset.dx, _lastOffset.dy);
+ }
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ description.add('offset: $offset');
+ description.add('link: $link');
+ }
+}
+
+/// A composited layer that applies a transformation matrix to its children such
+/// that they are positioned to match a [LeaderLayer].
+///
+/// If any of the ancestors of this layer have a degenerate matrix (e.g. scaling
+/// by zero), then the [FollowerLayer] will not be able to transform its child
+/// to the coordinate space of the [Leader].
+///
+/// A [linkedOffset] property can be provided to further offset the child layer
+/// from the leader layer, for example if the child is to follow the linked
+/// layer at a distance rather than directly overlapping it.
+class FollowerLayer extends ContainerLayer {
+ /// Creates a follower layer.
+ ///
+ /// The [link] property must not be null.
+ ///
+ /// The [unlinkedOffset], [linkedOffset], and [showWhenUnlinked] properties
+ /// must be non-null before the compositing phase of the pipeline.
+ FollowerLayer({
+ @required this.link,
+ this.showWhenUnlinked: true,
+ this.unlinkedOffset: Offset.zero,
+ this.linkedOffset: Offset.zero,
+ }) : assert(link != null);
+
+ /// The link to the [LeaderLayer].
+ ///
+ /// The same object should be provided to a [LeaderLayer] that is earlier in
+ /// the layer tree. When this layer is composited, it will apply a transform
+ /// that moves its children to match the position of the [LeaderLayer].
+ final LayerLink link;
+
+ /// Whether to show the layer's contents when the [link] does not point to a
+ /// [LeaderLayer].
+ ///
+ /// When the layer is linked, children layers are positioned such that they
+ /// have the same global position as the linked [LeaderLayer].
+ ///
+ /// When the layer is not linked, then: if [showWhenUnlinked] is true,
+ /// children are positioned as if the [FollowerLayer] was a [ContainerLayer];
+ /// if it is false, then children are hidden.
+ ///
+ /// The [showWhenUnlinked] property must be non-null before the compositing
+ /// phase of the pipeline.
+ bool showWhenUnlinked;
+
+ /// Offset from parent in the parent's coordinate system, used when the layer
+ /// is not linked to a [LeaderLayer].
+ ///
+ /// The scene must be explicitly recomposited after this property is changed
+ /// (as described at [Layer]).
+ ///
+ /// The [unlinkedOffset] property must be non-null before the compositing
+ /// phase of the pipeline.
+ ///
+ /// See also:
+ ///
+ /// * [linkedOffset], for when the layers are linked.
+ Offset unlinkedOffset;
+
+ /// Offset from the origin of the leader layer to the origin of the child
+ /// layers, used when the layer is linked to a [LeaderLayer].
+ ///
+ /// The scene must be explicitly recomposited after this property is changed
+ /// (as described at [Layer]).
+ ///
+ /// The [linkedOffset] property must be non-null before the compositing phase
+ /// of the pipeline.
+ ///
+ /// See also:
+ ///
+ /// * [unlinkedOffset], for when the layer is not linked.
+ Offset linkedOffset;
+
+ Offset _lastOffset;
+ Matrix4 _lastTransform;
+
+ /// The transform that was used during the last composition phase.
+ ///
+ /// If the [link] was not linked to a [LeaderLayer], or if this layer has
+ /// a degerenate matrix applied, then this will be null.
+ ///
+ /// This method returns a new [Matrix4] instance each time it is invoked.
+ Matrix4 getLastTransform() {
+ if (_lastTransform == null)
+ return null;
+ final Matrix4 result = new Matrix4.translationValues(-_lastOffset.dx, -_lastOffset.dy, 0.0);
+ result.multiply(_lastTransform);
+ return result;
+ }
+
+ /// Call [applyTransform] for each layer in the provided list.
+ ///
+ /// The list is in reverse order (deepest first). The first layer will be
+ /// treated as the child of the second, and so forth. The first layer in the
+ /// list won't have [applyTransform] called on it. The first layer may be
+ /// null.
+ Matrix4 _collectTransformForLayerChain(List<ContainerLayer> layers) {
+ // Initialize our result matrix.
+ final Matrix4 result = new Matrix4.identity();
+ // Apply each layer to the matrix in turn, starting from the last layer,
+ // and providing the previous layer as the child.
+ for (int index = layers.length - 1; index > 0; index -= 1)
+ layers[index].applyTransform(layers[index - 1], result);
+ return result;
+ }
+
+ /// Populate [_lastTransform] given the current state of the tree.
+ void _establishTransform() {
+ assert(link != null);
+ _lastTransform = null;
+ // Check to see if we are linked.
+ if (link.leader == null)
+ return;
+ // If we're linked, check the link is valid.
+ assert(link.leader.owner == owner, 'Linked LeaderLayer anchor is not in the same layer tree as the FollowerLayer.');
+ assert(link.leader._lastOffset != null, 'LeaderLayer anchor must come before FollowerLayer in paint order, but the reverse was true.');
+ // Collect all our ancestors into a Set so we can recognize them.
+ final Set<Layer> ancestors = new HashSet<Layer>();
+ Layer ancestor = parent;
+ while (ancestor != null) {
+ ancestors.add(ancestor);
+ ancestor = ancestor.parent;
+ }
+ // Collect all the layers from a hypothetical child (null) of the target
+ // layer up to the common ancestor layer.
+ ContainerLayer layer = link.leader;
+ final List<ContainerLayer> forwardLayers = <ContainerLayer>[null, layer];
+ do {
+ layer = layer.parent;
+ forwardLayers.add(layer);
+ } while (!ancestors.contains(layer));
+ ancestor = layer;
+ // Collect all the layers from this layer up to the common ancestor layer.
+ layer = this;
+ final List<ContainerLayer> inverseLayers = <ContainerLayer>[layer];
+ do {
+ layer = layer.parent;
+ inverseLayers.add(layer);
+ } while (layer != ancestor);
+ // Establish the forward and backward matrices given these lists of layers.
+ final Matrix4 forwardTransform = _collectTransformForLayerChain(forwardLayers);
+ final Matrix4 inverseTransform = _collectTransformForLayerChain(inverseLayers);
+ if (inverseTransform.invert() == 0.0) {
+ // We are in a degenerate transform, so there's not much we can do.
+ return;
+ }
+ // Combine the matrices and store the result.
+ inverseTransform.multiply(forwardTransform);
+ inverseTransform.translate(linkedOffset.dx, linkedOffset.dy);
+ _lastTransform = inverseTransform;
+ }
+
+ @override
+ void addToScene(ui.SceneBuilder builder, Offset layerOffset) {
+ assert(link != null);
+ assert(showWhenUnlinked != null);
+ if (link.leader == null && !showWhenUnlinked) {
+ _lastTransform = null;
+ _lastOffset = null;
+ return;
+ }
+ _establishTransform();
+ if (_lastTransform != null) {
+ builder.pushTransform(_lastTransform.storage);
+ addChildrenToScene(builder, Offset.zero);
+ builder.pop();
+ _lastOffset = unlinkedOffset + layerOffset;
+ } else {
+ _lastOffset = null;
+ addChildrenToScene(builder, unlinkedOffset + layerOffset);
+ }
+ }
+
+ @override
+ void applyTransform(Layer child, Matrix4 transform) {
+ assert(child != null);
+ assert(transform != null);
+ if (_lastTransform != null)
+ transform.multiply(_lastTransform);
+ }
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ description.add('link: $link');
+ if (_lastTransform != null) {
+ description.add('transform:');
+ description.addAll(debugDescribeTransform(getLastTransform()));
+ }
+ }
+}
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index 1cad998..6e248e0 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -56,13 +56,29 @@
/// child might be recorded in separate compositing layers. For this reason, do
/// not hold a reference to the canvas across operations that might paint
/// child render objects.
+///
+/// New [PaintingContext] objects are created automatically when using
+/// [PaintingContext.repaintCompositedChild] and [pushLayer].
class PaintingContext {
- PaintingContext._(this._containerLayer, this._paintBounds)
+ PaintingContext._(this._containerLayer, this.canvasBounds)
: assert(_containerLayer != null),
- assert(_paintBounds != null);
+ assert(canvasBounds != null);
final ContainerLayer _containerLayer;
- final Rect _paintBounds;
+
+ /// The bounds within which the painting context's [canvas] will record
+ /// painting commands.
+ ///
+ /// A render object provided with this [PaintingContext] (e.g. in its
+ /// [RenderObject.paint] method) is permitted to paint outside the region that
+ /// the render object occupies during layout, but is not permitted to paint
+ /// outside these paints bounds. These paint bounds are used to construct
+ /// memory-efficient composited layers, which means attempting to paint
+ /// outside these bounds can attempt to write to pixels that do not exist in
+ /// the composited layer.
+ ///
+ /// The [paintBounds] rectangle is in the [canvas] coordinate system.
+ final Rect canvasBounds;
/// Repaint the given render object.
///
@@ -70,6 +86,11 @@
/// composited layer, and must be in need of painting. The render object's
/// layer, if any, is re-used, along with any layers in the subtree that don't
/// need to be repainted.
+ ///
+ /// See also:
+ ///
+ /// * [RenderObject.isRepaintBoundary], which determines if a [RenderObject]
+ /// has a composited layer.
static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent: false }) {
assert(child.isRepaintBoundary);
assert(child._needsPaint);
@@ -97,7 +118,7 @@
childContext._stopRecordingIfNeeded();
}
- /// Paint a child render object.
+ /// Paint a child [RenderObject].
///
/// If the child has its own composited layer, the child will be composited
/// into the layer subtree associated with this painting context. Otherwise,
@@ -180,6 +201,8 @@
/// The current canvas can change whenever you paint a child using this
/// context, which means it's fragile to hold a reference to the canvas
/// returned by this getter.
+ ///
+ /// Only calls within the [canvasBounds] will be recorded.
Canvas get canvas {
if (_canvas == null)
_startRecording();
@@ -188,9 +211,9 @@
void _startRecording() {
assert(!_isRecording);
- _currentLayer = new PictureLayer();
+ _currentLayer = new PictureLayer(canvasBounds);
_recorder = new ui.PictureRecorder();
- _canvas = new Canvas(_recorder, _paintBounds);
+ _canvas = new Canvas(_recorder, canvasBounds);
_containerLayer.append(_currentLayer);
}
@@ -203,14 +226,14 @@
..style = PaintingStyle.stroke
..strokeWidth = 6.0
..color = debugCurrentRepaintColor.toColor();
- canvas.drawRect(_paintBounds.deflate(3.0), paint);
+ canvas.drawRect(canvasBounds.deflate(3.0), paint);
}
if (debugPaintLayerBordersEnabled) {
final Paint paint = new Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..color = debugPaintLayerBordersColor;
- canvas.drawRect(_paintBounds, paint);
+ canvas.drawRect(canvasBounds, paint);
}
return true;
});
@@ -262,7 +285,7 @@
}
/// Appends the given layer to the recording, and calls the `painter` callback
- /// with that layer, providing the [childPaintBounds] as the paint bounds of
+ /// with that layer, providing the `childPaintBounds` as the paint bounds of
/// the child. Canvas recording commands are not guaranteed to be stored
/// outside of the paint bounds.
///
@@ -272,9 +295,11 @@
///
/// The `offset` is the offset to pass to the `painter`.
///
- /// If the `childPaintBounds` are not specified then the current layer's
+ /// If the `childPaintBounds` are not specified then the current layer's paint
/// bounds are used. This is appropriate if the child layer does not apply any
- /// transformation or clipping to its contents.
+ /// transformation or clipping to its contents. The `childPaintBounds`, if
+ /// specified, must be in the coordinate system of the new layer, and should
+ /// not go outside the current layer's paint bounds.
///
/// See also:
///
@@ -285,7 +310,7 @@
assert(painter != null);
_stopRecordingIfNeeded();
_appendLayer(childLayer);
- final PaintingContext childContext = new PaintingContext._(childLayer, childPaintBounds ?? _paintBounds);
+ final PaintingContext childContext = new PaintingContext._(childLayer, childPaintBounds ?? canvasBounds);
painter(childContext, offset);
childContext._stopRecordingIfNeeded();
}
@@ -379,7 +404,7 @@
new TransformLayer(transform: effectiveTransform),
painter,
offset,
- childPaintBounds: MatrixUtils.inverseTransformRect(effectiveTransform, _paintBounds),
+ childPaintBounds: MatrixUtils.inverseTransformRect(effectiveTransform, canvasBounds),
);
} else {
canvas.save();
@@ -406,6 +431,9 @@
void pushOpacity(Offset offset, int alpha, PaintingContextCallback painter) {
pushLayer(new OpacityLayer(alpha: alpha), painter, offset);
}
+
+ @override
+ String toString() => '$runtimeType#$hashCode(layer: $_containerLayer, canvas bounds: $canvasBounds)';
}
/// An abstract set of layout constraints.
@@ -1981,6 +2009,9 @@
/// frequently might want to repaint themselves without requiring their parent
/// to repaint.
///
+ /// If this getter returns true, the [paintBounds] are applied to this object
+ /// and all descendants.
+ ///
/// Warning: This getter must not change value over the lifetime of this object.
bool get isRepaintBoundary => false;
@@ -2272,12 +2303,15 @@
/// The bounds within which this render object will paint.
///
- /// A render object is permitted to paint outside the region it occupies
- /// during layout but is not permitted to paint outside these paints bounds.
- /// These paint bounds are used to construct memory-efficient composited
- /// layers, which means attempting to paint outside these bounds can attempt
- /// to write to pixels that do not exist in this render object's composited
- /// layer.
+ /// A render object and its descendants are permitted to paint outside the
+ /// region it occupies during layout, but they are not permitted to paint
+ /// outside these paints bounds. These paint bounds are used to construct
+ /// memory-efficient composited layers, which means attempting to paint
+ /// outside these bounds can attempt to write to pixels that do not exist in
+ /// this render object's composited layer.
+ ///
+ /// The [paintBounds] are only actually enforced when the render object is a
+ /// repaint boundary; see [isRepaintBoundary].
Rect get paintBounds;
/// Override this method to paint debugging information.
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index bb4b7e7..4453c87 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -1636,7 +1636,7 @@
Matrix4 inverse;
try {
inverse = new Matrix4.inverted(_effectiveTransform);
- } catch (e) {
+ } on ArgumentError {
// We cannot invert the effective transform. That means the child
// doesn't appear on screen and cannot be hit.
return false;
@@ -1661,7 +1661,6 @@
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
transform.multiply(_effectiveTransform);
- super.applyPaintTransform(child, transform);
}
@override
@@ -1785,7 +1784,7 @@
Matrix4 inverse;
try {
inverse = new Matrix4.inverted(_transform);
- } catch (e) {
+ } on ArgumentError {
// We cannot invert the effective transform. That means the child
// doesn't appear on screen and cannot be hit.
return false;
@@ -1798,7 +1797,6 @@
void applyPaintTransform(RenderBox child, Matrix4 transform) {
_updatePaintData();
transform.multiply(_transform);
- super.applyPaintTransform(child, transform);
}
@override
@@ -1864,7 +1862,6 @@
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
transform.translate(translation.dx * size.width, translation.dy * size.height);
- super.applyPaintTransform(child, transform);
}
@override
@@ -3046,3 +3043,194 @@
description.add('excluding: $excluding');
}
}
+
+/// Provides an anchor for a [RenderFollowerLayer].
+///
+/// See also:
+///
+/// * [CompositedTransformTarget], the corresponding widget.
+/// * [LeaderLayer], the layer that this render object creates.
+class RenderLeaderLayer extends RenderProxyBox {
+ /// Creates a render object that uses a [LeaderLayer].
+ ///
+ /// The [link] must not be null.
+ RenderLeaderLayer({
+ @required LayerLink link,
+ RenderBox child,
+ }) : assert(link != null),
+ super(child) {
+ this.link = link;
+ }
+
+ /// The link object that connects this [RenderLeaderLayer] with one or more
+ /// [RenderFollowerLayer]s.
+ ///
+ /// This property must not be null. The object must not be associated with
+ /// another [RenderLeaderLayer] that is also being painted.
+ LayerLink get link => _link;
+ LayerLink _link;
+ set link(LayerLink value) {
+ assert(value != null);
+ if (_link == value)
+ return;
+ _link = value;
+ markNeedsPaint();
+ }
+
+ @override
+ bool get alwaysNeedsCompositing => true;
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ context.pushLayer(new LeaderLayer(link: link, offset: offset), super.paint, Offset.zero);
+ }
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ description.add('link: $link');
+ }
+}
+
+/// Transform the child so that its origin is [offset] from the orign of the
+/// [RenderLeaderLayer] with the same [LayerLink].
+///
+/// The [RenderLeaderLayer] in question must be earlier in the paint order.
+///
+/// Hit testing on descendants of this render object will only work if the
+/// target position is within the box that this render object's parent considers
+/// to be hitable.
+///
+/// See also:
+///
+/// * [CompositedTransformFollower], the corresponding widget.
+/// * [FollowerLayer], the layer that this render object creates.
+class RenderFollowerLayer extends RenderProxyBox {
+ /// Creates a render object that uses a [FollowerLayer].
+ ///
+ /// The [link] and [offset] arguments must not be null.
+ RenderFollowerLayer({
+ @required LayerLink link,
+ bool showWhenUnlinked: true,
+ Offset offset: Offset.zero,
+ RenderBox child,
+ }) : assert(link != null),
+ assert(showWhenUnlinked != null),
+ assert(offset != null),
+ super(child) {
+ this.link = link;
+ this.showWhenUnlinked = showWhenUnlinked;
+ this.offset = offset;
+ }
+
+ /// The link object that connects this [RenderFollowerLayer] with a
+ /// [RenderLeaderLayer] earlier in the paint order.
+ LayerLink get link => _link;
+ LayerLink _link;
+ set link(LayerLink value) {
+ assert(value != null);
+ if (_link == value)
+ return;
+ _link = value;
+ markNeedsPaint();
+ }
+
+ /// Whether to show the render object's contents when there is no
+ /// corresponding [RenderLeaderLayer] with the same [link].
+ ///
+ /// When the render object is linked, the child is positioned such that it has
+ /// the same global position as the linked [RenderLeaderLayer].
+ ///
+ /// When the render object is not linked, then: if [showWhenUnlinked] is true,
+ /// the child is visible and not repositioned; if it is false, then child is
+ /// hidden.
+ bool get showWhenUnlinked => _showWhenUnlinked;
+ bool _showWhenUnlinked;
+ set showWhenUnlinked(bool value) {
+ assert(value != null);
+ if (_showWhenUnlinked == value)
+ return;
+ _showWhenUnlinked = value;
+ markNeedsPaint();
+ }
+
+ /// The offset to apply to the origin of the linked [RenderLeaderLayer] to
+ /// obtain this render object's origin.
+ Offset get offset => _offset;
+ Offset _offset;
+ set offset(Offset value) {
+ assert(value != null);
+ if (_offset == value)
+ return;
+ _offset = value;
+ markNeedsPaint();
+ }
+
+ @override
+ void detach() {
+ _layer = null;
+ super.detach();
+ }
+
+ @override
+ bool get alwaysNeedsCompositing => true;
+
+ /// The layer we created when we were last painted.
+ FollowerLayer _layer;
+
+ Matrix4 getCurrentTransform() {
+ return _layer?.getLastTransform() ?? new Matrix4.identity();
+ }
+
+ @override
+ bool hitTest(HitTestResult result, { Offset position }) {
+ Matrix4 inverse;
+ try {
+ inverse = new Matrix4.inverted(getCurrentTransform());
+ } on ArgumentError {
+ // We cannot invert the effective transform. That means the child
+ // doesn't appear on screen and cannot be hit.
+ return false;
+ }
+ position = MatrixUtils.transformPoint(inverse, position);
+ return super.hitTest(result, position: position);
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ assert(showWhenUnlinked != null);
+ _layer = new FollowerLayer(
+ link: link,
+ showWhenUnlinked: showWhenUnlinked,
+ linkedOffset: this.offset,
+ unlinkedOffset: offset,
+ );
+ context.pushLayer(
+ _layer,
+ super.paint,
+ Offset.zero,
+ childPaintBounds: new Rect.fromLTRB(
+ // We don't know where we'll end up, so we have no idea what our cull rect should be.
+ double.NEGATIVE_INFINITY,
+ double.NEGATIVE_INFINITY,
+ double.INFINITY,
+ double.INFINITY,
+ ),
+ );
+ }
+
+ @override
+ void applyPaintTransform(RenderBox child, Matrix4 transform) {
+ transform.multiply(getCurrentTransform());
+ }
+
+ @override
+ void debugFillDescription(List<String> description) {
+ super.debugFillDescription(description);
+ description.add('link: $link');
+ description.add('showWhenUnlinked: $showWhenUnlinked');
+ description.add('offset: $offset');
+ description.add('current transform matrix:');
+ description.addAll(debugDescribeTransform(getCurrentTransform()));
+ }
+}
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index bf462de..6444b41 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -26,6 +26,7 @@
FlowPaintingContext,
FractionalOffsetTween,
HitTestBehavior,
+ LayerLink,
MainAxisAlignment,
MainAxisSize,
MultiChildLayoutDelegate,
@@ -301,11 +302,13 @@
final Size size;
@override
- RenderCustomPaint createRenderObject(BuildContext context) => new RenderCustomPaint(
- painter: painter,
- foregroundPainter: foregroundPainter,
- preferredSize: size,
- );
+ RenderCustomPaint createRenderObject(BuildContext context) {
+ return new RenderCustomPaint(
+ painter: painter,
+ foregroundPainter: foregroundPainter,
+ preferredSize: size,
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderCustomPaint renderObject) {
@@ -711,12 +714,14 @@
final bool transformHitTests;
@override
- RenderTransform createRenderObject(BuildContext context) => new RenderTransform(
- transform: transform,
- origin: origin,
- alignment: alignment,
- transformHitTests: transformHitTests
- );
+ RenderTransform createRenderObject(BuildContext context) {
+ return new RenderTransform(
+ transform: transform,
+ origin: origin,
+ alignment: alignment,
+ transformHitTests: transformHitTests
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderTransform renderObject) {
@@ -728,6 +733,140 @@
}
}
+/// A widget that can be targetted by a [CompositedTransformFollower].
+///
+/// When this widget is composited during the compositing phase (which comes
+/// after the paint phase, as described in [WidgetsBinding.drawFrame]), it
+/// updates the [link] object so that any [CompositedTransformFollower] widgets
+/// that are subsequently composited in the same frame and were given the same
+/// [LayerLink] can position themselves at the same screen location.
+///
+/// A single [CompositedTransformTarget] can be followed by multiple
+/// [CompositedTransformFollower] widgets.
+///
+/// The [CompositedTransformTarget] must come earlier in the paint order than
+/// any linked [CompositedTransformFollower]s.
+///
+/// See also:
+///
+/// * [CompositedTransformFollower], the widget that can target this one.
+/// * [LeaderLayer], the layer that implements this widget's logic.
+class CompositedTransformTarget extends SingleChildRenderObjectWidget {
+ /// Creates a composited transform target widget.
+ ///
+ /// The [link] property must not be null, and must not be currently being used
+ /// by any other [CompositedTransformTarget] object that is in the tree.
+ const CompositedTransformTarget({
+ Key key,
+ @required this.link,
+ Widget child,
+ }) : assert(link != null),
+ super(key: key, child: child);
+
+ /// The link object that connects this [CompositedTransformTarget] with one or
+ /// more [CompositedTransformFollower]s.
+ ///
+ /// This property must not be null. The object must not be associated with
+ /// another [CompositedTransformTarget] that is also being painted.
+ final LayerLink link;
+
+ @override
+ RenderLeaderLayer createRenderObject(BuildContext context) {
+ return new RenderLeaderLayer(
+ link: link,
+ );
+ }
+
+ @override
+ void updateRenderObject(BuildContext context, RenderLeaderLayer renderObject) {
+ renderObject
+ ..link = link;
+ }
+}
+
+/// A widget that follows a [CompositedTransformTarget].
+///
+/// When this widget is composited during the compositing phase (which comes
+/// after the paint phase, as described in [WidgetsBinding.drawFrame]), it
+/// applies a transformation that causes it to provide its child with a
+/// coordinate space that matches that of the linked [CompositedTransformTarget]
+/// widget, offset by [offset].
+///
+/// The [LayerLink] object used as the [link] must be the same object as that
+/// provided to the matching [CompositedTransformTarget].
+///
+/// The [CompositedTransformTarget] must come earlier in the paint order than
+/// this [CompositedTransformFollower].
+///
+/// Hit testing on descendants of this widget will only work if the target
+/// position is within the box that this widget's parent considers to be
+/// hitable. If the parent covers the screen, this is trivially achievable, so
+/// this widget is usually used as the root of an [OverlayEntry] in an app-wide
+/// [Overlay] (e.g. as created by the [MaterialApp] widget's [Navigator]).
+///
+/// See also:
+///
+/// * [CompositedTransformTarget], the widget that this widget can target.
+/// * [FollowerLayer], the layer that implements this widget's logic.
+/// * [Transform], which applies an arbitrary transform to a child.
+class CompositedTransformFollower extends SingleChildRenderObjectWidget {
+ /// Creates a composited transform target widget.
+ ///
+ /// The [link] property must not be null. If it was also provided to a
+ /// [CompositedTransformTarget], that widget must come earlier in the paint
+ /// order.
+ ///
+ /// The [showWhenUnlinked] and [offset] properties must also not be null.
+ const CompositedTransformFollower({
+ Key key,
+ @required this.link,
+ this.showWhenUnlinked: true,
+ this.offset: Offset.zero,
+ Widget child,
+ }) : assert(link != null),
+ assert(showWhenUnlinked != null),
+ assert(offset != null),
+ super(key: key, child: child);
+
+ /// The link object that connects this [CompositedTransformFollower] with a
+ /// [CompositedTransformTarget].
+ ///
+ /// This property must not be null.
+ final LayerLink link;
+
+ /// Whether to show the widget's contents when there is no corresponding
+ /// [CompositedTransformTarget] with the same [link].
+ ///
+ /// When the widget is linked, the child is positioned such that it has the
+ /// same global position as the linked [CompositedTransformTarget].
+ ///
+ /// When the widget is not linked, then: if [showWhenUnlinked] is true, the
+ /// child is visible and not repositioned; if it is false, then child is
+ /// hidden.
+ final bool showWhenUnlinked;
+
+ /// The offset to apply to the origin of the linked
+ /// [CompositedTransformTarget] to obtain this widget's origin.
+ final Offset offset;
+
+ @override
+ RenderFollowerLayer createRenderObject(BuildContext context) {
+ return new RenderFollowerLayer(
+ link: link,
+ showWhenUnlinked: showWhenUnlinked,
+ offset: offset,
+ );
+ }
+
+ @override
+ void updateRenderObject(BuildContext context, RenderFollowerLayer renderObject) {
+ renderObject
+ ..link = link
+ ..showWhenUnlinked = showWhenUnlinked
+ ..offset = offset;
+ }
+}
+
/// Scales and positions its child within itself according to [fit].
///
/// See also:
@@ -1207,9 +1346,11 @@
final double height;
@override
- RenderConstrainedBox createRenderObject(BuildContext context) => new RenderConstrainedBox(
- additionalConstraints: _additionalConstraints,
- );
+ RenderConstrainedBox createRenderObject(BuildContext context) {
+ return new RenderConstrainedBox(
+ additionalConstraints: _additionalConstraints,
+ );
+ }
BoxConstraints get _additionalConstraints {
return new BoxConstraints.tightFor(width: width, height: height);
@@ -1353,11 +1494,13 @@
final FractionalOffset alignment;
@override
- RenderFractionallySizedOverflowBox createRenderObject(BuildContext context) => new RenderFractionallySizedOverflowBox(
- alignment: alignment,
- widthFactor: widthFactor,
- heightFactor: heightFactor
- );
+ RenderFractionallySizedOverflowBox createRenderObject(BuildContext context) {
+ return new RenderFractionallySizedOverflowBox(
+ alignment: alignment,
+ widthFactor: widthFactor,
+ heightFactor: heightFactor
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderFractionallySizedOverflowBox renderObject) {
@@ -1423,10 +1566,12 @@
final double maxHeight;
@override
- RenderLimitedBox createRenderObject(BuildContext context) => new RenderLimitedBox(
- maxWidth: maxWidth,
- maxHeight: maxHeight
- );
+ RenderLimitedBox createRenderObject(BuildContext context) {
+ return new RenderLimitedBox(
+ maxWidth: maxWidth,
+ maxHeight: maxHeight
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderLimitedBox renderObject) {
@@ -1489,13 +1634,15 @@
final double maxHeight;
@override
- RenderConstrainedOverflowBox createRenderObject(BuildContext context) => new RenderConstrainedOverflowBox(
- alignment: alignment,
- minWidth: minWidth,
- maxWidth: maxWidth,
- minHeight: minHeight,
- maxHeight: maxHeight
- );
+ RenderConstrainedOverflowBox createRenderObject(BuildContext context) {
+ return new RenderConstrainedOverflowBox(
+ alignment: alignment,
+ minWidth: minWidth,
+ maxWidth: maxWidth,
+ minHeight: minHeight,
+ maxHeight: maxHeight
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderConstrainedOverflowBox renderObject) {
@@ -3196,18 +3343,20 @@
final Rect centerSlice;
@override
- RenderImage createRenderObject(BuildContext context) => new RenderImage(
- image: image,
- width: width,
- height: height,
- scale: scale,
- color: color,
- colorBlendMode: colorBlendMode,
- fit: fit,
- alignment: alignment,
- repeat: repeat,
- centerSlice: centerSlice
- );
+ RenderImage createRenderObject(BuildContext context) {
+ return new RenderImage(
+ image: image,
+ width: width,
+ height: height,
+ scale: scale,
+ color: color,
+ colorBlendMode: colorBlendMode,
+ fit: fit,
+ alignment: alignment,
+ repeat: repeat,
+ centerSlice: centerSlice
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderImage renderObject) {
@@ -3371,13 +3520,15 @@
final HitTestBehavior behavior;
@override
- RenderPointerListener createRenderObject(BuildContext context) => new RenderPointerListener(
- onPointerDown: onPointerDown,
- onPointerMove: onPointerMove,
- onPointerUp: onPointerUp,
- onPointerCancel: onPointerCancel,
- behavior: behavior
- );
+ RenderPointerListener createRenderObject(BuildContext context) {
+ return new RenderPointerListener(
+ onPointerDown: onPointerDown,
+ onPointerMove: onPointerMove,
+ onPointerUp: onPointerUp,
+ onPointerCancel: onPointerCancel,
+ behavior: behavior
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderPointerListener renderObject) {
@@ -3499,10 +3650,12 @@
final bool ignoringSemantics;
@override
- RenderIgnorePointer createRenderObject(BuildContext context) => new RenderIgnorePointer(
- ignoring: ignoring,
- ignoringSemantics: ignoringSemantics
- );
+ RenderIgnorePointer createRenderObject(BuildContext context) {
+ return new RenderIgnorePointer(
+ ignoring: ignoring,
+ ignoringSemantics: ignoringSemantics
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderIgnorePointer renderObject) {
@@ -3583,10 +3736,12 @@
final HitTestBehavior behavior;
@override
- RenderMetaData createRenderObject(BuildContext context) => new RenderMetaData(
- metaData: metaData,
- behavior: behavior
- );
+ RenderMetaData createRenderObject(BuildContext context) {
+ return new RenderMetaData(
+ metaData: metaData,
+ behavior: behavior
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderMetaData renderObject) {
@@ -3668,12 +3823,14 @@
final String label;
@override
- RenderSemanticsAnnotations createRenderObject(BuildContext context) => new RenderSemanticsAnnotations(
- container: container,
- checked: checked,
- selected: selected,
- label: label,
- );
+ RenderSemanticsAnnotations createRenderObject(BuildContext context) {
+ return new RenderSemanticsAnnotations(
+ container: container,
+ checked: checked,
+ selected: selected,
+ label: label,
+ );
+ }
@override
void updateRenderObject(BuildContext context, RenderSemanticsAnnotations renderObject) {
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index eb08c06..7ac573f 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -263,6 +263,7 @@
TextSelectionOverlay _selectionOverlay;
final ScrollController _scrollController = new ScrollController();
+ final LayerLink _layerLink = new LayerLink();
bool _didAutoFocus = false;
// State lifecycle:
@@ -272,6 +273,7 @@
super.initState();
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
+ _scrollController.addListener(() { _selectionOverlay?.updateForScroll(); });
}
@override
@@ -436,6 +438,7 @@
context: context,
value: _value,
debugRequiredFor: widget,
+ layerLink: _layerLink,
renderObject: renderObject,
onSelectionOverlayChanged: _handleSelectionOverlayChanged,
selectionControls: widget.selectionControls,
@@ -538,19 +541,22 @@
controller: _scrollController,
physics: const ClampingScrollPhysics(),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
- return new _Editable(
- value: _value,
- style: widget.style,
- cursorColor: widget.cursorColor,
- showCursor: _showCursor,
- maxLines: widget.maxLines,
- selectionColor: widget.selectionColor,
- textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0,
- textAlign: widget.textAlign,
- obscureText: widget.obscureText,
- offset: offset,
- onSelectionChanged: _handleSelectionChanged,
- onCaretChanged: _handleCaretChanged,
+ return new CompositedTransformTarget(
+ link: _layerLink,
+ child: new _Editable(
+ value: _value,
+ style: widget.style,
+ cursorColor: widget.cursorColor,
+ showCursor: _showCursor,
+ maxLines: widget.maxLines,
+ selectionColor: widget.selectionColor,
+ textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0,
+ textAlign: widget.textAlign,
+ obscureText: widget.obscureText,
+ offset: offset,
+ onSelectionChanged: _handleSelectionChanged,
+ onCaretChanged: _handleCaretChanged,
+ ),
);
},
);
diff --git a/packages/flutter/lib/src/widgets/scroll_controller.dart b/packages/flutter/lib/src/widgets/scroll_controller.dart
index 4f8b268..aeb1dea 100644
--- a/packages/flutter/lib/src/widgets/scroll_controller.dart
+++ b/packages/flutter/lib/src/widgets/scroll_controller.dart
@@ -24,6 +24,10 @@
/// to an individual [Scrollable] widget. To use a custom [ScrollPosition],
/// subclass [ScrollController] and override [createScrollPosition].
///
+/// A [ScrollController] is a [Listenable]. It notifies its listeners whenever
+/// any of the attached [ScrollPosition]s notify _their_ listeners (i.e.
+/// whenever any of them scroll).
+///
/// Typically used with [ListView], [GridView], [CustomScrollView].
///
/// See also:
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index d589eb5..e5a8846 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -73,8 +73,7 @@
/// Builds a toolbar near a text selection.
///
/// Typically displays buttons for copying and pasting text.
- // TODO(mpcomplete): A single position is probably insufficient.
- Widget buildToolbar(BuildContext context, Offset position, TextSelectionDelegate delegate);
+ Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate);
/// Returns the size of the selection handle.
Size get handleSize;
@@ -92,7 +91,8 @@
@required TextEditingValue value,
@required this.context,
this.debugRequiredFor,
- this.renderObject,
+ @required this.layerLink,
+ @required this.renderObject,
this.onSelectionOverlayChanged,
this.selectionControls,
}): assert(value != null),
@@ -113,6 +113,10 @@
/// Debugging information for explaining why the [Overlay] is required.
final Widget debugRequiredFor;
+ /// The object supplied to the [CompositedTransformTarget] that wraps the text
+ /// field.
+ final LayerLink layerLink;
+
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
/// The editable line in which the selected text is being displayed.
@@ -149,8 +153,8 @@
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
- new OverlayEntry(builder: (BuildContext c) => _buildHandle(c, _TextSelectionHandlePosition.start)),
- new OverlayEntry(builder: (BuildContext c) => _buildHandle(c, _TextSelectionHandlePosition.end)),
+ new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)),
+ new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)),
];
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
_handleController.forward(from: 0.0);
@@ -184,6 +188,14 @@
}
}
+ /// Causes the overlay to update its rendering.
+ ///
+ /// This is intended to be called when the [renderObject] may have changed its
+ /// text metrics (e.g. because the text was scrolled).
+ void updateForScroll() {
+ _markNeedsBuild();
+ }
+
void _markNeedsBuild([Duration duration]) {
if (_handles != null) {
_handles[0].markNeedsBuild();
@@ -223,10 +235,11 @@
child: new _TextSelectionHandleOverlay(
onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped,
+ layerLink: layerLink,
renderObject: renderObject,
selection: _selection,
selectionControls: selectionControls,
- position: position
+ position: position,
)
);
}
@@ -241,12 +254,22 @@
(endpoints.length == 1) ?
endpoints[0].point.dx :
(endpoints[0].point.dx + endpoints[1].point.dx) / 2.0,
- endpoints[0].point.dy - renderObject.size.height
+ endpoints[0].point.dy - renderObject.size.height,
+ );
+
+ final Rect editingRegion = new Rect.fromPoints(
+ renderObject.localToGlobal(Offset.zero),
+ renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
);
return new FadeTransition(
opacity: _toolbarOpacity,
- child: selectionControls.buildToolbar(context, midpoint, this)
+ child: new CompositedTransformFollower(
+ link: layerLink,
+ showWhenUnlinked: false,
+ offset: -editingRegion.topLeft,
+ child: selectionControls.buildToolbar(context, editingRegion, midpoint, this),
+ ),
);
}
@@ -298,16 +321,18 @@
class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({
Key key,
- this.selection,
- this.position,
- this.renderObject,
- this.onSelectionHandleChanged,
- this.onSelectionHandleTapped,
- this.selectionControls
+ @required this.selection,
+ @required this.position,
+ @required this.layerLink,
+ @required this.renderObject,
+ @required this.onSelectionHandleChanged,
+ @required this.onSelectionHandleTapped,
+ @required this.selectionControls
}) : super(key: key);
final TextSelection selection;
final _TextSelectionHandlePosition position;
+ final LayerLink layerLink;
final RenderEditable renderObject;
final ValueChanged<TextSelection> onSelectionHandleChanged;
final VoidCallback onSelectionHandleTapped;
@@ -379,19 +404,23 @@
break;
}
- return new GestureDetector(
- onPanStart: _handleDragStart,
- onPanUpdate: _handleDragUpdate,
- onTap: _handleTap,
- child: new Stack(
- children: <Widget>[
- new Positioned(
- left: point.dx,
- top: point.dy,
- child: widget.selectionControls.buildHandle(context, type)
- )
- ]
- )
+ return new CompositedTransformFollower(
+ link: widget.layerLink,
+ showWhenUnlinked: false,
+ child: new GestureDetector(
+ onPanStart: _handleDragStart,
+ onPanUpdate: _handleDragUpdate,
+ onTap: _handleTap,
+ child: new Stack(
+ children: <Widget>[
+ new Positioned(
+ left: point.dx,
+ top: point.dy,
+ child: widget.selectionControls.buildHandle(context, type),
+ ),
+ ],
+ ),
+ ),
);
}
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 7382e83..530e546 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -27,7 +27,7 @@
Widget overlay(Widget child) {
return new MediaQuery(
- data: const MediaQueryData(),
+ data: const MediaQueryData(size: const Size(800.0, 600.0)),
child: new Overlay(
initialEntries: <OverlayEntry>[
new OverlayEntry(
@@ -73,10 +73,22 @@
return renderEditable;
}
+ List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
+ return points.map((TextSelectionPoint point) {
+ return new TextSelectionPoint(
+ box.localToGlobal(point.point),
+ point.direction,
+ );
+ }).toList();
+ }
+
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(
- new TextSelection.collapsed(offset: offset),
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(
+ new TextSelection.collapsed(offset: offset),
+ ),
+ renderEditable,
);
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(0.0, -2.0);
@@ -309,15 +321,19 @@
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(selection);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(selection),
+ renderEditable,
+ );
expect(endpoints.length, 2);
// Drag the right handle 2 letters to the right.
- // Note: use a small offset because the endpoint is on the very corner
+ // We use a small offset because the endpoint is on the very corner
// of the handle.
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
Offset newHandlePos = textOffsetToPosition(tester, selection.extentOffset+2);
@@ -368,10 +384,15 @@
// Tap the selection handle to bring up the "paste / select all" menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
RenderEditable renderEditable = findRenderEditable(tester);
- List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(controller.selection);
+ List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
// SELECT ALL should select all the text.
await tester.tap(find.text('SELECT ALL'));
@@ -388,10 +409,15 @@
// Tap again to bring back the menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
renderEditable = findRenderEditable(tester);
- endpoints = renderEditable.getEndpointsForSelection(controller.selection);
+ endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
// PASTE right before the 'e'.
await tester.tap(find.text('PASTE'));
@@ -422,8 +448,12 @@
// Tap the selection handle to bring up the "paste / select all" menu.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(controller.selection);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());
@@ -547,12 +577,16 @@
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
expect(controller.selection.baseOffset, 39);
expect(controller.selection.extentOffset, 44);
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(controller.selection);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
expect(endpoints.length, 2);
// Drag the right handle to the third line, just after 'Third'.
@@ -653,7 +687,10 @@
await tester.pump(const Duration(seconds: 1));
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints = renderEditable.getEndpointsForSelection(controller.selection);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(controller.selection),
+ renderEditable,
+ );
expect(endpoints.length, 2);
// Drag the left handle to the first line, just after 'First'.
@@ -1410,11 +1447,15 @@
await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2')));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
final RenderEditable renderEditable = findRenderEditable(tester);
- final List<TextSelectionPoint> endpoints =
- renderEditable.getEndpointsForSelection(textController.selection);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(textController.selection),
+ renderEditable,
+ );
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());
+ await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
Clipboard.setData(const ClipboardData(text: '一4二\n5三6'));
await tester.tap(find.text('PASTE'));
diff --git a/packages/flutter/test/widgets/composited_transform_test.dart b/packages/flutter/test/widgets/composited_transform_test.dart
new file mode 100644
index 0000000..e293b8c
--- /dev/null
+++ b/packages/flutter/test/widgets/composited_transform_test.dart
@@ -0,0 +1,166 @@
+// Copyright 2017 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 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+void main() {
+ testWidgets('Composited transforms - only offsets', (WidgetTester tester) async {
+ final LayerLink link = new LayerLink();
+ final GlobalKey key = new GlobalKey();
+ await tester.pumpWidget(
+ new Stack(
+ children: <Widget>[
+ new Positioned(
+ left: 123.0,
+ top: 456.0,
+ child: new CompositedTransformTarget(
+ link: link,
+ child: new Container(height: 10.0, width: 10.0),
+ ),
+ ),
+ new Positioned(
+ left: 787.0,
+ top: 343.0,
+ child: new CompositedTransformFollower(
+ link: link,
+ child: new Container(key: key, height: 10.0, width: 10.0),
+ ),
+ ),
+ ],
+ ),
+ );
+ final RenderBox box = key.currentContext.findRenderObject();
+ expect(box.localToGlobal(Offset.zero), const Offset(123.0, 456.0));
+ });
+
+ testWidgets('Composited transforms - with rotations', (WidgetTester tester) async {
+ final LayerLink link = new LayerLink();
+ final GlobalKey key1 = new GlobalKey();
+ final GlobalKey key2 = new GlobalKey();
+ await tester.pumpWidget(
+ new Stack(
+ children: <Widget>[
+ new Positioned(
+ top: 123.0,
+ left: 456.0,
+ child: new Transform.rotate(
+ angle: 1.0, // radians
+ child: new CompositedTransformTarget(
+ link: link,
+ child: new Container(key: key1, height: 10.0, width: 10.0),
+ ),
+ ),
+ ),
+ new Positioned(
+ top: 787.0,
+ left: 343.0,
+ child: new Transform.rotate(
+ angle: -0.3, // radians
+ child: new CompositedTransformFollower(
+ link: link,
+ child: new Container(key: key2, height: 10.0, width: 10.0),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ final RenderBox box1 = key1.currentContext.findRenderObject();
+ final RenderBox box2 = key2.currentContext.findRenderObject();
+ final Offset position1 = box1.localToGlobal(Offset.zero);
+ final Offset position2 = box2.localToGlobal(Offset.zero);
+ expect(position1.dx, moreOrLessEquals(position2.dx));
+ expect(position1.dy, moreOrLessEquals(position2.dy));
+ });
+
+ testWidgets('Composited transforms - nested', (WidgetTester tester) async {
+ final LayerLink link = new LayerLink();
+ final GlobalKey key1 = new GlobalKey();
+ final GlobalKey key2 = new GlobalKey();
+ await tester.pumpWidget(
+ new Stack(
+ children: <Widget>[
+ new Positioned(
+ top: 123.0,
+ left: 456.0,
+ child: new Transform.rotate(
+ angle: 1.0, // radians
+ child: new CompositedTransformTarget(
+ link: link,
+ child: new Container(key: key1, height: 10.0, width: 10.0),
+ ),
+ ),
+ ),
+ new Positioned(
+ top: 787.0,
+ left: 343.0,
+ child: new Transform.rotate(
+ angle: -0.3, // radians
+ child: new Padding(
+ padding: const EdgeInsets.all(20.0),
+ child: new CompositedTransformFollower(
+ link: new LayerLink(),
+ child: new Transform(
+ transform: new Matrix4.skew(0.9, 1.1),
+ child: new Padding(
+ padding: const EdgeInsets.all(20.0),
+ child: new CompositedTransformFollower(
+ link: link,
+ child: new Container(key: key2, height: 10.0, width: 10.0),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ final RenderBox box1 = key1.currentContext.findRenderObject();
+ final RenderBox box2 = key2.currentContext.findRenderObject();
+ final Offset position1 = box1.localToGlobal(Offset.zero);
+ final Offset position2 = box2.localToGlobal(Offset.zero);
+ expect(position1.dx, moreOrLessEquals(position2.dx));
+ expect(position1.dy, moreOrLessEquals(position2.dy));
+ });
+
+ testWidgets('Composited transforms - hit testing', (WidgetTester tester) async {
+ final LayerLink link = new LayerLink();
+ final GlobalKey key1 = new GlobalKey();
+ final GlobalKey key2 = new GlobalKey();
+ final GlobalKey key3 = new GlobalKey();
+ bool _tapped = false;
+ await tester.pumpWidget(
+ new Stack(
+ children: <Widget>[
+ new Positioned(
+ left: 123.0,
+ top: 456.0,
+ child: new CompositedTransformTarget(
+ link: link,
+ child: new Container(key: key1, height: 10.0, width: 10.0),
+ ),
+ ),
+ new CompositedTransformFollower(
+ link: link,
+ child: new GestureDetector(
+ key: key2,
+ behavior: HitTestBehavior.opaque,
+ onTap: () { _tapped = true; },
+ child: new Container(key: key3, height: 10.0, width: 10.0),
+ ),
+ ),
+ ],
+ ),
+ );
+ final RenderBox box2 = key2.currentContext.findRenderObject();
+ expect(box2.size, const Size(10.0, 10.0));
+ expect(_tapped, isFalse);
+ await tester.tap(find.byKey(key1));
+ expect(_tapped, isTrue);
+ });
+}