// 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 '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, { super.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, ); /// 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, { super.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, ); /// 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 of the cursor. /// /// The cursor indicates the current text insertion point. /// /// If null then [DefaultSelectionStyle.cursorColor] is used. If that is also /// null and [ThemeData.platform] is [TargetPlatform.iOS] or /// [TargetPlatform.macOS], then [CupertinoThemeData.primaryColor] is used. /// Otherwise [ColorScheme.primary] of [ThemeData.colorScheme] is used. 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 DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context); final FocusNode focusNode = _effectiveFocusNode; TextSelectionControls? textSelectionControls = widget.selectionControls; final bool paintCursorAboveText; final bool cursorOpacityAnimates; Offset? cursorOffset; final Color 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 = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; selectionColor = selectionStyle.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 = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; selectionColor = selectionStyle.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 = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; selectionColor = selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); break; case TargetPlatform.linux: case TargetPlatform.windows: forcePressEnabled = false; textSelectionControls ??= desktopTextSelectionControls; paintCursorAboveText = false; cursorOpacityAnimates = false; cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; selectionColor = selectionStyle.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, ), ); } }