diff --git a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart index 3c634144d3..cb69a74f64 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 description = []; + 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 description = []; debugFillDescription(description); - result += description.map((String description) => '$descriptionPrefix$description\n').join(); + result += description + .expand((String description) => debugWordWrap(description, 65, wrapIndent: ' ')) + .map((String line) => "$descriptionPrefix$line\n") + .join(); if (childrenDescription == '') { final String prefix = prefixOtherLines.trimRight(); if (prefix != '') @@ -31,7 +79,8 @@ abstract class TreeDiagnosticsMixin { 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 description) { } diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index f696c41db4..719f466e15 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:async'; 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 @@ class _TextSelectionToolbar extends StatelessWidget { /// 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 @@ class _TextSelectionToolbarLayout extends SingleChildLayoutDelegate { @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 @@ class _MaterialTextSelectionControls extends TextSelectionControls { /// 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 065e5da6f2..270820fd50 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -1829,7 +1829,7 @@ abstract class RenderBox extends RenderObject { /// 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 54610e13ba..6183b4bec1 100644 --- a/packages/flutter/lib/src/rendering/debug.dart +++ b/packages/flutter/lib/src/rendering/debug.dart @@ -123,8 +123,13 @@ bool debugCheckIntrinsicSizes = false; 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 debugDescribeTransform(Matrix4 transform) { + if (transform == null) + return const ['null']; final List 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 aa5c516b0b..b28f39ddfa 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -30,8 +30,9 @@ typedef void SelectionChangedHandler(TextSelection selection, RenderEditable ren /// 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 @@ class TextSelectionPoint { 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 @@ class RenderEditable extends RenderBox { 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 @@ class RenderEditable extends RenderBox { // 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 [new TextSelectionPoint(localToGlobal(start), null)]; + return [new TextSelectionPoint(start, null)]; } else { final List 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 [ - 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 da25813b60..453fe509e1 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 @@ abstract class Layer extends AbstractNode with TreeDiagnosticsMixin { /// 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 @@ abstract class Layer extends AbstractNode with TreeDiagnosticsMixin { /// /// 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 @@ class PictureLayer extends Layer { void addToScene(ui.SceneBuilder builder, Offset layerOffset) { builder.addPicture(layerOffset, picture, isComplexHint: isComplexHint, willChangeHint: willChangeHint); } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('paint bounds: $canvasBounds'); + } } /// A layer that indicates to the compositor that it should display @@ -357,6 +374,44 @@ class ContainerLayer extends Layer { } } + /// 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 ContainerLayer extends Layer { 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 @@ class OffsetLayer extends ContainerLayer { } } - /// A composite layer that clips its children using a rectangle. class ClipRectLayer extends ContainerLayer { /// Creates a layer with a rectangular clip. @@ -497,35 +555,51 @@ class ClipPathLayer extends ContainerLayer { } } -/// 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 description) { super.debugFillDescription(description); @@ -565,7 +639,7 @@ class OpacityLayer extends ContainerLayer { } } -/// 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 @@ class PhysicalModelLayer extends ContainerLayer { 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 ? "" : "" })'; +} + +/// 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 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 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 ancestors = new HashSet(); + 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 forwardLayers = [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 inverseLayers = [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 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 1cad998d3f..6e248e0600 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -56,13 +56,29 @@ typedef void PaintingContextCallback(PaintingContext context, Offset offset); /// 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 @@ class PaintingContext { /// 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 @@ class PaintingContext { 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 @@ class PaintingContext { /// 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 @@ class PaintingContext { 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 @@ class PaintingContext { ..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 @@ class PaintingContext { } /// 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 @@ class PaintingContext { /// /// 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 @@ class PaintingContext { 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 @@ class PaintingContext { new TransformLayer(transform: effectiveTransform), painter, offset, - childPaintBounds: MatrixUtils.inverseTransformRect(effectiveTransform, _paintBounds), + childPaintBounds: MatrixUtils.inverseTransformRect(effectiveTransform, canvasBounds), ); } else { canvas.save(); @@ -406,6 +431,9 @@ class PaintingContext { 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 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// 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 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// 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 bb4b7e78c4..4453c870bb 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -1636,7 +1636,7 @@ class RenderTransform extends RenderProxyBox { 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 @@ class RenderTransform extends RenderProxyBox { @override void applyPaintTransform(RenderBox child, Matrix4 transform) { transform.multiply(_effectiveTransform); - super.applyPaintTransform(child, transform); } @override @@ -1785,7 +1784,7 @@ class RenderFittedBox extends RenderProxyBox { 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 @@ class RenderFittedBox extends RenderProxyBox { void applyPaintTransform(RenderBox child, Matrix4 transform) { _updatePaintData(); transform.multiply(_transform); - super.applyPaintTransform(child, transform); } @override @@ -1864,7 +1862,6 @@ class RenderFractionalTranslation extends RenderProxyBox { @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 @@ class RenderExcludeSemantics extends RenderProxyBox { 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 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 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 bf462de3ff..6444b413d9 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -26,6 +26,7 @@ export 'package:flutter/rendering.dart' show FlowPaintingContext, FractionalOffsetTween, HitTestBehavior, + LayerLink, MainAxisAlignment, MainAxisSize, MultiChildLayoutDelegate, @@ -301,11 +302,13 @@ class CustomPaint extends SingleChildRenderObjectWidget { 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 @@ class Transform extends SingleChildRenderObjectWidget { 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 @@ class Transform extends SingleChildRenderObjectWidget { } } +/// 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 @@ class SizedBox extends SingleChildRenderObjectWidget { 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 @@ class FractionallySizedBox extends SingleChildRenderObjectWidget { 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 @@ class LimitedBox extends SingleChildRenderObjectWidget { 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 @@ class OverflowBox extends SingleChildRenderObjectWidget { 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 @@ class RawImage extends LeafRenderObjectWidget { 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 @@ class Listener extends SingleChildRenderObjectWidget { 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 @@ class IgnorePointer extends SingleChildRenderObjectWidget { 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 @@ class MetaData extends SingleChildRenderObjectWidget { 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 @@ class Semantics extends SingleChildRenderObjectWidget { 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 eb08c06a9e..7ac573f28d 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -263,6 +263,7 @@ class EditableTextState extends State implements TextInputClient { TextSelectionOverlay _selectionOverlay; final ScrollController _scrollController = new ScrollController(); + final LayerLink _layerLink = new LayerLink(); bool _didAutoFocus = false; // State lifecycle: @@ -272,6 +273,7 @@ class EditableTextState extends State implements TextInputClient { super.initState(); widget.controller.addListener(_didChangeTextEditingValue); widget.focusNode.addListener(_handleFocusChanged); + _scrollController.addListener(() { _selectionOverlay?.updateForScroll(); }); } @override @@ -436,6 +438,7 @@ class EditableTextState extends State implements TextInputClient { context: context, value: _value, debugRequiredFor: widget, + layerLink: _layerLink, renderObject: renderObject, onSelectionOverlayChanged: _handleSelectionOverlayChanged, selectionControls: widget.selectionControls, @@ -538,19 +541,22 @@ class EditableTextState extends State implements TextInputClient { 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 4f8b268f0b..aeb1dea48a 100644 --- a/packages/flutter/lib/src/widgets/scroll_controller.dart +++ b/packages/flutter/lib/src/widgets/scroll_controller.dart @@ -24,6 +24,10 @@ import 'scroll_position_with_single_context.dart'; /// 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 d589eb5f34..e5a8846418 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -73,8 +73,7 @@ abstract class TextSelectionControls { /// 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 @@ class TextSelectionOverlay implements TextSelectionDelegate { @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 @@ class TextSelectionOverlay implements TextSelectionDelegate { /// 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 @@ class TextSelectionOverlay implements TextSelectionDelegate { void showHandles() { assert(_handles == null); _handles = [ - 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 @@ class TextSelectionOverlay implements TextSelectionDelegate { } } + /// 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 @@ class TextSelectionOverlay implements TextSelectionDelegate { 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 @@ class TextSelectionOverlay implements TextSelectionDelegate { (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 TextSelectionOverlay implements TextSelectionDelegate { 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 onSelectionHandleChanged; final VoidCallback onSelectionHandleTapped; @@ -379,19 +404,23 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay break; } - return new GestureDetector( - onPanStart: _handleDragStart, - onPanUpdate: _handleDragUpdate, - onTap: _handleTap, - child: new Stack( - children: [ - 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: [ + 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 7382e83bf4..530e546e4b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -27,7 +27,7 @@ class MockClipboard { Widget overlay(Widget child) { return new MediaQuery( - data: const MediaQueryData(), + data: const MediaQueryData(size: const Size(800.0, 600.0)), child: new Overlay( initialEntries: [ new OverlayEntry( @@ -73,10 +73,22 @@ void main() { return renderEditable; } + List globalize(Iterable 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 endpoints = renderEditable.getEndpointsForSelection( - new TextSelection.collapsed(offset: offset), + final List 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 @@ void main() { 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 endpoints = renderEditable.getEndpointsForSelection(selection); + final List 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 @@ void main() { // 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 endpoints = renderEditable.getEndpointsForSelection(controller.selection); + List 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 @@ void main() { // 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 @@ void main() { // 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 endpoints = renderEditable.getEndpointsForSelection(controller.selection); + final List 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 @@ void main() { 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 endpoints = renderEditable.getEndpointsForSelection(controller.selection); + final List 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 @@ void main() { await tester.pump(const Duration(seconds: 1)); final RenderEditable renderEditable = findRenderEditable(tester); - final List endpoints = renderEditable.getEndpointsForSelection(controller.selection); + final List 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 @@ void main() { 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 endpoints = - renderEditable.getEndpointsForSelection(textController.selection); + final List 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 0000000000..e293b8c228 --- /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: [ + 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: [ + 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: [ + 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: [ + 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); + }); +}