|
@@ -0,0 +1,758 @@
|
|
|
+// 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 'package:flutter/material.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 _FlowySelectableTextState state,
|
|
|
+ }) : _state = state,
|
|
|
+ super(delegate: state);
|
|
|
+
|
|
|
+ final _FlowySelectableTextState _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 [FlowySelectableText] 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 [FlowySelectableText] 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 [FlowySelectableText] 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 FlowySelectableText 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 FlowySelectableText(
|
|
|
+ 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 FlowySelectableText.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<FlowySelectableText> createState() => _FlowySelectableTextState();
|
|
|
+
|
|
|
+ @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 _FlowySelectableTextState extends State<FlowySelectableText>
|
|
|
+ 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(FlowySelectableText 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,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|