// Copyright 2015 The Chromium 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:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'automatic_keep_alive.dart'; import 'basic.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; import 'framework.dart'; import 'media_query.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; import 'text_selection.dart'; export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType; export 'package:flutter/rendering.dart' show SelectionChangedCause; /// Signature for the callback that reports when the user changes the selection /// (including the cursor location). typedef void SelectionChangedCallback(TextSelection selection, SelectionChangedCause cause); const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); // Number of cursor ticks during which the most recently entered character // is shown in an obscured text field. const int _kObscureShowLatestCharCursorTicks = 3; /// A controller for an editable text field. /// /// Whenever the user modifies a text field with an associated /// [TextEditingController], the text field updates [value] and the controller /// notifies its listeners. Listeners can then read the [text] and [selection] /// properties to learn what the user has typed or how the selection has been /// updated. /// /// Similarly, if you modify the [text] or [selection] properties, the text /// field will be notified and will update itself appropriately. /// /// A [TextEditingController] can also be used to provide an initial value for a /// text field. If you build a text field with a controller that already has /// [text], the text field will use that text as its initial value. /// /// See also: /// /// * [TextField], which is a Material Design text field that can be controlled /// with a [TextEditingController]. /// * [EditableText], which is a raw region of editable text that can be /// controlled with a [TextEditingController]. class TextEditingController extends ValueNotifier<TextEditingValue> { /// Creates a controller for an editable text field. /// /// This constructor treats a null [text] argument as if it were the empty /// string. TextEditingController({ String text }) : super(text == null ? TextEditingValue.empty : new TextEditingValue(text: text)); /// Creates a controller for an editable text field from an initial [TextEditingValue]. /// /// This constructor treats a null [value] argument as if it were /// [TextEditingValue.empty]. TextEditingController.fromValue(TextEditingValue value) : super(value ?? TextEditingValue.empty); /// The current string the user is editing. String get text => value.text; /// Setting this will notify all the listeners of this [TextEditingController] /// that they need to update (it calls [notifyListeners]). For this reason, /// this value should only be set between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. set text(String newText) { value = value.copyWith(text: newText, selection: const TextSelection.collapsed(offset: -1), composing: TextRange.empty); } /// The currently selected [text]. /// /// If the selection is collapsed, then this property gives the offset of the /// cursor within the text. TextSelection get selection => value.selection; /// Setting this will notify all the listeners of this [TextEditingController] /// that they need to update (it calls [notifyListeners]). For this reason, /// this value should only be set between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. set selection(TextSelection newSelection) { if (newSelection.start > text.length || newSelection.end > text.length) throw new FlutterError('invalid text selection: $newSelection'); value = value.copyWith(selection: newSelection, composing: TextRange.empty); } /// Set the [value] to empty. /// /// After calling this function, [text] will be the empty string and the /// selection will be invalid. /// /// Calling this will notify all the listeners of this [TextEditingController] /// that they need to update (it calls [notifyListeners]). For this reason, /// this method should only be called between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. void clear() { value = TextEditingValue.empty; } /// Set the composing region to an empty range. /// /// The composing region is the range of text that is still being composed. /// Calling this function indicates that the user is done composing that /// region. /// /// Calling this will notify all the listeners of this [TextEditingController] /// that they need to update (it calls [notifyListeners]). For this reason, /// this method should only be called between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. void clearComposing() { value = value.copyWith(composing: TextRange.empty); } } /// A basic text input field. /// /// This widget interacts with the [TextInput] service to let the user edit the /// text it contains. It also provides scrolling, selection, and cursor /// movement. This widget does not provide any focus management (e.g., /// tap-to-focus). /// /// Rather than using this widget directly, consider using [TextField], which /// is a full-featured, material-design text input field with placeholder text, /// labels, and [Form] integration. /// /// See also: /// /// * [TextField], which is a full-featured, material-design text input field /// with placeholder text, labels, and [Form] integration. class EditableText extends StatefulWidget { /// Creates a basic text input control. /// /// The [maxLines] property can be set to null to remove the restriction on /// the number of lines. By default, it is one, meaning this is a single-line /// text field. [maxLines] must be null or greater than zero. /// /// If [keyboardType] is not set or is null, it will default to /// [TextInputType.text] unless [maxLines] is greater than one, when it will /// default to [TextInputType.multiline]. /// /// The [controller], [focusNode], [style], [cursorColor], [textAlign], /// and [rendererIgnoresPointer], arguments must not be null. EditableText({ Key key, @required this.controller, @required this.focusNode, this.obscureText: false, this.autocorrect: true, @required this.style, @required this.cursorColor, this.textAlign: TextAlign.start, this.textDirection, this.textScaleFactor, this.maxLines: 1, this.autofocus: false, this.selectionColor, this.selectionControls, TextInputType keyboardType, this.onChanged, this.onSubmitted, this.onSelectionChanged, List<TextInputFormatter> inputFormatters, this.rendererIgnoresPointer: false, }) : assert(controller != null), assert(focusNode != null), assert(obscureText != null), assert(autocorrect != null), assert(style != null), assert(cursorColor != null), assert(textAlign != null), assert(maxLines == null || maxLines > 0), assert(autofocus != null), assert(rendererIgnoresPointer != null), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), inputFormatters = maxLines == 1 ? ( <TextInputFormatter>[BlacklistingTextInputFormatter.singleLineFormatter] ..addAll(inputFormatters ?? const Iterable<TextInputFormatter>.empty()) ) : inputFormatters, super(key: key); /// Controls the text being edited. final TextEditingController controller; /// Controls whether this widget has keyboard focus. final FocusNode focusNode; /// Whether to hide the text being edited (e.g., for passwords). /// /// Defaults to false. final bool obscureText; /// Whether to enable autocorrection. /// /// Defaults to true. final bool autocorrect; /// The text style to use for the editable text. final TextStyle style; /// How the text should be aligned horizontally. /// /// Defaults to [TextAlign.start]. final TextAlign textAlign; /// The directionality of the text. /// /// This decides how [textAlign] values like [TextAlign.start] and /// [TextAlign.end] are interpreted. /// /// This is also used to disambiguate how to render bidirectional text. For /// example, if the text is an English phrase followed by a Hebrew phrase, /// in a [TextDirection.ltr] context the English phrase will be on the left /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] /// context, the English phrase will be on the right and the Hebrew phrase on /// its left. /// /// Defaults to the ambient [Directionality], if any. final TextDirection textDirection; /// The number of font pixels for each logical pixel. /// /// For example, if the text scale factor is 1.5, text will be 50% larger than /// the specified font size. /// /// Defaults to the [MediaQueryData.textScaleFactor] obtained from the ambient /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. final double textScaleFactor; /// The color to use when painting the cursor. final Color cursorColor; /// The maximum number of lines for the text to span, wrapping if necessary. /// /// If this is 1 (the default), the text will not wrap, but will scroll /// horizontally instead. /// /// If this is null, there is no limit to the number of lines. If it is not /// null, the value must be greater than zero. final int maxLines; /// Whether this input field should focus itself if nothing else is already focused. /// If true, the keyboard will open as soon as this input obtains focus. Otherwise, /// the keyboard is only shown after the user taps the text field. /// /// Defaults to false. final bool autofocus; /// The color to use when painting the selection. final Color selectionColor; /// Optional delegate for building the text selection handles and toolbar. final TextSelectionControls selectionControls; /// The type of keyboard to use for editing the text. final TextInputType keyboardType; /// Called when the text being edited changes. final ValueChanged<String> onChanged; /// Called when the user indicates that they are done editing the text in the field. final ValueChanged<String> onSubmitted; /// Called when the user changes the selection of text (including the cursor /// location). final SelectionChangedCallback onSelectionChanged; /// Optional input validation and formatting overrides. Formatters are run /// in the provided order when the text input changes. final List<TextInputFormatter> inputFormatters; /// If true, the [RenderEditable] created by this widget will not handle /// pointer events, see [renderEditable] and [RenderEditable.ignorePointer]. /// /// This property is false by default. final bool rendererIgnoresPointer; @override EditableTextState createState() => new EditableTextState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(new DiagnosticsProperty<TextEditingController>('controller', controller)); properties.add(new DiagnosticsProperty<FocusNode>('focusNode', focusNode)); properties.add(new DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false)); properties.add(new DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: true)); style?.debugFillProperties(properties); properties.add(new EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null)); properties.add(new EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null)); properties.add(new DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); properties.add(new IntProperty('maxLines', maxLines, defaultValue: 1)); properties.add(new DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false)); properties.add(new DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: null)); } } /// State for a [EditableText]. class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin implements TextInputClient, TextSelectionDelegate { Timer _cursorTimer; final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false); final GlobalKey _editableKey = new GlobalKey(); TextInputConnection _textInputConnection; TextSelectionOverlay _selectionOverlay; final ScrollController _scrollController = new ScrollController(); final LayerLink _layerLink = new LayerLink(); bool _didAutoFocus = false; @override bool get wantKeepAlive => widget.focusNode.hasFocus; // State lifecycle: @override void initState() { super.initState(); widget.controller.addListener(_didChangeTextEditingValue); widget.focusNode.addListener(_handleFocusChanged); _scrollController.addListener(() { _selectionOverlay?.updateForScroll(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_didAutoFocus && widget.autofocus) { FocusScope.of(context).autofocus(widget.focusNode); _didAutoFocus = true; } } @override void didUpdateWidget(EditableText oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.removeListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue); _updateRemoteEditingValueIfNeeded(); } if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } } @override void dispose() { widget.controller.removeListener(_didChangeTextEditingValue); _closeInputConnectionIfNeeded(); assert(!_hasInputConnection); _stopCursorTimer(); assert(_cursorTimer == null); _selectionOverlay?.dispose(); _selectionOverlay = null; widget.focusNode.removeListener(_handleFocusChanged); super.dispose(); } // TextInputClient implementation: TextEditingValue _lastKnownRemoteTextEditingValue; @override void updateEditingValue(TextEditingValue value) { if (value.text != _value.text) { _hideSelectionOverlayIfNeeded(); if (widget.obscureText && value.text.length == _value.text.length + 1) { _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; _obscureLatestCharIndex = _value.selection.baseOffset; } } _lastKnownRemoteTextEditingValue = value; _formatAndSetValue(value); } @override void performAction(TextInputAction action) { switch (action) { case TextInputAction.done: widget.controller.clearComposing(); widget.focusNode.unfocus(); if (widget.onSubmitted != null) widget.onSubmitted(_value.text); break; case TextInputAction.newline: // Do nothing for a "newline" action: the newline is already inserted. break; } } void _updateRemoteEditingValueIfNeeded() { if (!_hasInputConnection) return; final TextEditingValue localValue = _value; if (localValue == _lastKnownRemoteTextEditingValue) return; _lastKnownRemoteTextEditingValue = localValue; _textInputConnection.setEditingState(localValue); } TextEditingValue get _value => widget.controller.value; set _value(TextEditingValue value) { widget.controller.value = value; } bool get _hasFocus => widget.focusNode.hasFocus; bool get _isMultiline => widget.maxLines != 1; // Calculate the new scroll offset so the cursor remains visible. double _getScrollOffsetForCaret(Rect caretRect) { final double caretStart = _isMultiline ? caretRect.top : caretRect.left; final double caretEnd = _isMultiline ? caretRect.bottom : caretRect.right; double scrollOffset = _scrollController.offset; final double viewportExtent = _scrollController.position.viewportDimension; if (caretStart < 0.0) // cursor before start of bounds scrollOffset += caretStart; else if (caretEnd >= viewportExtent) // cursor after end of bounds scrollOffset += caretEnd - viewportExtent; return scrollOffset; } bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached; void _openInputConnection() { if (!_hasInputConnection) { final TextEditingValue localValue = _value; _lastKnownRemoteTextEditingValue = localValue; _textInputConnection = TextInput.attach(this, new TextInputConfiguration( inputType: widget.keyboardType, obscureText: widget.obscureText, autocorrect: widget.autocorrect, inputAction: widget.keyboardType == TextInputType.multiline ? TextInputAction.newline : TextInputAction.done ) )..setEditingState(localValue); } _textInputConnection.show(); } void _closeInputConnectionIfNeeded() { if (_hasInputConnection) { _textInputConnection.close(); _textInputConnection = null; _lastKnownRemoteTextEditingValue = null; } } void _openOrCloseInputConnectionIfNeeded() { if (_hasFocus && widget.focusNode.consumeKeyboardToken()) { _openInputConnection(); } else if (!_hasFocus) { _closeInputConnectionIfNeeded(); widget.controller.clearComposing(); } } /// Express interest in interacting with the keyboard. /// /// If this control is already attached to the keyboard, this function will /// request that the keyboard become visible. Otherwise, this function will /// ask the focus system that it become focused. If successful in acquiring /// focus, the control will then attach to the keyboard and request that the /// keyboard become visible. void requestKeyboard() { if (_hasFocus) _openInputConnection(); else FocusScope.of(context).requestFocus(widget.focusNode); } void _hideSelectionOverlayIfNeeded() { _selectionOverlay?.hide(); _selectionOverlay = null; } void _updateOrDisposeSelectionOverlayIfNeeded() { if (_selectionOverlay != null) { if (_hasFocus) { _selectionOverlay.update(_value); } else { _selectionOverlay.dispose(); _selectionOverlay = null; } } } void _handleSelectionChanged(TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { widget.controller.selection = selection; // This will show the keyboard for all selection changes on the // EditableWidget, not just changes triggered by user gestures. requestKeyboard(); _hideSelectionOverlayIfNeeded(); if (widget.selectionControls != null) { _selectionOverlay = new TextSelectionOverlay( context: context, value: _value, debugRequiredFor: widget, layerLink: _layerLink, renderObject: renderObject, selectionControls: widget.selectionControls, selectionDelegate: this, ); final bool longPress = cause == SelectionChangedCause.longPress; if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress)) _selectionOverlay.showHandles(); if (longPress) _selectionOverlay.showToolbar(); if (widget.onSelectionChanged != null) widget.onSelectionChanged(selection, cause); } } bool _textChangedSinceLastCaretUpdate = false; void _handleCaretChanged(Rect caretRect) { // If the caret location has changed due to an update to the text or // selection, then scroll the caret into view. if (_textChangedSinceLastCaretUpdate) { _textChangedSinceLastCaretUpdate = false; scheduleMicrotask(() { _scrollController.animateTo( _getScrollOffsetForCaret(caretRect), curve: Curves.fastOutSlowIn, duration: const Duration(milliseconds: 50), ); }); } } void _formatAndSetValue(TextEditingValue value) { final bool textChanged = _value?.text != value?.text; if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { for (TextInputFormatter formatter in widget.inputFormatters) value = formatter.formatEditUpdate(_value, value); _value = value; _updateRemoteEditingValueIfNeeded(); } else { _value = value; } if (textChanged && widget.onChanged != null) widget.onChanged(value.text); } /// Whether the blinking cursor is actually visible at this precise moment /// (it's hidden half the time, since it blinks). @visibleForTesting bool get cursorCurrentlyVisible => _showCursor.value; /// The cursor blink interval (the amount of time the cursor is in the "on" /// state or the "off" state). A complete cursor blink period is twice this /// value (half on, half off). @visibleForTesting Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod; /// The current status of the text selection handles. @visibleForTesting TextSelectionOverlay get selectionOverlay => _selectionOverlay; int _obscureShowCharTicksPending = 0; int _obscureLatestCharIndex; void _cursorTick(Timer timer) { _showCursor.value = !_showCursor.value; if (_obscureShowCharTicksPending > 0) { setState(() { _obscureShowCharTicksPending--; }); } } void _startCursorTimer() { _showCursor.value = true; _cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); } void _stopCursorTimer() { _cursorTimer?.cancel(); _cursorTimer = null; _showCursor.value = false; _obscureShowCharTicksPending = 0; } void _startOrStopCursorTimerIfNeeded() { if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) _startCursorTimer(); else if (_cursorTimer != null && (!_hasFocus || !_value.selection.isCollapsed)) _stopCursorTimer(); } void _didChangeTextEditingValue() { _updateRemoteEditingValueIfNeeded(); _startOrStopCursorTimerIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded(); _textChangedSinceLastCaretUpdate = true; // TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue> // to avoid this setState(). setState(() { /* We use widget.controller.value in build(). */ }); } void _handleFocusChanged() { _openOrCloseInputConnectionIfNeeded(); _startOrStopCursorTimerIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded(); if (!_hasFocus) { // Clear the selection and composition state if this widget lost focus. _value = new TextEditingValue(text: _value.text); } updateKeepAlive(); } TextDirection get _textDirection { final TextDirection result = widget.textDirection ?? Directionality.of(context); assert(result != null, '$runtimeType created without a textDirection and with no ambient Directionality.'); return result; } /// The renderer for this widget's [Editable] descendant. /// /// This property is typically used to notify the renderer of input gestures /// when [ignorePointer] is true. See [RenderEditable.ignorePointer]. RenderEditable get renderEditable => _editableKey.currentContext.findRenderObject(); @override TextEditingValue get textEditingValue => _value; @override set textEditingValue(TextEditingValue value) { _selectionOverlay?.update(value); _formatAndSetValue(value); } @override void bringIntoView(TextPosition position) { _scrollController.jumpTo(_getScrollOffsetForCaret(renderEditable.getLocalRectForCaret(position))); } @override void hideToolbar() { _selectionOverlay?.hide(); } @override Widget build(BuildContext context) { FocusScope.of(context).reparentIfNeeded(widget.focusNode); super.build(context); // See AutomaticKeepAliveClientMixin. final TextSelectionControls controls = widget.selectionControls; return new Scrollable( excludeFromSemantics: true, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, controller: _scrollController, physics: const ClampingScrollPhysics(), viewportBuilder: (BuildContext context, ViewportOffset offset) { return new CompositedTransformTarget( link: _layerLink, child: new Semantics( onCopy: _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) : null, onCut: _hasFocus && controls?.canCut(this) == true ? () => controls.handleCut(this) : null, onPaste: _hasFocus && controls?.canPaste(this) == true ? () => controls.handlePaste(this) : null, child: new _Editable( key: _editableKey, value: _value, style: widget.style, cursorColor: widget.cursorColor, showCursor: _showCursor, hasFocus: _hasFocus, maxLines: widget.maxLines, selectionColor: widget.selectionColor, textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0, textAlign: widget.textAlign, textDirection: _textDirection, obscureText: widget.obscureText, obscureShowCharacterAtIndex: _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null, autocorrect: widget.autocorrect, offset: offset, onSelectionChanged: _handleSelectionChanged, onCaretChanged: _handleCaretChanged, rendererIgnoresPointer: widget.rendererIgnoresPointer, ), ), ); }, ); } } class _Editable extends LeafRenderObjectWidget { const _Editable({ Key key, this.value, this.style, this.cursorColor, this.showCursor, this.hasFocus, this.maxLines, this.selectionColor, this.textScaleFactor, this.textAlign, @required this.textDirection, this.obscureText, this.obscureShowCharacterAtIndex, this.autocorrect, this.offset, this.onSelectionChanged, this.onCaretChanged, this.rendererIgnoresPointer: false, }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), super(key: key); final TextEditingValue value; final TextStyle style; final Color cursorColor; final ValueNotifier<bool> showCursor; final bool hasFocus; final int maxLines; final Color selectionColor; final double textScaleFactor; final TextAlign textAlign; final TextDirection textDirection; final bool obscureText; final int obscureShowCharacterAtIndex; final bool autocorrect; final ViewportOffset offset; final SelectionChangedHandler onSelectionChanged; final CaretChangedHandler onCaretChanged; final bool rendererIgnoresPointer; @override RenderEditable createRenderObject(BuildContext context) { return new RenderEditable( text: _styledTextSpan, cursorColor: cursorColor, showCursor: showCursor, hasFocus: hasFocus, maxLines: maxLines, selectionColor: selectionColor, textScaleFactor: textScaleFactor, textAlign: textAlign, textDirection: textDirection, selection: value.selection, offset: offset, onSelectionChanged: onSelectionChanged, onCaretChanged: onCaretChanged, ignorePointer: rendererIgnoresPointer, obscureText: obscureText, ); } @override void updateRenderObject(BuildContext context, RenderEditable renderObject) { renderObject ..text = _styledTextSpan ..cursorColor = cursorColor ..showCursor = showCursor ..hasFocus = hasFocus ..maxLines = maxLines ..selectionColor = selectionColor ..textScaleFactor = textScaleFactor ..textAlign = textAlign ..textDirection = textDirection ..selection = value.selection ..offset = offset ..onSelectionChanged = onSelectionChanged ..onCaretChanged = onCaretChanged ..ignorePointer = rendererIgnoresPointer ..obscureText = obscureText; } TextSpan get _styledTextSpan { if (!obscureText && value.composing.isValid) { final TextStyle composingStyle = style.merge( const TextStyle(decoration: TextDecoration.underline) ); return new TextSpan( style: style, children: <TextSpan>[ new TextSpan(text: value.composing.textBefore(value.text)), new TextSpan( style: composingStyle, text: value.composing.textInside(value.text) ), new TextSpan(text: value.composing.textAfter(value.text)) ]); } String text = value.text; if (obscureText) { text = RenderEditable.obscuringCharacter * text.length; final int o = obscureShowCharacterAtIndex; if (o != null && o >= 0 && o < text.length) text = text.replaceRange(o, o + 1, value.text.substring(o, o + 1)); } return new TextSpan(style: style, text: text); } }