// 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. // @dart = 2.8 import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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'; /// 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}) { // This does not care about composing. return TextSpan( style: style, children: [_textSpan], ); } @override set text(String newText) { // This should never be reached. throw UnimplementedError(); } } class _SpecialSelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { _SpecialSelectableTextSelectionGestureDetectorBuilder({ @required _SpecialSelectableTextState state, }) : _state = state, super(delegate: state); final _SpecialSelectableTextState _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 [SpecialSelectableText] 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 /// SpecialSelectableText( /// 'Hello! How are you?', /// textAlign: TextAlign.center, /// style: TextStyle(fontWeight: FontWeight.bold), /// ) /// ``` /// {@end-tool} /// /// Using the [SpecialSelectableText.rich] constructor, the [SpecialSelectableText] 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 SpecialSelectableText.rich( /// TextSpan( /// text: 'Hello', // default text style /// children: [ /// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), /// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), /// ], /// ), /// ) /// ``` /// {@end-tool} /// /// ## Interactivity /// /// To make [SpecialSelectableText] 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 SpecialSelectableText 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 SpecialSelectableText( 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.textHeightBehavior, 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 SpecialSelectableText 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 SpecialSelectableText.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.textHeightBehavior, 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 SpecialSelectableText.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.dart:ui.textHeightBehavior} final TextHeightBehavior textHeightBehavior; /// {@macro flutter.painting.textPainter.textWidthBasis} final TextWidthBasis textWidthBasis; @override _SpecialSelectableTextState createState() => _SpecialSelectableTextState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('data', data, defaultValue: null)); properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); properties.add(DiagnosticsProperty('style', style, defaultValue: null)); properties.add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); properties.add(DiagnosticsProperty('showCursor', showCursor, defaultValue: false)); properties.add(IntProperty('minLines', minLines, defaultValue: null)); properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); properties.add(EnumProperty('textAlign', textAlign, defaultValue: null)); properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, defaultValue: null)); properties.add(DiagnosticsProperty('cursorColor', cursorColor, defaultValue: null)); properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled')); properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); properties.add(DiagnosticsProperty('textHeightBehavior', textHeightBehavior, defaultValue: null)); } } class _SpecialSelectableTextState extends State with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate { EditableTextState get _editableText => editableTextKey.currentState; _TextSpanEditingController _controller; FocusNode _keyFocusNode = FocusNode(); FocusNode _focusNode; FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); bool _showSelectionHandles = false; _SpecialSelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; // API for TextSelectionGestureDetectorBuilderDelegate. @override bool forcePressEnabled; @override final GlobalKey editableTextKey = GlobalKey(); @override bool get selectionEnabled => widget.selectionEnabled; // End of API for TextSelectionGestureDetectorBuilderDelegate. @override void initState() { super.initState(); _selectionGestureDetectorBuilder = _SpecialSelectableTextSelectionGestureDetectorBuilder(state: this); _controller = _TextSpanEditingController( textSpan: widget.textSpan ?? TextSpan(text: widget.data) ); } @override void didUpdateWidget(SpecialSelectableText 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); }(), 'SpecialSelectableText 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, 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: 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: RawKeyboardListener( focusNode: _keyFocusNode, onKey: _onKey, child: child, ) ), ); } _onKey(RawKeyEvent event) { // We don't care about key up events if (event is RawKeyUpEvent) { return; } //TODO: tab to next focus node // Handle special keyboard events with control key if (event.data.isControlPressed) { // Handle select all if (event.logicalKey == LogicalKeyboardKey.keyA) { _controller.selection = TextSelection(baseOffset: 0, extentOffset: _controller.text.length); return; } // Handle copy if (event.logicalKey == LogicalKeyboardKey.keyC) { Clipboard.setData(ClipboardData(text: _controller.selection.textInside(_controller.text))); return; } } } }