Commit 6d32b339 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

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.
parent 40db1e4b
......@@ -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 @@ 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<String> description) { }
......
......@@ -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),
)
);
}
......
......@@ -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
......
......@@ -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<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;
......
......@@ -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 <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),
];
}
}
......
......@@ -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.
......
......@@ -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<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()));
}
}
This diff is collapsed.
......@@ -263,6 +263,7 @@ class EditableTextState extends State<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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,
),
);
},
);
......
......@@ -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:
......
......@@ -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 = <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 @@ 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<TextSelection> 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: <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),
),
],
),
),
);
}
......
......@@ -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: <OverlayEntry>[
new OverlayEntry(
......@@ -73,10 +73,22 @@ void main() {
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 @@ 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<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 @@ 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<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 @@ 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<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 @@ 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<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 @@ void main() {
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 @@ 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<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'));
......
// 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);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment