| // 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 'package:flutter/cupertino.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| |
| import 'feedback.dart'; |
| import 'text_selection.dart'; |
| import 'theme.dart'; |
| |
| /// An eyeballed value that moves the cursor slightly left of where it is |
| /// rendered for text on Android so its positioning more accurately matches the |
| /// native iOS text cursor positioning. |
| /// |
| /// This value is in device pixels, not logical pixels as is typically used |
| /// throughout the codebase. |
| const int iOSHorizontalOffset = -2; |
| |
| class _TextSpanEditingController extends TextEditingController { |
| _TextSpanEditingController({@required TextSpan textSpan}): |
| assert(textSpan != null), |
| _textSpan = textSpan, |
| super(text: textSpan.toPlainText()); |
| |
| final TextSpan _textSpan; |
| |
| @override |
| TextSpan buildTextSpan({TextStyle style ,bool withComposing}) { |
| // TODO(chunhtai): Implement composing. |
| return TextSpan( |
| style: style, |
| children: <TextSpan>[_textSpan], |
| ); |
| } |
| |
| @override |
| set text(String newText) { |
| // TODO(chunhtai): Implement value editing. |
| } |
| } |
| |
| class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { |
| _SelectableTextSelectionGestureDetectorBuilder({ |
| @required _SelectableTextState state, |
| }) : _state = state, |
| super(delegate: state); |
| |
| final _SelectableTextState _state; |
| |
| @override |
| void onForcePressStart(ForcePressDetails details) { |
| super.onForcePressStart(details); |
| if (delegate.selectionEnabled && shouldShowSelectionToolbar) { |
| editableText.showToolbar(); |
| } |
| } |
| |
| @override |
| void onForcePressEnd(ForcePressDetails details) { |
| // Not required. |
| } |
| |
| @override |
| void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { |
| if (delegate.selectionEnabled) { |
| switch (Theme.of(_state.context).platform) { |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| renderEditable.selectPositionAt( |
| from: details.globalPosition, |
| cause: SelectionChangedCause.longPress, |
| ); |
| break; |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| renderEditable.selectWordsInRange( |
| from: details.globalPosition - details.offsetFromOrigin, |
| to: details.globalPosition, |
| cause: SelectionChangedCause.longPress, |
| ); |
| break; |
| } |
| } |
| } |
| |
| @override |
| void onSingleTapUp(TapUpDetails details) { |
| editableText.hideToolbar(); |
| if (delegate.selectionEnabled) { |
| switch (Theme.of(_state.context).platform) { |
| case TargetPlatform.iOS: |
| 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; |
| } |
| } |
| if (_state.widget.onTap != null) |
| _state.widget.onTap(); |
| } |
| |
| @override |
| void onSingleLongTapStart(LongPressStartDetails details) { |
| if (delegate.selectionEnabled) { |
| switch (Theme.of(_state.context).platform) { |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| renderEditable.selectPositionAt( |
| from: details.globalPosition, |
| cause: SelectionChangedCause.longPress, |
| ); |
| break; |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| renderEditable.selectWord(cause: SelectionChangedCause.longPress); |
| Feedback.forLongPress(_state.context); |
| break; |
| } |
| } |
| } |
| } |
| |
| /// A run of selectable text with a single style. |
| /// |
| /// The [SelectableText] widget displays a string of text with a single style. |
| /// The string might break across multiple lines or might all be displayed on |
| /// the same line depending on the layout constraints. |
| /// |
| /// {@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. |
| /// |
| /// {@tool snippet} |
| /// |
| /// ```dart |
| /// SelectableText( |
| /// 'Hello! How are you?', |
| /// textAlign: TextAlign.center, |
| /// style: TextStyle(fontWeight: FontWeight.bold), |
| /// ) |
| /// ``` |
| /// {@end-tool} |
| /// |
| /// Using the [SelectableText.rich] constructor, the [SelectableText] widget can |
| /// display a paragraph with differently styled [TextSpan]s. The sample |
| /// that follows displays "Hello beautiful world" with different styles |
| /// for each word. |
| /// |
| /// {@tool 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], and [data] parameters |
| /// must not be null. If specified, the [maxLines] argument must be greater |
| /// than zero. |
| const SelectableText( |
| 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.cursorRadius, |
| this.cursorColor, |
| this.dragStartBehavior = DragStartBehavior.start, |
| this.enableInteractiveSelection = true, |
| this.onTap, |
| this.scrollPhysics, |
| this.textWidthBasis, |
| }) : assert(showCursor != null), |
| assert(autofocus != null), |
| assert(dragStartBehavior != null), |
| assert(maxLines == null || maxLines > 0), |
| assert(minLines == null || minLines > 0), |
| assert( |
| (maxLines == null) || (minLines == null) || (maxLines >= minLines), |
| 'minLines can\'t be greater than maxLines', |
| ), |
| assert( |
| data != null, |
| 'A non-null String must be provided to a SelectableText widget.', |
| ), |
| textSpan = null, |
| toolbarOptions = toolbarOptions ?? |
| const ToolbarOptions( |
| selectAll: true, |
| copy: true, |
| ), |
| super(key: key); |
| |
| /// Creates a selectable text widget with a [TextSpan]. |
| /// |
| /// The [textSpan] parameter must not be null and only contain [TextSpan] in |
| /// [textSpan.children]. Other type of [InlineSpan] is not allowed. |
| /// |
| /// The [autofocus] and [dragStartBehavior] arguments must not be null. |
| const SelectableText.rich( |
| 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.cursorRadius, |
| this.cursorColor, |
| this.dragStartBehavior = DragStartBehavior.start, |
| this.enableInteractiveSelection = true, |
| this.onTap, |
| this.scrollPhysics, |
| this.textWidthBasis, |
| }) : assert(showCursor != null), |
| assert(autofocus != null), |
| assert(dragStartBehavior != null), |
| assert(maxLines == null || maxLines > 0), |
| assert(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]. |
| 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.cursorRadius} |
| final Radius cursorRadius; |
| |
| /// The color to use when painting the cursor. |
| /// |
| /// Defaults to the theme's `cursorColor` when null. |
| final Color cursorColor; |
| |
| /// {@macro flutter.widgets.editableText.enableInteractiveSelection} |
| final bool enableInteractiveSelection; |
| |
| /// {@macro flutter.widgets.scrollable.dragStartBehavior} |
| final DragStartBehavior dragStartBehavior; |
| |
| /// 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.rendering.editable.selectionEnabled} |
| bool get selectionEnabled { |
| return enableInteractiveSelection; |
| } |
| |
| /// Called when the user taps on this selectable text. |
| /// |
| /// The selectable text builds a [GestureDetector] to handle input events like tap, |
| /// to trigger focus requests, to move the caret, adjust the selection, etc. |
| /// Handling some of those events by wrapping the selectable text with a competing |
| /// GestureDetector is problematic. |
| /// |
| /// To unconditionally handle taps, without interfering with the selectable text's |
| /// internal gesture detector, provide this callback. |
| /// |
| /// To be notified when the text field gains or loses the focus, provide a |
| /// [focusNode] and add a listener to that. |
| /// |
| /// To listen to arbitrary pointer events without competing with the |
| /// selectable text's internal gesture detector, use a [Listener]. |
| final GestureTapCallback onTap; |
| |
| /// {@macro flutter.widgets.editableText.scrollPhysics} |
| final ScrollPhysics scrollPhysics; |
| |
| /// {@macro flutter.painting.textPainter.textWidthBasis} |
| final TextWidthBasis textWidthBasis; |
| |
| @override |
| _SelectableTextState createState() => _SelectableTextState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null)); |
| properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); |
| properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null)); |
| properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false)); |
| properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false)); |
| properties.add(IntProperty('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(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null)); |
| properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled')); |
| properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null)); |
| } |
| } |
| |
| class _SelectableTextState extends State<SelectableText> with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate { |
| EditableTextState get _editableText => editableTextKey.currentState; |
| |
| _TextSpanEditingController _controller; |
| |
| FocusNode _focusNode; |
| FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); |
| |
| bool _showSelectionHandles = false; |
| |
| _SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; |
| |
| // API for TextSelectionGestureDetectorBuilderDelegate. |
| @override |
| bool forcePressEnabled; |
| |
| @override |
| final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>(); |
| |
| @override |
| bool get selectionEnabled => widget.selectionEnabled; |
| // End of API for TextSelectionGestureDetectorBuilderDelegate. |
| |
| @override |
| void initState() { |
| super.initState(); |
| _selectionGestureDetectorBuilder = _SelectableTextSelectionGestureDetectorBuilder(state: this); |
| _controller = _TextSpanEditingController( |
| textSpan: widget.textSpan ?? TextSpan(text: widget.data) |
| ); |
| } |
| |
| @override |
| void didUpdateWidget(SelectableText oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) { |
| _controller = _TextSpanEditingController( |
| textSpan: widget.textSpan ?? TextSpan(text: widget.data) |
| ); |
| } |
| if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { |
| _showSelectionHandles = false; |
| } |
| } |
| |
| @override |
| void dispose() { |
| _focusNode?.dispose(); |
| super.dispose(); |
| } |
| |
| void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { |
| final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); |
| if (willShowSelectionHandles != _showSelectionHandles) { |
| setState(() { |
| _showSelectionHandles = willShowSelectionHandles; |
| }); |
| } |
| |
| switch (Theme.of(context).platform) { |
| case TargetPlatform.iOS: |
| 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 |
| bool get wantKeepAlive => true; |
| |
| @override |
| Widget build(BuildContext context) { |
| super.build(context); // See AutomaticKeepAliveClientMixin. |
| assert(() { |
| return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan); |
| }(), 'SelectableText only supports TextSpan; Other type of InlineSpan is not allowed'); |
| assert(debugCheckHasMediaQuery(context)); |
| assert(debugCheckHasDirectionality(context)); |
| assert( |
| !(widget.style != null && widget.style.inherit == false && |
| (widget.style.fontSize == null || widget.style.textBaseline == null)), |
| 'inherit false style must supply fontSize and textBaseline', |
| ); |
| |
| final ThemeData themeData = Theme.of(context); |
| final FocusNode focusNode = _effectiveFocusNode; |
| |
| TextSelectionControls textSelectionControls; |
| bool paintCursorAboveText; |
| bool cursorOpacityAnimates; |
| Offset cursorOffset; |
| Color cursorColor = widget.cursorColor; |
| Radius cursorRadius = widget.cursorRadius; |
| |
| switch (themeData.platform) { |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| forcePressEnabled = true; |
| textSelectionControls = cupertinoTextSelectionControls; |
| paintCursorAboveText = true; |
| cursorOpacityAnimates = true; |
| cursorColor ??= CupertinoTheme.of(context).primaryColor; |
| cursorRadius ??= const Radius.circular(2.0); |
| cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); |
| break; |
| |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| forcePressEnabled = false; |
| textSelectionControls = materialTextSelectionControls; |
| paintCursorAboveText = false; |
| cursorOpacityAnimates = false; |
| cursorColor ??= themeData.cursorColor; |
| break; |
| } |
| |
| final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); |
| TextStyle effectiveTextStyle = widget.style; |
| if (widget.style == null || widget.style.inherit) |
| effectiveTextStyle = defaultTextStyle.style.merge(widget.style); |
| if (MediaQuery.boldTextOverride(context)) |
| effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold)); |
| final Widget child = RepaintBoundary( |
| child: EditableText( |
| key: editableTextKey, |
| style: effectiveTextStyle, |
| readOnly: true, |
| textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, |
| showSelectionHandles: _showSelectionHandles, |
| showCursor: widget.showCursor, |
| controller: _controller, |
| focusNode: focusNode, |
| strutStyle: widget.strutStyle ?? 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: themeData.textSelectionColor, |
| selectionControls: widget.selectionEnabled ? textSelectionControls : null, |
| onSelectionChanged: _handleSelectionChanged, |
| onSelectionHandleTapped: _handleSelectionHandleTapped, |
| rendererIgnoresPointer: true, |
| cursorWidth: widget.cursorWidth, |
| cursorRadius: cursorRadius, |
| cursorColor: cursorColor, |
| cursorOpacityAnimates: cursorOpacityAnimates, |
| cursorOffset: cursorOffset, |
| paintCursorAboveText: paintCursorAboveText, |
| backgroundCursorColor: CupertinoColors.inactiveGray, |
| enableInteractiveSelection: widget.enableInteractiveSelection, |
| dragStartBehavior: widget.dragStartBehavior, |
| scrollPhysics: widget.scrollPhysics, |
| ), |
| ); |
| |
| return Semantics( |
| onTap: () { |
| if (!_controller.selection.isValid) |
| _controller.selection = TextSelection.collapsed(offset: _controller.text.length); |
| _effectiveFocusNode.requestFocus(); |
| }, |
| onLongPress: () { |
| _effectiveFocusNode.requestFocus(); |
| }, |
| child: _selectionGestureDetectorBuilder.buildGestureDetector( |
| behavior: HitTestBehavior.translucent, |
| child: child, |
| ), |
| ); |
| } |
| } |