Unverified Commit 2338576a authored by chunhtai's avatar chunhtai Committed by GitHub

implement selectable text (#34019)

parent 41bc10fa
......@@ -93,6 +93,7 @@ export 'src/material/reorderable_list.dart';
export 'src/material/scaffold.dart';
export 'src/material/scrollbar.dart';
export 'src/material/search.dart';
export 'src/material/selectable_text.dart';
export 'src/material/shadows.dart';
export 'src/material/slider.dart';
export 'src/material/slider_theme.dart';
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'feedback.dart';
import 'text_selection.dart';
import 'theme.dart';
/// An eyeballed value that moves the cursor slightly left of where it is
/// rendered for text on Android so its positioning more accurately matches the
/// native iOS text cursor positioning.
///
/// This value is in device pixels, not logical pixels as is typically used
/// throughout the codebase.
const int iOSHorizontalOffset = -2;
class _TextSpanEditingController extends TextEditingController {
_TextSpanEditingController({@required TextSpan textSpan}):
assert(textSpan != null),
_textSpan = textSpan,
super(text: textSpan.toPlainText());
final TextSpan _textSpan;
@override
TextSpan buildTextSpan({TextStyle style ,bool withComposing}) {
// TODO(chunhtai): Implement composing.
return TextSpan(
style: style,
children: <TextSpan>[_textSpan],
);
}
@override
set text(String newText) {
// TODO(chunhtai): Implement value editing.
}
}
class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
_SelectableTextSelectionGestureDetectorBuilder({
@required _SelectableTextState state
}) : _state = state,
super(delegate: state);
final _SelectableTextState _state;
@override
void onForcePressStart(ForcePressDetails details) {
super.onForcePressStart(details);
if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
editableText.showToolbar();
}
}
@override
void onForcePressEnd(ForcePressDetails details) {
// Not required.
}
@override
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
renderEditable.selectWordsInRange(
from: details.globalPosition - details.offsetFromOrigin,
to: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
}
}
}
@override
void onSingleTapUp(TapUpDetails details) {
editableText.hideToolbar();
if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
}
}
if (_state.widget.onTap != null)
_state.widget.onTap();
}
@override
void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
Feedback.forLongPress(_state.context);
break;
}
}
}
}
/// A run of selectable text with a single style.
///
/// The [SelectableText] widget displays a string of text with a single style.
/// The string might break across multiple lines or might all be displayed on
/// the same line depending on the layout constraints.
///
/// The [style] argument is optional. When omitted, the text will use the style
/// from the closest enclosing [DefaultTextStyle]. If the given style's
/// [TextStyle.inherit] property is true (the default), the given style will
/// be merged with the closest enclosing [DefaultTextStyle]. This merging
/// behavior is useful, for example, to make the text bold while using the
/// default font family and size.
///
/// {@tool sample}
///
/// ```dart
/// SelectableText(
/// 'Hello! How are you?',
/// textAlign: TextAlign.center,
/// style: TextStyle(fontWeight: FontWeight.bold),
/// )
/// ```
/// {@end-tool}
///
/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can
/// display a paragraph with differently styled [TextSpan]s. The sample
/// that follows displays "Hello beautiful world" with different styles
/// for each word.
///
/// {@tool sample}
///
/// ```dart
/// const SelectableText.rich(
/// TextSpan(
/// text: 'Hello', // default text style
/// children: <TextSpan>[
/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)),
/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)),
/// ],
/// ),
/// )
/// ```
/// {@end-tool}
///
/// ## Interactivity
///
/// To make [SelectableText] react to touch events, use callback [onTap] to achieve
/// the desired behavior.
///
/// See also:
///
/// * [Text], which is the non selectable version of this widget.
/// * [TextField], which is the editable version of this widget.
class SelectableText extends StatefulWidget {
/// Creates a selectable text widget.
///
/// If the [style] argument is null, the text will use the style from the
/// closest enclosing [DefaultTextStyle].
///
/// The [data] parameter must not be null.
const SelectableText(
this.data, {
Key key,
this.focusNode,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.showCursor = false,
this.autofocus = false,
this.maxLines,
this.cursorWidth = 2.0,
this.cursorRadius,
this.cursorColor,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.onTap,
this.scrollPhysics,
this.textWidthBasis,
}) : assert(showCursor != null),
assert(autofocus != null),
assert(dragStartBehavior != null),
assert(maxLines == null || maxLines > 0),
assert(
data != null,
'A non-null String must be provided to a SelectableText widget.',
),
textSpan = null,
super(key: key);
/// Creates a selectable text widget with a [TextSpan].
///
/// The [textSpan] parameter must not be null and only contain [TextSpan] in
/// [textSpan.children]. Other type of [InlineSpan] is not allowed.
const SelectableText.rich(
this.textSpan, {
Key key,
this.focusNode,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.showCursor = false,
this.autofocus = false,
this.maxLines,
this.cursorWidth = 2.0,
this.cursorRadius,
this.cursorColor,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.onTap,
this.scrollPhysics,
this.textWidthBasis,
}) : assert(showCursor != null),
assert(autofocus != null),
assert(dragStartBehavior != null),
assert(maxLines == null || maxLines > 0),
assert(
textSpan != null,
'A non-null TextSpan must be provided to a SelectableText.rich widget.',
),
data = null,
super(key: key);
/// The text to display.
///
/// This will be null if a [textSpan] is provided instead.
final String data;
/// The text to display as a [TextSpan].
///
/// This will be null if [data] is provided instead.
final TextSpan textSpan;
/// Defines the focus for this widget.
///
/// Text is only selectable when widget is focused.
///
/// The [focusNode] is a long-lived object that's typically managed by a
/// [StatefulWidget] parent. See [FocusNode] for more information.
///
/// To give the focus to this widget, provide a [focusNode] and then
/// use the current [FocusScope] to request the focus:
///
/// ```dart
/// FocusScope.of(context).requestFocus(myFocusNode);
/// ```
///
/// This happens automatically when the widget is tapped.
///
/// To be notified when the widget gains or loses the focus, add a listener
/// to the [focusNode]:
///
/// ```dart
/// focusNode.addListener(() { print(myFocusNode.hasFocus); });
/// ```
///
/// If null, this widget will create its own [FocusNode].
final FocusNode focusNode;
/// The style to use for the text.
///
/// If null, defaults [DefaultTextStyle] of context.
final TextStyle style;
/// {@macro flutter.widgets.editableText.strutStyle}
final StrutStyle strutStyle;
/// {@macro flutter.widgets.editableText.textAlign}
final TextAlign textAlign;
/// {@macro flutter.widgets.editableText.textDirection}
final TextDirection textDirection;
/// {@macro flutter.widgets.editableText.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.editableText.maxLines}
final int maxLines;
/// {@macro flutter.widgets.editableText.showCursor}
final bool showCursor;
/// {@macro flutter.widgets.editableText.cursorWidth}
final double cursorWidth;
/// {@macro flutter.widgets.editableText.cursorRadius}
final Radius cursorRadius;
/// The color to use when painting the cursor.
///
/// Defaults to the theme's `cursorColor` when null.
final Color cursorColor;
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
final bool enableInteractiveSelection;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.rendering.editable.selectionEnabled}
bool get selectionEnabled {
return enableInteractiveSelection;
}
/// Called when the user taps on this selectable text.
///
/// The selectable text builds a [GestureDetector] to handle input events like tap,
/// to trigger focus requests, to move the caret, adjust the selection, etc.
/// Handling some of those events by wrapping the selectable text with a competing
/// GestureDetector is problematic.
///
/// To unconditionally handle taps, without interfering with the selectable text's
/// internal gesture detector, provide this callback.
///
/// To be notified when the text field gains or loses the focus, provide a
/// [focusNode] and add a listener to that.
///
/// To listen to arbitrary pointer events without competing with the
/// selectable text's internal gesture detector, use a [Listener].
final GestureTapCallback onTap;
/// {@macro flutter.widgets.edtiableText.scrollPhysics}
final ScrollPhysics scrollPhysics;
/// {@macro flutter.dart:ui.text.TextWidthBasis}
final TextWidthBasis textWidthBasis;
@override
_SelectableTextState createState() => _SelectableTextState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
}
}
class _SelectableTextState extends State<SelectableText> with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate {
EditableTextState get _editableText => editableTextKey.currentState;
_TextSpanEditingController _controller;
FocusNode _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
bool _showSelectionHandles = false;
_SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
// API for TextSelectionGestureDetectorBuilderDelegate.
@override
bool forcePressEnabled;
@override
final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
@override
bool get selectionEnabled => widget.selectionEnabled;
// End of API for TextSelectionGestureDetectorBuilderDelegate.
@override
void initState() {
super.initState();
_selectionGestureDetectorBuilder = _SelectableTextSelectionGestureDetectorBuilder(state: this);
_controller = _TextSpanEditingController(
textSpan: widget.textSpan ?? TextSpan(text: widget.data)
);
}
@override
void didUpdateWidget(SelectableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) {
_controller = _TextSpanEditingController(
textSpan: widget.textSpan ?? TextSpan(text: widget.data)
);
}
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
_showSelectionHandles = false;
}
}
@override
void dispose() {
_focusNode?.dispose();
super.dispose();
}
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
if (willShowSelectionHandles != _showSelectionHandles) {
setState(() {
_showSelectionHandles = willShowSelectionHandles;
});
}
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
if (cause == SelectionChangedCause.longPress) {
_editableText?.bringIntoView(selection.base);
}
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// Do nothing.
}
}
/// Toggle the toolbar when a selection handle is tapped.
void _handleSelectionHandleTapped() {
if (_controller.selection.isCollapsed) {
_editableText.toggleToolbar();
}
}
bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
// When the text field is activated by something that doesn't trigger the
// selection overlay, we shouldn't show the handles either.
if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar)
return false;
if (_controller.selection.isCollapsed)
return false;
if (cause == SelectionChangedCause.keyboard)
return false;
if (cause == SelectionChangedCause.longPress)
return true;
if (_controller.text.isNotEmpty)
return true;
return false;
}
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // See AutomaticKeepAliveClientMixin.
assert(() {
return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan);
}(), 'SelectableText only supports TextSpan; Other type of InlineSpan is not allowed');
assert(debugCheckHasMediaQuery(context));
assert(debugCheckHasDirectionality(context));
assert(
!(widget.style != null && widget.style.inherit == false &&
(widget.style.fontSize == null || widget.style.textBaseline == null)),
'inherit false style must supply fontSize and textBaseline',
);
final ThemeData themeData = Theme.of(context);
final FocusNode focusNode = _effectiveFocusNode;
TextSelectionControls textSelectionControls;
bool paintCursorAboveText;
bool cursorOpacityAnimates;
Offset cursorOffset;
Color cursorColor = widget.cursorColor;
Radius cursorRadius = widget.cursorRadius;
switch (themeData.platform) {
case TargetPlatform.iOS:
forcePressEnabled = true;
textSelectionControls = cupertinoTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= CupertinoTheme.of(context).primaryColor;
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
textSelectionControls = materialTextSelectionControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor ??= themeData.cursorColor;
break;
}
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
TextStyle effectiveTextStyle = widget.style;
if (widget.style == null || widget.style.inherit)
effectiveTextStyle = defaultTextStyle.style.merge(widget.style);
if (MediaQuery.boldTextOverride(context))
effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold));
final Widget child = RepaintBoundary(
child: EditableText(
key: editableTextKey,
style: effectiveTextStyle,
readOnly: true,
textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
showSelectionHandles: _showSelectionHandles,
showCursor: widget.showCursor,
controller: _controller,
focusNode: focusNode,
strutStyle: widget.strutStyle ?? StrutStyle.disabled,
textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
textDirection: widget.textDirection,
autofocus: widget.autofocus,
forceLine: false,
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
selectionColor: themeData.textSelectionColor,
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
onSelectionChanged: _handleSelectionChanged,
onSelectionHandleTapped: _handleSelectionHandleTapped,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
cursorOpacityAnimates: cursorOpacityAnimates,
cursorOffset: cursorOffset,
paintCursorAboveText: paintCursorAboveText,
backgroundCursorColor: CupertinoColors.inactiveGray,
enableInteractiveSelection: widget.enableInteractiveSelection,
dragStartBehavior: widget.dragStartBehavior,
scrollPhysics: widget.scrollPhysics,
),
);
return Semantics(
onTap: () {
if (!_controller.selection.isValid)
_controller.selection = TextSelection.collapsed(offset: _controller.text.length);
_effectiveFocusNode.requestFocus();
},
onLongPress: () {
_effectiveFocusNode.requestFocus();
},
child: _selectionGestureDetectorBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: child,
),
);
}
}
......@@ -17,6 +17,7 @@ import 'ink_well.dart' show InteractiveInkFeature;
import 'input_decorator.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'selectable_text.dart' show iOSHorizontalOffset;
import 'text_selection.dart';
import 'theme.dart';
......@@ -932,14 +933,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
cursorOpacityAnimates = true;
cursorColor ??= CupertinoTheme.of(context).primaryColor;
cursorRadius ??= const Radius.circular(2.0);
// An eyeballed value that moves the cursor slightly left of where it is
// rendered for text on Android so its positioning more accurately matches the
// native iOS text cursor positioning.
//
// This value is in device pixels, not logical pixels as is typically used
// throughout the codebase.
const int _iOSHorizontalOffset = -2;
cursorOffset = Offset(_iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
......
......@@ -652,7 +652,7 @@ class TextPainter {
final double caretEnd = box.end;
final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd;
return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom);
return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom);
}
return null;
}
......@@ -694,7 +694,7 @@ class TextPainter {
final TextBox box = boxes.last;
final double caretStart = box.start;
final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart;
return Rect.fromLTRB(min(dx, width), box.top, min(dx, width), box.bottom);
return Rect.fromLTRB(min(dx, _paragraph.width), box.top, min(dx, _paragraph.width), box.bottom);
}
return null;
}
......
......@@ -157,6 +157,9 @@ class RenderEditable extends RenderBox {
this.onSelectionChanged,
this.onCaretChanged,
this.ignorePointer = false,
bool readOnly = false,
bool forceLine = true,
TextWidthBasis textWidthBasis = TextWidthBasis.parent,
bool obscureText = false,
Locale locale,
double cursorWidth = 1.0,
......@@ -185,10 +188,13 @@ class RenderEditable extends RenderBox {
assert(textScaleFactor != null),
assert(offset != null),
assert(ignorePointer != null),
assert(textWidthBasis != null),
assert(paintCursorAboveText != null),
assert(obscureText != null),
assert(textSelectionDelegate != null),
assert(cursorWidth != null && cursorWidth >= 0.0),
assert(readOnly != null),
assert(forceLine != null),
assert(devicePixelRatio != null),
_textPainter = TextPainter(
text: text,
......@@ -197,6 +203,7 @@ class RenderEditable extends RenderBox {
textScaleFactor: textScaleFactor,
locale: locale,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
),
_cursorColor = cursorColor,
_backgroundCursorColor = backgroundCursorColor,
......@@ -216,7 +223,9 @@ class RenderEditable extends RenderBox {
_devicePixelRatio = devicePixelRatio,
_startHandleLayerLink = startHandleLayerLink,
_endHandleLayerLink = endHandleLayerLink,
_obscureText = obscureText {
_obscureText = obscureText,
_readOnly = readOnly,
_forceLine = forceLine {
assert(_showCursor != null);
assert(!_showCursor.value || cursorColor != null);
this.hasFocus = hasFocus ?? false;
......@@ -245,12 +254,15 @@ class RenderEditable extends RenderBox {
/// The default value of this property is false.
bool ignorePointer;
/// Whether text is composed.
///
/// Text is composed when user selects it for editing. The [TextSpan] will have
/// children with composing effect and leave text property to be null.
@visibleForTesting
bool get isComposingText => text.text == null;
/// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis}
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
set textWidthBasis(TextWidthBasis value) {
assert(value != null);
if (_textPainter.textWidthBasis == value)
return;
_textPainter.textWidthBasis = value;
markNeedsTextLayout();
}
/// The pixel ratio of the current device.
///
......@@ -444,7 +456,7 @@ class RenderEditable extends RenderBox {
if (leftArrow && _extentOffset > 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset - 2));
newOffset = textSelection.baseOffset + 1;
} else if (rightArrow && _extentOffset < text.text.length - 2) {
} else if (rightArrow && _extentOffset < text.toPlainText().length - 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset + 1));
newOffset = textSelection.extentOffset - 1;
}
......@@ -487,7 +499,7 @@ class RenderEditable extends RenderBox {
// case that the user wants to unhighlight some text.
if (position.offset == _extentOffset) {
if (downArrow)
newOffset = text.text.length;
newOffset = text.toPlainText().length;
else if (upArrow)
newOffset = 0;
_resetCursor = shift;
......@@ -554,16 +566,16 @@ class RenderEditable extends RenderBox {
case _kCKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
ClipboardData(text: selection.textInside(text.text)));
ClipboardData(text: selection.textInside(text.toPlainText())));
}
break;
case _kXKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
ClipboardData(text: selection.textInside(text.text)));
ClipboardData(text: selection.textInside(text.toPlainText())));
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.text)
+ selection.textAfter(text.text),
text: selection.textBefore(text.toPlainText())
+ selection.textAfter(text.toPlainText()),
selection: TextSelection.collapsed(offset: selection.start),
);
}
......@@ -601,15 +613,15 @@ class RenderEditable extends RenderBox {
}
void _handleDelete() {
if (selection.textAfter(text.text).isNotEmpty) {
if (selection.textAfter(text.toPlainText()).isNotEmpty) {
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.text)
+ selection.textAfter(text.text).substring(1),
text: selection.textBefore(text.toPlainText())
+ selection.textAfter(text.toPlainText()).substring(1),
selection: TextSelection.collapsed(offset: selection.start),
);
} else {
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.text),
text: selection.textBefore(text.toPlainText()),
selection: TextSelection.collapsed(offset: selection.start),
);
}
......@@ -758,6 +770,28 @@ class RenderEditable extends RenderBox {
markNeedsSemanticsUpdate();
}
/// Whether this rendering object will take a full line regardless the text width.
bool get forceLine => _forceLine;
bool _forceLine = false;
set forceLine(bool value) {
assert(value != null);
if (_forceLine == value)
return;
_forceLine = value;
markNeedsLayout();
}
/// Whether this rendering object is read only.
bool get readOnly => _readOnly;
bool _readOnly = false;
set readOnly(bool value) {
assert(value != null);
if (_readOnly == value)
return;
_readOnly = value;
markNeedsSemanticsUpdate();
}
/// The maximum number of lines for the text to span, wrapping if necessary.
///
/// If this is 1 (the default), the text will not wrap, but will extend
......@@ -983,6 +1017,8 @@ class RenderEditable extends RenderBox {
return enableInteractiveSelection ?? !obscureText;
}
double get _caretMargin => _kCaretGap + cursorWidth;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
......@@ -995,7 +1031,8 @@ class RenderEditable extends RenderBox {
..isMultiline = _isMultiline
..textDirection = textDirection
..isFocused = hasFocus
..isTextField = true;
..isTextField = true
..isReadOnly = readOnly;
if (hasFocus && selectionEnabled)
config.onSetSelection = _handleSetSelection;
......@@ -1526,10 +1563,12 @@ class RenderEditable extends RenderBox {
assert(constraintWidth != null);
if (_textLayoutLastWidth == constraintWidth)
return;
final double caretMargin = _kCaretGap + cursorWidth;
final double availableWidth = math.max(0.0, constraintWidth - caretMargin);
final double availableWidth = math.max(0.0, constraintWidth - _caretMargin);
final double maxWidth = _isMultiline ? availableWidth : double.infinity;
_textPainter.layout(minWidth: availableWidth, maxWidth: maxWidth);
_textPainter.layout(
minWidth: forceLine ? availableWidth : 0,
maxWidth: maxWidth,
);
_textLayoutLastWidth = constraintWidth;
}
......@@ -1566,8 +1605,10 @@ class RenderEditable extends RenderBox {
// though we currently don't use those here.
// See also RenderParagraph which has a similar issue.
final Size textPainterSize = _textPainter.size;
size = Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
final Size contentSize = Size(textPainterSize.width + _kCaretGap + cursorWidth, textPainterSize.height);
final double width = forceLine ? constraints.maxWidth : constraints
.constrainWidth(_textPainter.size.width + _caretMargin);
size = Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height);
_maxScrollExtent = _getMaxScrollExtent(contentSize);
offset.applyViewportDimension(_viewportExtent);
offset.applyContentDimensions(0.0, _maxScrollExtent);
......
......@@ -150,6 +150,29 @@ class TextEditingController extends ValueNotifier<TextEditingValue> {
);
}
/// Builds [TextSpan] from current editing value.
///
/// By default makes text in composing range appear as underlined.
/// Descendants can override this method to customize appearance of text.
TextSpan buildTextSpan({TextStyle style , bool withComposing}) {
if (!value.composing.isValid || !withComposing) {
return TextSpan(style: style, text: text);
}
final TextStyle composingStyle = style.merge(
const TextStyle(decoration: TextDecoration.underline),
);
return TextSpan(
style: style,
children: <TextSpan>[
TextSpan(text: value.composing.textBefore(value.text)),
TextSpan(
style: composingStyle,
text: value.composing.textInside(value.text),
),
TextSpan(text: value.composing.textAfter(value.text)),
]);
}
/// The currently selected [text].
///
/// If the selection is collapsed, then this property gives the offset of the
......@@ -288,6 +311,8 @@ class EditableText extends StatefulWidget {
this.maxLines = 1,
this.minLines,
this.expands = false,
this.forceLine = true,
this.textWidthBasis = TextWidthBasis.parent,
this.autofocus = false,
bool showCursor,
this.showSelectionHandles = false,
......@@ -320,6 +345,7 @@ class EditableText extends StatefulWidget {
assert(autocorrect != null),
assert(showSelectionHandles != null),
assert(readOnly != null),
assert(forceLine != null),
assert(style != null),
assert(cursorColor != null),
assert(cursorOpacityAnimates != null),
......@@ -368,6 +394,9 @@ class EditableText extends StatefulWidget {
/// {@endtemplate}
final bool obscureText;
/// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis}
final TextWidthBasis textWidthBasis;
/// {@template flutter.widgets.editableText.readOnly}
/// Whether the text can be changed.
///
......@@ -378,6 +407,18 @@ class EditableText extends StatefulWidget {
/// {@endtemplate}
final bool readOnly;
/// Whether the text will take the full width regardless of the text width.
///
/// When this is set to false, the width will be based on text width, which
/// will also be affected by [textWidthBasis].
///
/// Defaults to true. Must not be null.
///
/// See also:
///
/// * [textWidthBasis], which controls the calculation of text width.
final bool forceLine;
/// Whether to show selection handles.
///
/// When a selection is active, there will be two handles at each side of
......@@ -396,7 +437,7 @@ class EditableText extends StatefulWidget {
///
/// See also:
///
/// * [showSelectionHandles], which controls the visibility of the selection handles..
/// * [showSelectionHandles], which controls the visibility of the selection handles.
/// {@endtemplate}
final bool showCursor;
......@@ -1622,6 +1663,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
showCursor: EditableText.debugDeterministicCursor
? ValueNotifier<bool>(widget.showCursor)
: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
......@@ -1632,6 +1675,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
textWidthBasis: widget.textWidthBasis,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
offset: offset,
......@@ -1657,33 +1701,21 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// By default makes text in composing range appear as underlined.
/// Descendants can override this method to customize appearance of text.
TextSpan buildTextSpan() {
// Read only mode should not paint text composing.
if (!widget.obscureText && _value.composing.isValid && !widget.readOnly) {
final TextStyle composingStyle = widget.style.merge(
const TextStyle(decoration: TextDecoration.underline),
);
return TextSpan(
style: widget.style,
children: <TextSpan>[
TextSpan(text: _value.composing.textBefore(_value.text)),
TextSpan(
style: composingStyle,
text: _value.composing.textInside(_value.text),
),
TextSpan(text: _value.composing.textAfter(_value.text)),
]);
}
String text = _value.text;
if (widget.obscureText) {
String text = _value.text;
text = RenderEditable.obscuringCharacter * text.length;
final int o =
_obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null;
if (o != null && o >= 0 && o < text.length)
text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1));
}
return TextSpan(style: widget.style, text: text);
}
// Read only mode should not paint text composing.
return widget.controller.buildTextSpan(
style: widget.style,
withComposing: !widget.readOnly,
);
}
}
class _Editable extends LeafRenderObjectWidget {
......@@ -1696,6 +1728,9 @@ class _Editable extends LeafRenderObjectWidget {
this.cursorColor,
this.backgroundCursorColor,
this.showCursor,
this.forceLine,
this.readOnly,
this.textWidthBasis,
this.hasFocus,
this.maxLines,
this.minLines,
......@@ -1730,6 +1765,8 @@ class _Editable extends LeafRenderObjectWidget {
final LayerLink endHandleLayerLink;
final Color backgroundCursorColor;
final ValueNotifier<bool> showCursor;
final bool forceLine;
final bool readOnly;
final bool hasFocus;
final int maxLines;
final int minLines;
......@@ -1741,6 +1778,7 @@ class _Editable extends LeafRenderObjectWidget {
final TextDirection textDirection;
final Locale locale;
final bool obscureText;
final TextWidthBasis textWidthBasis;
final bool autocorrect;
final ViewportOffset offset;
final SelectionChangedHandler onSelectionChanged;
......@@ -1763,6 +1801,8 @@ class _Editable extends LeafRenderObjectWidget {
endHandleLayerLink: endHandleLayerLink,
backgroundCursorColor: backgroundCursorColor,
showCursor: showCursor,
forceLine: forceLine,
readOnly: readOnly,
hasFocus: hasFocus,
maxLines: maxLines,
minLines: minLines,
......@@ -1779,6 +1819,7 @@ class _Editable extends LeafRenderObjectWidget {
onCaretChanged: onCaretChanged,
ignorePointer: rendererIgnoresPointer,
obscureText: obscureText,
textWidthBasis: textWidthBasis,
cursorWidth: cursorWidth,
cursorRadius: cursorRadius,
cursorOffset: cursorOffset,
......@@ -1797,6 +1838,8 @@ class _Editable extends LeafRenderObjectWidget {
..startHandleLayerLink = startHandleLayerLink
..endHandleLayerLink = endHandleLayerLink
..showCursor = showCursor
..forceLine = forceLine
..readOnly = readOnly
..hasFocus = hasFocus
..maxLines = maxLines
..minLines = minLines
......@@ -1812,6 +1855,7 @@ class _Editable extends LeafRenderObjectWidget {
..onSelectionChanged = onSelectionChanged
..onCaretChanged = onCaretChanged
..ignorePointer = rendererIgnoresPointer
..textWidthBasis = textWidthBasis
..obscureText = obscureText
..cursorWidth = cursorWidth
..cursorRadius = cursorRadius
......
......@@ -973,7 +973,7 @@ void main() {
final RenderEditable renderEditable = findRenderEditable(tester);
// There should be no composing.
expect(renderEditable.isComposingText, false);
expect(renderEditable.text, TextSpan(text:'readonly', style: renderEditable.text.style));
});
testWidgets('Dynamically switching between read only and not read only should hide or show collapse cursor', (WidgetTester tester) async {
......@@ -3231,6 +3231,30 @@ void main() {
semantics.dispose();
});
testWidgets('Read only TextField identifies as read only text field in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: TextField(
maxLength: 10,
readOnly: true,
),
),
),
),
);
expect(
semantics,
includesNodeWith(flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isReadOnly])
);
semantics.dispose();
});
void sendFakeKeyEvent(Map<String, dynamic> data) {
defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.keyEvent.name,
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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