blob: bf09e043ceaf88dedfaaf4ccf7d1182d8645b4d6 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'desktop_text_selection.dart';
import 'feedback.dart';
import 'text_selection.dart';
import 'text_selection_theme.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(includeSemanticsLabels: false));
final TextSpan _textSpan;
@override
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
// This does not care about composing.
return TextSpan(
style: style,
children: <TextSpan>[_textSpan],
);
}
@override
set text(String? newText) {
// This should never be reached.
throw UnimplementedError();
}
}
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) {
renderEditable.selectWordsInRange(
from: details.globalPosition - details.offsetFromOrigin,
to: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
}
}
@override
void onSingleTapUp(TapUpDetails details) {
editableText.hideToolbar();
if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
}
}
_state.widget.onTap?.call();
}
@override
void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.selectionEnabled) {
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
Feedback.forLongPress(_state.context);
}
}
}
/// 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.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc}
///
/// 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.
///
/// {@macro flutter.material.textfield.wantKeepAlive}
///
/// {@tool snippet}
///
/// ```dart
/// const 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 snippet}
///
/// ```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 [showCursor], [autofocus], [dragStartBehavior], [selectionHeightStyle],
/// [selectionWidthStyle] and [data] parameters must not be null. If specified,
/// the [maxLines] argument must be greater than zero.
const SelectableText(
String this.data, {
Key? key,
this.focusNode,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
ToolbarOptions? toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.selectionControls,
this.onTap,
this.scrollPhysics,
this.semanticsLabel,
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
}) : assert(showCursor != null),
assert(autofocus != null),
assert(dragStartBehavior != null),
assert(selectionHeightStyle != null),
assert(selectionWidthStyle != null),
assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0),
assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
"minLines can't be greater than maxLines",
),
assert(
data != null,
'A non-null String must be provided to a SelectableText widget.',
),
textSpan = null,
toolbarOptions = toolbarOptions ??
const ToolbarOptions(
selectAll: true,
copy: true,
),
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.
///
/// The [autofocus] and [dragStartBehavior] arguments must not be null.
const SelectableText.rich(
TextSpan this.textSpan, {
Key? key,
this.focusNode,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
ToolbarOptions? toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.selectionControls,
this.onTap,
this.scrollPhysics,
this.semanticsLabel,
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
}) : assert(showCursor != null),
assert(autofocus != null),
assert(dragStartBehavior != null),
assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0),
assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
"minLines can't be greater than maxLines",
),
assert(
textSpan != null,
'A non-null TextSpan must be provided to a SelectableText.rich widget.',
),
data = null,
toolbarOptions = toolbarOptions ??
const ToolbarOptions(
selectAll: true,
copy: true,
),
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] with
/// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget
/// to be skipped over during focus traversal.
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.textScaleFactor}
final double? textScaleFactor;
/// {@macro flutter.widgets.editableText.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.editableText.minLines}
final int? minLines;
/// {@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.cursorHeight}
final double? cursorHeight;
/// {@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;
/// Controls how tall the selection highlight boxes are computed to be.
///
/// See [ui.BoxHeightStyle] for details on available styles.
final ui.BoxHeightStyle selectionHeightStyle;
/// Controls how wide the selection highlight boxes are computed to be.
///
/// See [ui.BoxWidthStyle] for details on available styles.
final ui.BoxWidthStyle selectionWidthStyle;
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
final bool enableInteractiveSelection;
/// {@macro flutter.widgets.editableText.selectionControls}
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// Configuration of toolbar options.
///
/// Paste and cut will be disabled regardless.
///
/// If not set, select all and copy will be enabled by default.
final ToolbarOptions toolbarOptions;
/// {@macro flutter.widgets.editableText.selectionEnabled}
bool get selectionEnabled => 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.editableText.scrollPhysics}
final ScrollPhysics? scrollPhysics;
/// {@macro flutter.widgets.Text.semanticsLabel}
final String? semanticsLabel;
/// {@macro dart.ui.textHeightBehavior}
final TextHeightBehavior? textHeightBehavior;
/// {@macro flutter.painting.textPainter.textWidthBasis}
final TextWidthBasis? textWidthBasis;
/// {@macro flutter.widgets.editableText.onSelectionChanged}
final SelectionChangedCallback? onSelectionChanged;
@override
State<SelectableText> createState() => _SelectableTextState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
properties.add(DiagnosticsProperty<String>('semanticsLabel', semanticsLabel, 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('minLines', minLines, defaultValue: null));
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('textScaleFactor', textScaleFactor, defaultValue: null));
properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null));
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<TextSelectionControls>('selectionControls', selectionControls, defaultValue: null));
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
}
}
class _SelectableTextState extends State<SelectableText> implements TextSelectionGestureDetectorBuilderDelegate {
EditableTextState? get _editableText => editableTextKey.currentState;
late _TextSpanEditingController _controller;
FocusNode? _focusNode;
FocusNode get _effectiveFocusNode =>
widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true));
bool _showSelectionHandles = false;
late _SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
// API for TextSelectionGestureDetectorBuilderDelegate.
@override
late 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),
);
_controller.addListener(_onControllerChanged);
}
@override
void didUpdateWidget(SelectableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) {
_controller.removeListener(_onControllerChanged);
_controller = _TextSpanEditingController(
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
);
_controller.addListener(_onControllerChanged);
}
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
_showSelectionHandles = false;
} else {
_showSelectionHandles = true;
}
}
@override
void dispose() {
_focusNode?.dispose();
_controller.removeListener(_onControllerChanged);
super.dispose();
}
void _onControllerChanged() {
final bool showSelectionHandles = !_effectiveFocusNode.hasFocus
|| !_controller.selection.isCollapsed;
if (showSelectionHandles == _showSelectionHandles) {
return;
}
setState(() {
_showSelectionHandles = showSelectionHandles;
});
}
TextSelection? _lastSeenTextSelection;
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
if (willShowSelectionHandles != _showSelectionHandles) {
setState(() {
_showSelectionHandles = willShowSelectionHandles;
});
}
// TODO(chunhtai): The selection may be the same. We should remove this
// check once this is fixed https://github.com/flutter/flutter/issues/76349.
if (widget.onSelectionChanged != null && _lastSeenTextSelection != selection) {
widget.onSelectionChanged!(selection, cause);
}
_lastSeenTextSelection = selection;
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
if (cause == SelectionChangedCause.longPress) {
_editableText?.bringIntoView(selection.base);
}
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// 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
Widget build(BuildContext context) {
// TODO(garyq): Assert to block WidgetSpans from being used here are removed,
// but we still do not yet have nice handling of things like carets, clipboard,
// and other features. We should add proper support. Currently, caret handling
// is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010
// should be landed in SkParagraph after the switch is complete.
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 theme = Theme.of(context);
final TextSelectionThemeData selectionTheme = TextSelectionTheme.of(context);
final FocusNode focusNode = _effectiveFocusNode;
TextSelectionControls? textSelectionControls = widget.selectionControls;
final bool paintCursorAboveText;
final bool cursorOpacityAnimates;
Offset? cursorOffset;
Color? cursorColor = widget.cursorColor;
final Color selectionColor;
Radius? cursorRadius = widget.cursorRadius;
switch (theme.platform) {
case TargetPlatform.iOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
textSelectionControls ??= cupertinoTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
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 ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
selectionColor = selectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
forcePressEnabled = false;
textSelectionControls ??= desktopTextSelectionControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
selectionColor = selectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40);
break;
}
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
TextStyle? effectiveTextStyle = widget.style;
if (effectiveTextStyle == null || effectiveTextStyle.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,
textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
showSelectionHandles: _showSelectionHandles,
showCursor: widget.showCursor,
controller: _controller,
focusNode: focusNode,
strutStyle: widget.strutStyle ?? const StrutStyle(),
textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
textDirection: widget.textDirection,
textScaleFactor: widget.textScaleFactor,
autofocus: widget.autofocus,
forceLine: false,
toolbarOptions: widget.toolbarOptions,
minLines: widget.minLines,
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
selectionColor: selectionColor,
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
onSelectionChanged: _handleSelectionChanged,
onSelectionHandleTapped: _handleSelectionHandleTapped,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
cursorOpacityAnimates: cursorOpacityAnimates,
cursorOffset: cursorOffset,
paintCursorAboveText: paintCursorAboveText,
backgroundCursorColor: CupertinoColors.inactiveGray,
enableInteractiveSelection: widget.enableInteractiveSelection,
dragStartBehavior: widget.dragStartBehavior,
scrollPhysics: widget.scrollPhysics,
autofillHints: null,
),
);
return Semantics(
label: widget.semanticsLabel,
excludeSemantics: widget.semanticsLabel != null,
onLongPress: () {
_effectiveFocusNode.requestFocus();
},
child: _selectionGestureDetectorBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: child,
),
);
}
}