mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-05-30 23:58:13 +08:00
901
lib/common/widgets/flutter/text_intro/selectable_text.dart
Normal file
901
lib/common/widgets/flutter/text_intro/selectable_text.dart
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
// 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:PiliPlus/common/widgets/flutter/text_intro/text_selection.dart';
|
||||||
|
import 'package:flutter/cupertino.dart'
|
||||||
|
hide TextSelectionGestureDetectorBuilder;
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart' hide TextSelectionGestureDetectorBuilder;
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
|
class _TextSpanEditingController extends TextEditingController {
|
||||||
|
_TextSpanEditingController({required TextSpan textSpan})
|
||||||
|
: _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 CustomTextSelectionGestureDetectorBuilder {
|
||||||
|
_SelectableTextSelectionGestureDetectorBuilder({
|
||||||
|
required _SelectableTextState state,
|
||||||
|
}) : _state = state,
|
||||||
|
super(delegate: state);
|
||||||
|
|
||||||
|
final _SelectableTextState _state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onSingleTapUp(TapDragUpDetails details) {
|
||||||
|
if (!delegate.selectionEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.onSingleTapUp(details);
|
||||||
|
_state.widget.onTap?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of selectable text with a single style.
|
||||||
|
///
|
||||||
|
/// Consider using [SelectionArea] or [SelectableRegion] instead, which enable
|
||||||
|
/// selection on a widget subtree, including but not limited to [Text] widgets.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// ## Scrolling Considerations
|
||||||
|
///
|
||||||
|
/// If this [SelectableText] is not a descendant of [Scaffold] and is being used
|
||||||
|
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
|
||||||
|
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
|
||||||
|
/// [SelectableText] to ensure proper scroll coordination for [SelectableText]
|
||||||
|
/// and its components like [TextSelectionOverlay].
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [Text], which is the non selectable version of this widget.
|
||||||
|
/// * [TextField], which is the editable version of this widget.
|
||||||
|
/// * [SelectionArea], which enables the selection of multiple [Text] widgets
|
||||||
|
/// and of other widgets.
|
||||||
|
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].
|
||||||
|
///
|
||||||
|
|
||||||
|
/// If the [showCursor], [autofocus], [dragStartBehavior],
|
||||||
|
/// [selectionHeightStyle], [selectionWidthStyle] and [data] arguments are
|
||||||
|
/// 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,
|
||||||
|
@Deprecated(
|
||||||
|
'Use textScaler instead. '
|
||||||
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||||
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||||
|
)
|
||||||
|
this.textScaleFactor,
|
||||||
|
this.textScaler,
|
||||||
|
this.showCursor = false,
|
||||||
|
this.autofocus = false,
|
||||||
|
@Deprecated(
|
||||||
|
'Use `contextMenuBuilder` instead. '
|
||||||
|
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||||
|
)
|
||||||
|
this.toolbarOptions,
|
||||||
|
this.minLines,
|
||||||
|
this.maxLines,
|
||||||
|
this.cursorWidth = 2.0,
|
||||||
|
this.cursorHeight,
|
||||||
|
this.cursorRadius,
|
||||||
|
this.cursorColor,
|
||||||
|
this.selectionColor,
|
||||||
|
this.selectionHeightStyle,
|
||||||
|
this.selectionWidthStyle,
|
||||||
|
this.dragStartBehavior = DragStartBehavior.start,
|
||||||
|
this.enableInteractiveSelection = true,
|
||||||
|
this.selectionControls,
|
||||||
|
this.onTap,
|
||||||
|
this.scrollPhysics,
|
||||||
|
this.scrollBehavior,
|
||||||
|
this.semanticsLabel,
|
||||||
|
this.textHeightBehavior,
|
||||||
|
this.textWidthBasis,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||||
|
this.magnifierConfiguration,
|
||||||
|
}) : 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(
|
||||||
|
textScaler == null || textScaleFactor == null,
|
||||||
|
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
|
||||||
|
),
|
||||||
|
textSpan = null;
|
||||||
|
|
||||||
|
/// Creates a selectable text widget with a [TextSpan].
|
||||||
|
///
|
||||||
|
/// The [TextSpan.children] attribute of the [textSpan] parameter must only
|
||||||
|
/// contain [TextSpan]s. Other types of [InlineSpan] are not allowed.
|
||||||
|
const SelectableText.rich(
|
||||||
|
TextSpan this.textSpan, {
|
||||||
|
super.key,
|
||||||
|
this.focusNode,
|
||||||
|
this.style,
|
||||||
|
this.strutStyle,
|
||||||
|
this.textAlign,
|
||||||
|
this.textDirection,
|
||||||
|
@Deprecated(
|
||||||
|
'Use textScaler instead. '
|
||||||
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||||
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||||
|
)
|
||||||
|
this.textScaleFactor,
|
||||||
|
this.textScaler,
|
||||||
|
this.showCursor = false,
|
||||||
|
this.autofocus = false,
|
||||||
|
@Deprecated(
|
||||||
|
'Use `contextMenuBuilder` instead. '
|
||||||
|
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||||
|
)
|
||||||
|
this.toolbarOptions,
|
||||||
|
this.minLines,
|
||||||
|
this.maxLines,
|
||||||
|
this.cursorWidth = 2.0,
|
||||||
|
this.cursorHeight,
|
||||||
|
this.cursorRadius,
|
||||||
|
this.cursorColor,
|
||||||
|
this.selectionColor,
|
||||||
|
this.selectionHeightStyle,
|
||||||
|
this.selectionWidthStyle,
|
||||||
|
this.dragStartBehavior = DragStartBehavior.start,
|
||||||
|
this.enableInteractiveSelection = true,
|
||||||
|
this.selectionControls,
|
||||||
|
this.onTap,
|
||||||
|
this.scrollPhysics,
|
||||||
|
this.scrollBehavior,
|
||||||
|
this.semanticsLabel,
|
||||||
|
this.textHeightBehavior,
|
||||||
|
this.textWidthBasis,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||||
|
this.magnifierConfiguration,
|
||||||
|
}) : 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(
|
||||||
|
textScaler == null || textScaleFactor == null,
|
||||||
|
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
|
||||||
|
),
|
||||||
|
data = null;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// myFocusNode.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}
|
||||||
|
@Deprecated(
|
||||||
|
'Use textScaler instead. '
|
||||||
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||||
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||||
|
)
|
||||||
|
final double? textScaleFactor;
|
||||||
|
|
||||||
|
/// {@macro flutter.painting.textPainter.textScaler}
|
||||||
|
final TextScaler? textScaler;
|
||||||
|
|
||||||
|
/// {@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;
|
||||||
|
|
||||||
|
/// The color to use when painting the selection.
|
||||||
|
///
|
||||||
|
/// If this property is null, this widget gets the selection color from the
|
||||||
|
/// inherited [DefaultSelectionStyle] (if any); if none, the selection
|
||||||
|
/// color is derived from the [CupertinoThemeData.primaryColor] on
|
||||||
|
/// Apple platforms and [ColorScheme.primary] of [ThemeData.colorScheme] on
|
||||||
|
/// other platforms.
|
||||||
|
final Color? selectionColor;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
@Deprecated(
|
||||||
|
'Use `contextMenuBuilder` instead. '
|
||||||
|
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||||
|
)
|
||||||
|
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.editableText.scrollBehavior}
|
||||||
|
final ScrollBehavior? scrollBehavior;
|
||||||
|
|
||||||
|
/// {@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;
|
||||||
|
|
||||||
|
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
|
||||||
|
final EditableTextContextMenuBuilder? contextMenuBuilder;
|
||||||
|
|
||||||
|
static Widget _defaultContextMenuBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
EditableTextState editableTextState,
|
||||||
|
) {
|
||||||
|
return AdaptiveTextSelectionToolbar.editableText(
|
||||||
|
editableTextState: editableTextState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration for the magnifier used when the text is selected.
|
||||||
|
///
|
||||||
|
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier]
|
||||||
|
/// on Android, and builds nothing on all other platforms. To suppress the
|
||||||
|
/// magnifier, consider passing [TextMagnifierConfiguration.disabled].
|
||||||
|
///
|
||||||
|
/// {@macro flutter.widgets.magnifier.intro}
|
||||||
|
final TextMagnifierConfiguration? magnifierConfiguration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SelectableText> createState() => _SelectableTextState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
|
super.debugFillProperties(properties);
|
||||||
|
properties
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<String>('data', data, defaultValue: null),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<String>(
|
||||||
|
'semanticsLabel',
|
||||||
|
semanticsLabel,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<FocusNode>(
|
||||||
|
'focusNode',
|
||||||
|
focusNode,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<TextStyle>('style', style, defaultValue: null),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<bool>(
|
||||||
|
'showCursor',
|
||||||
|
showCursor,
|
||||||
|
defaultValue: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(IntProperty('minLines', minLines, defaultValue: null))
|
||||||
|
..add(IntProperty('maxLines', maxLines, defaultValue: null))
|
||||||
|
..add(
|
||||||
|
EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
EnumProperty<TextDirection>(
|
||||||
|
'textDirection',
|
||||||
|
textDirection,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<TextScaler>(
|
||||||
|
'textScaler',
|
||||||
|
textScaler,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DoubleProperty('cursorHeight', cursorHeight, defaultValue: null),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<Radius>(
|
||||||
|
'cursorRadius',
|
||||||
|
cursorRadius,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<Color>(
|
||||||
|
'cursorColor',
|
||||||
|
cursorColor,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<Color>(
|
||||||
|
'selectionColor',
|
||||||
|
selectionColor,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
FlagProperty(
|
||||||
|
'selectionEnabled',
|
||||||
|
value: selectionEnabled,
|
||||||
|
defaultValue: true,
|
||||||
|
ifFalse: 'selection disabled',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<TextSelectionControls>(
|
||||||
|
'selectionControls',
|
||||||
|
selectionControls,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<ScrollPhysics>(
|
||||||
|
'scrollPhysics',
|
||||||
|
scrollPhysics,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
DiagnosticsProperty<ScrollBehavior>(
|
||||||
|
'scrollBehavior',
|
||||||
|
scrollBehavior,
|
||||||
|
defaultValue: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..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);
|
||||||
|
_effectiveFocusNode.addListener(_handleFocusChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SelectableText oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.data != oldWidget.data ||
|
||||||
|
widget.textSpan != oldWidget.textSpan) {
|
||||||
|
_controller.removeListener(_onControllerChanged);
|
||||||
|
_controller.dispose();
|
||||||
|
_controller = _TextSpanEditingController(
|
||||||
|
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
|
||||||
|
);
|
||||||
|
_controller.addListener(_onControllerChanged);
|
||||||
|
}
|
||||||
|
if (widget.focusNode != oldWidget.focusNode) {
|
||||||
|
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
|
||||||
|
(widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged);
|
||||||
|
}
|
||||||
|
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
|
||||||
|
_showSelectionHandles = false;
|
||||||
|
} else {
|
||||||
|
_showSelectionHandles = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_effectiveFocusNode.removeListener(_handleFocusChanged);
|
||||||
|
_focusNode?.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onControllerChanged() {
|
||||||
|
final bool showSelectionHandles =
|
||||||
|
!_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed;
|
||||||
|
if (showSelectionHandles == _showSelectionHandles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_showSelectionHandles = showSelectionHandles;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChanged() {
|
||||||
|
if (!_effectiveFocusNode.hasFocus &&
|
||||||
|
SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) {
|
||||||
|
// We should only clear the selection when this SelectableText loses
|
||||||
|
// focus while the application is currently running. It is possible
|
||||||
|
// that the application is not currently running, for example on desktop
|
||||||
|
// platforms, clicking on a different window switches the focus to
|
||||||
|
// the new window causing the Flutter application to go inactive. In this
|
||||||
|
// case we want to retain the selection so it remains when we return to
|
||||||
|
// the Flutter application.
|
||||||
|
_controller.value = TextEditingValue(text: _controller.value.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSelectionChanged(
|
||||||
|
TextSelection selection,
|
||||||
|
SelectionChangedCause? cause,
|
||||||
|
) {
|
||||||
|
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
|
||||||
|
if (willShowSelectionHandles != _showSelectionHandles) {
|
||||||
|
setState(() {
|
||||||
|
_showSelectionHandles = willShowSelectionHandles;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onSelectionChanged?.call(selection, cause);
|
||||||
|
|
||||||
|
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 &&
|
||||||
|
(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 ??= cupertinoTextSelectionHandleControls;
|
||||||
|
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.devicePixelRatioOf(context),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
|
||||||
|
forcePressEnabled = false;
|
||||||
|
textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
|
||||||
|
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.devicePixelRatioOf(context),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
forcePressEnabled = false;
|
||||||
|
textSelectionControls ??= materialTextSelectionHandleControls;
|
||||||
|
paintCursorAboveText = false;
|
||||||
|
cursorOpacityAnimates = false;
|
||||||
|
cursorColor =
|
||||||
|
widget.cursorColor ??
|
||||||
|
selectionStyle.cursorColor ??
|
||||||
|
theme.colorScheme.primary;
|
||||||
|
selectionColor =
|
||||||
|
selectionStyle.selectionColor ??
|
||||||
|
theme.colorScheme.primary.withOpacity(0.40);
|
||||||
|
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
forcePressEnabled = false;
|
||||||
|
textSelectionControls ??= desktopTextSelectionHandleControls;
|
||||||
|
paintCursorAboveText = false;
|
||||||
|
cursorOpacityAnimates = false;
|
||||||
|
cursorColor =
|
||||||
|
widget.cursorColor ??
|
||||||
|
selectionStyle.cursorColor ??
|
||||||
|
theme.colorScheme.primary;
|
||||||
|
selectionColor =
|
||||||
|
selectionStyle.selectionColor ??
|
||||||
|
theme.colorScheme.primary.withOpacity(0.40);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
|
||||||
|
TextStyle? effectiveTextStyle = widget.style;
|
||||||
|
if (effectiveTextStyle == null || effectiveTextStyle.inherit) {
|
||||||
|
effectiveTextStyle = defaultTextStyle.style.merge(
|
||||||
|
widget.style ?? _controller._textSpan.style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final TextScaler? effectiveScaler =
|
||||||
|
widget.textScaler ??
|
||||||
|
switch (widget.textScaleFactor) {
|
||||||
|
null => null,
|
||||||
|
final double textScaleFactor => TextScaler.linear(textScaleFactor),
|
||||||
|
};
|
||||||
|
final Widget child = RepaintBoundary(
|
||||||
|
child: EditableText(
|
||||||
|
key: editableTextKey,
|
||||||
|
style: effectiveTextStyle,
|
||||||
|
readOnly: true,
|
||||||
|
toolbarOptions: widget.toolbarOptions,
|
||||||
|
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,
|
||||||
|
textScaler: effectiveScaler,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
forceLine: false,
|
||||||
|
minLines: widget.minLines,
|
||||||
|
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
|
||||||
|
selectionColor: widget.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,
|
||||||
|
magnifierConfiguration:
|
||||||
|
widget.magnifierConfiguration ??
|
||||||
|
TextMagnifier.adaptiveMagnifierConfiguration,
|
||||||
|
dragStartBehavior: widget.dragStartBehavior,
|
||||||
|
scrollPhysics: widget.scrollPhysics,
|
||||||
|
scrollBehavior: widget.scrollBehavior,
|
||||||
|
autofillHints: null,
|
||||||
|
contextMenuBuilder: widget.contextMenuBuilder,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
label: widget.semanticsLabel,
|
||||||
|
excludeSemantics: widget.semanticsLabel != null,
|
||||||
|
onLongPress: () {
|
||||||
|
_effectiveFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: _selectionGestureDetectorBuilder.buildGestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1085
lib/common/widgets/flutter/text_intro/tap_and_drag.dart
Normal file
1085
lib/common/widgets/flutter/text_intro/tap_and_drag.dart
Normal file
File diff suppressed because it is too large
Load Diff
22
lib/common/widgets/flutter/text_intro/text.dart
Normal file
22
lib/common/widgets/flutter/text_intro/text.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:PiliPlus/common/widgets/flutter/text_intro/selectable_text.dart';
|
||||||
|
import 'package:PiliPlus/utils/platform_utils.dart';
|
||||||
|
import 'package:flutter/material.dart' hide SelectableText;
|
||||||
|
|
||||||
|
Widget selectableRichText(
|
||||||
|
TextSpan textSpan, {
|
||||||
|
TextStyle? style,
|
||||||
|
}) {
|
||||||
|
if (PlatformUtils.isDesktop) {
|
||||||
|
return SelectionArea(
|
||||||
|
child: Text.rich(
|
||||||
|
style: style,
|
||||||
|
textSpan,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SelectableText.rich(
|
||||||
|
style: style,
|
||||||
|
textSpan,
|
||||||
|
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||||
|
);
|
||||||
|
}
|
||||||
419
lib/common/widgets/flutter/text_intro/text_selection.dart
Normal file
419
lib/common/widgets/flutter/text_intro/text_selection.dart
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
// 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:math' as math;
|
||||||
|
|
||||||
|
import 'package:PiliPlus/common/widgets/flutter/text_intro/tap_and_drag.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart'
|
||||||
|
hide BaseTapAndDragGestureRecognizer, TapAndHorizontalDragGestureRecognizer;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CustomTextSelectionGestureDetectorBuilder
|
||||||
|
extends TextSelectionGestureDetectorBuilder {
|
||||||
|
CustomTextSelectionGestureDetectorBuilder({required super.delegate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildGestureDetector({
|
||||||
|
Key? key,
|
||||||
|
HitTestBehavior? behavior,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return TextSelectionGestureDetector(
|
||||||
|
key: key,
|
||||||
|
onTapTrackStart: onTapTrackStart,
|
||||||
|
onTapTrackReset: onTapTrackReset,
|
||||||
|
onTapDown: onTapDown,
|
||||||
|
onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null,
|
||||||
|
onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
|
||||||
|
onSecondaryTap: onSecondaryTap,
|
||||||
|
onSecondaryTapDown: onSecondaryTapDown,
|
||||||
|
onSingleTapUp: onSingleTapUp,
|
||||||
|
onSingleTapCancel: onSingleTapCancel,
|
||||||
|
onUserTap: onUserTap,
|
||||||
|
onSingleLongTapStart: onSingleLongTapStart,
|
||||||
|
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
|
||||||
|
onSingleLongTapEnd: onSingleLongTapEnd,
|
||||||
|
onSingleLongTapCancel: onSingleLongTapCancel,
|
||||||
|
onDoubleTapDown: onDoubleTapDown,
|
||||||
|
onTripleTapDown: onTripleTapDown,
|
||||||
|
onDragSelectionStart: onDragSelectionStart,
|
||||||
|
onDragSelectionUpdate: onDragSelectionUpdate,
|
||||||
|
onDragSelectionEnd: onDragSelectionEnd,
|
||||||
|
onUserTapAlwaysCalled: onUserTapAlwaysCalled,
|
||||||
|
behavior: behavior,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gesture detector to respond to non-exclusive event chains for a text field.
|
||||||
|
///
|
||||||
|
/// An ordinary [GestureDetector] configured to handle events like tap and
|
||||||
|
/// double tap will only recognize one or the other. This widget detects both:
|
||||||
|
/// the first tap and then any subsequent taps that occurs within a time limit
|
||||||
|
/// after the first.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [TextField], a Material text field which uses this gesture detector.
|
||||||
|
/// * [CupertinoTextField], a Cupertino text field which uses this gesture
|
||||||
|
/// detector.
|
||||||
|
class TextSelectionGestureDetector extends StatefulWidget {
|
||||||
|
/// Create a [TextSelectionGestureDetector].
|
||||||
|
///
|
||||||
|
/// Multiple callbacks can be called for one sequence of input gesture.
|
||||||
|
const TextSelectionGestureDetector({
|
||||||
|
super.key,
|
||||||
|
this.onTapTrackStart,
|
||||||
|
this.onTapTrackReset,
|
||||||
|
this.onTapDown,
|
||||||
|
this.onForcePressStart,
|
||||||
|
this.onForcePressEnd,
|
||||||
|
this.onSecondaryTap,
|
||||||
|
this.onSecondaryTapDown,
|
||||||
|
this.onSingleTapUp,
|
||||||
|
this.onSingleTapCancel,
|
||||||
|
this.onUserTap,
|
||||||
|
this.onSingleLongTapStart,
|
||||||
|
this.onSingleLongTapMoveUpdate,
|
||||||
|
this.onSingleLongTapEnd,
|
||||||
|
this.onSingleLongTapCancel,
|
||||||
|
this.onDoubleTapDown,
|
||||||
|
this.onTripleTapDown,
|
||||||
|
this.onDragSelectionStart,
|
||||||
|
this.onDragSelectionUpdate,
|
||||||
|
this.onDragSelectionEnd,
|
||||||
|
this.onUserTapAlwaysCalled = false,
|
||||||
|
this.behavior,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackStart}
|
||||||
|
/// Callback used to indicate that a tap tracking has started upon
|
||||||
|
/// a [PointerDownEvent].
|
||||||
|
/// {@endtemplate}
|
||||||
|
final VoidCallback? onTapTrackStart;
|
||||||
|
|
||||||
|
/// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackReset}
|
||||||
|
/// Callback used to indicate that a tap tracking has been reset which
|
||||||
|
/// happens on the next [PointerDownEvent] after the timer between two taps
|
||||||
|
/// elapses, the recognizer loses the arena, the gesture is cancelled or
|
||||||
|
/// the recognizer is disposed of.
|
||||||
|
/// {@endtemplate}
|
||||||
|
final VoidCallback? onTapTrackReset;
|
||||||
|
|
||||||
|
/// Called for every tap down including every tap down that's part of a
|
||||||
|
/// double click or a long press, except touches that include enough movement
|
||||||
|
/// to not qualify as taps (e.g. pans and flings).
|
||||||
|
final GestureTapDragDownCallback? onTapDown;
|
||||||
|
|
||||||
|
/// Called when a pointer has tapped down and the force of the pointer has
|
||||||
|
/// just become greater than [ForcePressGestureRecognizer.startPressure].
|
||||||
|
final GestureForcePressStartCallback? onForcePressStart;
|
||||||
|
|
||||||
|
/// Called when a pointer that had previously triggered [onForcePressStart] is
|
||||||
|
/// lifted off the screen.
|
||||||
|
final GestureForcePressEndCallback? onForcePressEnd;
|
||||||
|
|
||||||
|
/// Called for a tap event with the secondary mouse button.
|
||||||
|
final GestureTapCallback? onSecondaryTap;
|
||||||
|
|
||||||
|
/// Called for a tap down event with the secondary mouse button.
|
||||||
|
final GestureTapDownCallback? onSecondaryTapDown;
|
||||||
|
|
||||||
|
/// Called for the first tap in a series of taps, consecutive taps do not call
|
||||||
|
/// this method.
|
||||||
|
///
|
||||||
|
/// For example, if the detector was configured with [onTapDown] and
|
||||||
|
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
|
||||||
|
/// down, followed by a tap up, then a double tap down, followed by a single tap down.
|
||||||
|
final GestureTapDragUpCallback? onSingleTapUp;
|
||||||
|
|
||||||
|
/// Called for each touch that becomes recognized as a gesture that is not a
|
||||||
|
/// short tap, such as a long tap or drag. It is called at the moment when
|
||||||
|
/// another gesture from the touch is recognized.
|
||||||
|
final GestureCancelCallback? onSingleTapCancel;
|
||||||
|
|
||||||
|
/// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is
|
||||||
|
/// disabled, which is the default behavior.
|
||||||
|
///
|
||||||
|
/// When [onUserTapAlwaysCalled] is enabled, this is called for every tap,
|
||||||
|
/// including consecutive taps.
|
||||||
|
final GestureTapCallback? onUserTap;
|
||||||
|
|
||||||
|
/// Called for a single long tap that's sustained for longer than
|
||||||
|
/// [kLongPressTimeout] but not necessarily lifted. Not called for a
|
||||||
|
/// double-tap-hold, which calls [onDoubleTapDown] instead.
|
||||||
|
final GestureLongPressStartCallback? onSingleLongTapStart;
|
||||||
|
|
||||||
|
/// Called after [onSingleLongTapStart] when the pointer is dragged.
|
||||||
|
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
|
||||||
|
|
||||||
|
/// Called after [onSingleLongTapStart] when the pointer is lifted.
|
||||||
|
final GestureLongPressEndCallback? onSingleLongTapEnd;
|
||||||
|
|
||||||
|
/// Called after [onSingleLongTapStart] when the pointer is canceled.
|
||||||
|
final GestureLongPressCancelCallback? onSingleLongTapCancel;
|
||||||
|
|
||||||
|
/// Called after a momentary hold or a short tap that is close in space and
|
||||||
|
/// time (within [kDoubleTapTimeout]) to a previous short tap.
|
||||||
|
final GestureTapDragDownCallback? onDoubleTapDown;
|
||||||
|
|
||||||
|
/// Called after a momentary hold or a short tap that is close in space and
|
||||||
|
/// time (within [kDoubleTapTimeout]) to a previous double-tap.
|
||||||
|
final GestureTapDragDownCallback? onTripleTapDown;
|
||||||
|
|
||||||
|
/// Called when a mouse starts dragging to select text.
|
||||||
|
final GestureTapDragStartCallback? onDragSelectionStart;
|
||||||
|
|
||||||
|
/// Called repeatedly as a mouse moves while dragging.
|
||||||
|
final GestureTapDragUpdateCallback? onDragSelectionUpdate;
|
||||||
|
|
||||||
|
/// Called when a mouse that was previously dragging is released.
|
||||||
|
final GestureTapDragEndCallback? onDragSelectionEnd;
|
||||||
|
|
||||||
|
/// Whether [onUserTap] will be called for all taps including consecutive taps.
|
||||||
|
///
|
||||||
|
/// Defaults to false, so [onUserTap] is only called for each distinct tap.
|
||||||
|
final bool onUserTapAlwaysCalled;
|
||||||
|
|
||||||
|
/// How this gesture detector should behave during hit testing.
|
||||||
|
///
|
||||||
|
/// This defaults to [HitTestBehavior.deferToChild].
|
||||||
|
final HitTestBehavior? behavior;
|
||||||
|
|
||||||
|
/// Child below this widget.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _TextSelectionGestureDetectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TextSelectionGestureDetectorState
|
||||||
|
extends State<TextSelectionGestureDetector> {
|
||||||
|
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
|
||||||
|
// which can grow to be infinitely large, to a value between 1 and 3. The value
|
||||||
|
// that the raw count is converted to is based on the default observed behavior
|
||||||
|
// on the native platforms.
|
||||||
|
//
|
||||||
|
// This method should be used in all instances when details.consecutiveTapCount
|
||||||
|
// would be used.
|
||||||
|
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
// From observation, these platform's reset their tap count to 0 when
|
||||||
|
// the number of consecutive taps exceeds 3. For example on Debian Linux
|
||||||
|
// with GTK, when going past a triple click, on the fourth click the
|
||||||
|
// selection is moved to the precise click position, on the fifth click
|
||||||
|
// the word at the position is selected, and on the sixth click the
|
||||||
|
// paragraph at the position is selected.
|
||||||
|
return rawCount <= 3
|
||||||
|
? rawCount
|
||||||
|
: (rawCount % 3 == 0 ? 3 : rawCount % 3);
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
// From observation, these platform's either hold their tap count at 3.
|
||||||
|
// For example on macOS, when going past a triple click, the selection
|
||||||
|
// should be retained at the paragraph that was first selected on triple
|
||||||
|
// click.
|
||||||
|
return math.min(rawCount, 3);
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
// From observation, this platform's consecutive tap actions alternate
|
||||||
|
// between double click and triple click actions. For example, after a
|
||||||
|
// triple click has selected a paragraph, on the next click the word at
|
||||||
|
// the clicked position will be selected, and on the next click the
|
||||||
|
// paragraph at the position is selected.
|
||||||
|
return rawCount < 2 ? rawCount : 2 + rawCount % 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapTrackStart() {
|
||||||
|
widget.onTapTrackStart?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapTrackReset() {
|
||||||
|
widget.onTapTrackReset?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The down handler is force-run on success of a single tap and optimistically
|
||||||
|
// run before a long press success.
|
||||||
|
void _handleTapDown(TapDragDownDetails details) {
|
||||||
|
widget.onTapDown?.call(details);
|
||||||
|
// This isn't detected as a double tap gesture in the gesture recognizer
|
||||||
|
// because it's 2 single taps, each of which may do different things depending
|
||||||
|
// on whether it's a single tap, the first tap of a double tap, the second
|
||||||
|
// tap held down, a clean double tap etc.
|
||||||
|
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) {
|
||||||
|
return widget.onDoubleTapDown?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) {
|
||||||
|
return widget.onTripleTapDown?.call(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapUp(TapDragUpDetails details) {
|
||||||
|
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) {
|
||||||
|
widget.onSingleTapUp?.call(details);
|
||||||
|
widget.onUserTap?.call();
|
||||||
|
} else if (widget.onUserTapAlwaysCalled) {
|
||||||
|
widget.onUserTap?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapCancel() {
|
||||||
|
widget.onSingleTapCancel?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragStart(TapDragStartDetails details) {
|
||||||
|
widget.onDragSelectionStart?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUpdate(TapDragUpdateDetails details) {
|
||||||
|
widget.onDragSelectionUpdate?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragEnd(TapDragEndDetails details) {
|
||||||
|
widget.onDragSelectionEnd?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _forcePressStarted(ForcePressDetails details) {
|
||||||
|
widget.onForcePressStart?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _forcePressEnded(ForcePressDetails details) {
|
||||||
|
widget.onForcePressEnd?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPressStart(LongPressStartDetails details) {
|
||||||
|
widget.onSingleLongTapStart?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||||
|
widget.onSingleLongTapMoveUpdate?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPressEnd(LongPressEndDetails details) {
|
||||||
|
widget.onSingleLongTapEnd?.call(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPressCancel() {
|
||||||
|
widget.onSingleLongTapCancel?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Map<Type, GestureRecognizerFactory> gestures =
|
||||||
|
<Type, GestureRecognizerFactory>{};
|
||||||
|
|
||||||
|
gestures[TapGestureRecognizer] =
|
||||||
|
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||||
|
() => TapGestureRecognizer(debugOwner: this),
|
||||||
|
(TapGestureRecognizer instance) {
|
||||||
|
instance
|
||||||
|
..onSecondaryTap = widget.onSecondaryTap
|
||||||
|
..onSecondaryTapDown = widget.onSecondaryTapDown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.onSingleLongTapStart != null ||
|
||||||
|
widget.onSingleLongTapMoveUpdate != null ||
|
||||||
|
widget.onSingleLongTapEnd != null ||
|
||||||
|
widget.onSingleLongTapCancel != null) {
|
||||||
|
gestures[LongPressGestureRecognizer] =
|
||||||
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||||
|
() => LongPressGestureRecognizer(
|
||||||
|
debugOwner: this,
|
||||||
|
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch},
|
||||||
|
),
|
||||||
|
(LongPressGestureRecognizer instance) {
|
||||||
|
instance
|
||||||
|
..onLongPressStart = _handleLongPressStart
|
||||||
|
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
|
||||||
|
..onLongPressEnd = _handleLongPressEnd
|
||||||
|
..onLongPressCancel = _handleLongPressCancel;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.onDragSelectionStart != null ||
|
||||||
|
widget.onDragSelectionUpdate != null ||
|
||||||
|
widget.onDragSelectionEnd != null) {
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
gestures[TapAndHorizontalDragGestureRecognizer] =
|
||||||
|
GestureRecognizerFactoryWithHandlers<
|
||||||
|
TapAndHorizontalDragGestureRecognizer
|
||||||
|
>(
|
||||||
|
() => TapAndHorizontalDragGestureRecognizer(debugOwner: this),
|
||||||
|
(TapAndHorizontalDragGestureRecognizer instance) {
|
||||||
|
instance
|
||||||
|
// Text selection should start from the position of the first pointer
|
||||||
|
// down event.
|
||||||
|
..dragStartBehavior = DragStartBehavior.down
|
||||||
|
..eagerVictoryOnDrag =
|
||||||
|
defaultTargetPlatform != TargetPlatform.iOS
|
||||||
|
..onTapTrackStart = _handleTapTrackStart
|
||||||
|
..onTapTrackReset = _handleTapTrackReset
|
||||||
|
..onTapDown = _handleTapDown
|
||||||
|
..onDragStart = _handleDragStart
|
||||||
|
..onDragUpdate = _handleDragUpdate
|
||||||
|
..onDragEnd = _handleDragEnd
|
||||||
|
..onTapUp = _handleTapUp
|
||||||
|
..onCancel = _handleTapCancel;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
gestures[TapAndPanGestureRecognizer] =
|
||||||
|
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
|
||||||
|
() => TapAndPanGestureRecognizer(debugOwner: this),
|
||||||
|
(TapAndPanGestureRecognizer instance) {
|
||||||
|
instance
|
||||||
|
// Text selection should start from the position of the first pointer
|
||||||
|
// down event.
|
||||||
|
..dragStartBehavior = DragStartBehavior.down
|
||||||
|
..onTapTrackStart = _handleTapTrackStart
|
||||||
|
..onTapTrackReset = _handleTapTrackReset
|
||||||
|
..onTapDown = _handleTapDown
|
||||||
|
..onDragStart = _handleDragStart
|
||||||
|
..onDragUpdate = _handleDragUpdate
|
||||||
|
..onDragEnd = _handleDragEnd
|
||||||
|
..onTapUp = _handleTapUp
|
||||||
|
..onCancel = _handleTapCancel;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
|
||||||
|
gestures[ForcePressGestureRecognizer] =
|
||||||
|
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
|
||||||
|
() => ForcePressGestureRecognizer(debugOwner: this),
|
||||||
|
(ForcePressGestureRecognizer instance) {
|
||||||
|
instance
|
||||||
|
..onStart = widget.onForcePressStart != null
|
||||||
|
? _forcePressStarted
|
||||||
|
: null
|
||||||
|
..onEnd = widget.onForcePressEnd != null
|
||||||
|
? _forcePressEnded
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RawGestureDetector(
|
||||||
|
gestures: gestures,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
behavior: widget.behavior,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:PiliPlus/common/constants.dart';
|
import 'package:PiliPlus/common/constants.dart';
|
||||||
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
|
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
|
||||||
|
import 'package:PiliPlus/common/widgets/flutter/text_intro/text.dart';
|
||||||
import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart';
|
import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart';
|
||||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||||
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
|
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
|
||||||
@@ -19,7 +20,6 @@ import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
|
|||||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
|
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
|
||||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart';
|
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart';
|
||||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/season.dart';
|
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/season.dart';
|
||||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/selectable_text.dart';
|
|
||||||
import 'package:PiliPlus/utils/app_scheme.dart';
|
import 'package:PiliPlus/utils/app_scheme.dart';
|
||||||
import 'package:PiliPlus/utils/date_utils.dart';
|
import 'package:PiliPlus/utils/date_utils.dart';
|
||||||
import 'package:PiliPlus/utils/duration_utils.dart';
|
import 'package:PiliPlus/utils/duration_utils.dart';
|
||||||
|
|||||||
@@ -19,22 +19,3 @@ Widget selectableText(
|
|||||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget selectableRichText(
|
|
||||||
TextSpan textSpan, {
|
|
||||||
TextStyle? style,
|
|
||||||
}) {
|
|
||||||
if (PlatformUtils.isDesktop) {
|
|
||||||
return SelectionArea(
|
|
||||||
child: Text.rich(
|
|
||||||
style: style,
|
|
||||||
textSpan,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return SelectableText.rich(
|
|
||||||
style: style,
|
|
||||||
textSpan,
|
|
||||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user