| // 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/rendering.dart' show RenderEditable, SelectionChangedHandler, RenderEditablePaintOffsetNeededCallback; |
| import 'package:flutter/services.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import 'basic.dart'; |
| import 'focus.dart'; |
| import 'framework.dart'; |
| import 'media_query.dart'; |
| import 'scroll_behavior.dart'; |
| import 'scrollable.dart'; |
| import 'text_selection.dart'; |
| |
| export 'package:flutter/painting.dart' show TextSelection; |
| export 'package:flutter/services.dart' show TextInputType; |
| |
| const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); |
| |
| TextSelection _getTextSelectionFromEditingState(TextEditingState state) { |
| return new TextSelection( |
| baseOffset: state.selectionBase, |
| extentOffset: state.selectionExtent, |
| affinity: state.selectionAffinity, |
| isDirectional: state.selectionIsDirectional, |
| ); |
| } |
| |
| InputValue _getInputValueFromEditingState(TextEditingState state) { |
| return new InputValue( |
| text: state.text, |
| selection: _getTextSelectionFromEditingState(state), |
| composing: new TextRange(start: state.composingBase, end: state.composingExtent), |
| ); |
| } |
| |
| TextEditingState _getTextEditingStateFromInputValue(InputValue value) { |
| return new TextEditingState( |
| text: value.text, |
| selectionBase: value.selection.baseOffset, |
| selectionExtent: value.selection.extentOffset, |
| selectionAffinity: value.selection.affinity, |
| selectionIsDirectional: value.selection.isDirectional, |
| composingBase: value.composing.start, |
| composingExtent: value.composing.end, |
| ); |
| } |
| |
| /// Configuration information for an input field. |
| /// |
| /// An [InputValue] contains the text for the input field as well as the |
| /// selection extent and the composing range. |
| class InputValue { |
| // TODO(abarth): This class is really the same as TextEditingState. |
| // We should merge them into one object. |
| |
| /// Creates configuration information for an input field |
| /// |
| /// The selection and composing range must be within the text. |
| /// |
| /// The [text], [selection], and [composing] arguments must not be null but |
| /// each have default values. |
| const InputValue({ |
| this.text: '', |
| this.selection: const TextSelection.collapsed(offset: -1), |
| this.composing: TextRange.empty |
| }); |
| |
| /// The current text being edited. |
| final String text; |
| |
| /// The range of text that is currently selected. |
| final TextSelection selection; |
| |
| /// The range of text that is still being composed. |
| final TextRange composing; |
| |
| /// An input value that corresponds to the empty string with no selection and no composing range. |
| static const InputValue empty = const InputValue(); |
| |
| @override |
| String toString() => '$runtimeType(text: \u2524$text\u251C, selection: $selection, composing: $composing)'; |
| |
| @override |
| bool operator ==(dynamic other) { |
| if (identical(this, other)) |
| return true; |
| if (other is! InputValue) |
| return false; |
| InputValue typedOther = other; |
| return typedOther.text == text |
| && typedOther.selection == selection |
| && typedOther.composing == composing; |
| } |
| |
| @override |
| int get hashCode => hashValues( |
| text.hashCode, |
| selection.hashCode, |
| composing.hashCode |
| ); |
| |
| /// Creates a copy of this input value but with the given fields replaced with the new values. |
| InputValue copyWith({ |
| String text, |
| TextSelection selection, |
| TextRange composing |
| }) { |
| return new InputValue ( |
| text: text ?? this.text, |
| selection: selection ?? this.selection, |
| composing: composing ?? this.composing |
| ); |
| } |
| } |
| |
| /// A basic text input control. |
| /// |
| /// This control is not intended to be used directly. Instead, consider using |
| /// [Input], which provides focus management and material design. |
| class RawInput extends Scrollable { |
| /// Creates a basic text input control. |
| /// |
| /// The [value] argument must not be null. |
| RawInput({ |
| Key key, |
| @required this.value, |
| this.focusKey, |
| this.hideText: false, |
| this.style, |
| this.cursorColor, |
| this.textScaleFactor, |
| int maxLines: 1, |
| this.selectionColor, |
| this.selectionControls, |
| @required this.platform, |
| this.keyboardType, |
| this.onChanged, |
| this.onSubmitted |
| }) : maxLines = maxLines, super( |
| key: key, |
| initialScrollOffset: 0.0, |
| scrollDirection: maxLines > 1 ? Axis.vertical : Axis.horizontal |
| ) { |
| assert(value != null); |
| } |
| |
| /// The string being displayed in this widget. |
| final InputValue value; |
| |
| /// Key of the enclosing widget that holds the focus. |
| final GlobalKey focusKey; |
| |
| /// Whether to hide the text being edited (e.g., for passwords). |
| final bool hideText; |
| |
| /// The text style to use for the editable text. |
| final TextStyle style; |
| |
| /// 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 [MediaQuery.textScaleFactor]. |
| 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. |
| final int maxLines; |
| |
| /// 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 platform whose behavior should be approximated, in particular |
| /// for scroll physics. (See [ScrollBehavior.platform].) |
| /// |
| /// Must not be null. |
| final TargetPlatform platform; |
| |
| /// The type of keyboard to use for editing the text. |
| final TextInputType keyboardType; |
| |
| /// Called when the text being edited changes. |
| final ValueChanged<InputValue> onChanged; |
| |
| /// Called when the user indicates that they are done editing the text in the field. |
| final ValueChanged<InputValue> onSubmitted; |
| |
| @override |
| RawInputState createState() => new RawInputState(); |
| } |
| |
| /// State for a [RawInput]. |
| class RawInputState extends ScrollableState<RawInput> implements TextInputClient { |
| Timer _cursorTimer; |
| bool _showCursor = false; |
| |
| InputValue _currentValue; |
| TextInputConnection _textInputConnection; |
| TextSelectionOverlay _selectionOverlay; |
| |
| @override |
| ExtentScrollBehavior createScrollBehavior() => new BoundedBehavior(platform: config.platform); |
| |
| @override |
| BoundedBehavior get scrollBehavior => super.scrollBehavior; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _currentValue = config.value; |
| } |
| |
| @override |
| void didUpdateConfig(RawInput oldConfig) { |
| if (_currentValue != config.value) { |
| _currentValue = config.value; |
| if (_isAttachedToKeyboard) |
| _textInputConnection.setEditingState(_getTextEditingStateFromInputValue(_currentValue)); |
| } |
| } |
| |
| bool get _isAttachedToKeyboard => _textInputConnection != null && _textInputConnection.attached; |
| |
| bool get _isMultiline => config.maxLines > 1; |
| |
| double _contentExtent = 0.0; |
| double _containerExtent = 0.0; |
| |
| Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions, Rect caretRect) { |
| // We make various state changes here but don't have to do so in a |
| // setState() callback because we are called during layout and all |
| // we're updating is the new offset, which we are providing to the |
| // render object via our return value. |
| _contentExtent = _isMultiline ? |
| dimensions.contentSize.height : |
| dimensions.contentSize.width; |
| _containerExtent = _isMultiline ? |
| dimensions.containerSize.height : |
| dimensions.containerSize.width; |
| didUpdateScrollBehavior(scrollBehavior.updateExtents( |
| contentExtent: _contentExtent, |
| containerExtent: _containerExtent, |
| // TODO(ianh): We should really only do this when text is added, |
| // not generally any time the size changes. |
| scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent) |
| )); |
| updateGestureDetector(); |
| return scrollOffsetToPixelDelta(scrollOffset); |
| } |
| |
| // Calculate the new scroll offset so the cursor remains visible. |
| double _getScrollOffsetForCaret(Rect caretRect, double containerExtent) { |
| double caretStart = _isMultiline ? caretRect.top : caretRect.left; |
| double caretEnd = _isMultiline ? caretRect.bottom : caretRect.right; |
| double newScrollOffset = scrollOffset; |
| if (caretStart < 0.0) // cursor before start of bounds |
| newScrollOffset += pixelOffsetToScrollOffset(-caretStart); |
| else if (caretEnd >= containerExtent) // cursor after end of bounds |
| newScrollOffset += pixelOffsetToScrollOffset(-(caretEnd - containerExtent)); |
| return newScrollOffset; |
| } |
| |
| // True if the focus was explicitly requested last frame. This ensures we |
| // don't show the keyboard when focus defaults back to the RawInput. |
| bool _requestingFocus = false; |
| |
| void _attachOrDetachKeyboard(bool focused) { |
| if (focused && !_isAttachedToKeyboard && _requestingFocus) { |
| _textInputConnection = TextInput.attach( |
| this, new TextInputConfiguration(inputType: config.keyboardType)) |
| ..setEditingState(_getTextEditingStateFromInputValue(_currentValue)) |
| ..show(); |
| } else if (!focused) { |
| if (_isAttachedToKeyboard) { |
| _textInputConnection.close(); |
| _textInputConnection = null; |
| } |
| _clearComposing(); |
| } |
| _requestingFocus = false; |
| } |
| |
| void _clearComposing() { |
| // TODO(abarth): We should call config.onChanged to notify our parent of |
| // this change in our composing range. |
| _currentValue = _currentValue.copyWith(composing: TextRange.empty); |
| } |
| |
| /// 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 (_isAttachedToKeyboard) { |
| _textInputConnection.show(); |
| } else { |
| Focus.moveTo(config.focusKey); |
| setState(() { |
| _requestingFocus = true; |
| }); |
| } |
| } |
| |
| @override |
| void updateEditingState(TextEditingState state) { |
| _currentValue = _getInputValueFromEditingState(state); |
| if (config.onChanged != null) |
| config.onChanged(_currentValue); |
| if (_currentValue.text != config.value.text) { |
| _selectionOverlay?.hide(); |
| _selectionOverlay = null; |
| } |
| } |
| |
| @override |
| void performAction(TextInputAction action) { |
| _clearComposing(); |
| Focus.clear(context); |
| if (config.onSubmitted != null) |
| config.onSubmitted(_currentValue); |
| } |
| |
| void _handleSelectionChanged(TextSelection selection, RenderEditable renderObject, bool longPress) { |
| // Note that this will show the keyboard for all selection changes on the |
| // EditableWidget, not just changes triggered by user gestures. |
| requestKeyboard(); |
| |
| InputValue newInput = _currentValue.copyWith(selection: selection, composing: TextRange.empty); |
| if (config.onChanged != null) |
| config.onChanged(newInput); |
| |
| if (_selectionOverlay != null) { |
| _selectionOverlay.hide(); |
| _selectionOverlay = null; |
| } |
| |
| if (config.selectionControls != null) { |
| _selectionOverlay = new TextSelectionOverlay( |
| input: newInput, |
| context: context, |
| debugRequiredFor: config, |
| renderObject: renderObject, |
| onSelectionOverlayChanged: _handleSelectionOverlayChanged, |
| selectionControls: config.selectionControls, |
| ); |
| if (newInput.text.isNotEmpty || longPress) |
| _selectionOverlay.showHandles(); |
| if (longPress) |
| _selectionOverlay.showToolbar(); |
| } |
| } |
| |
| void _handleSelectionOverlayChanged(InputValue newInput, Rect caretRect) { |
| assert(!newInput.composing.isValid); // composing range must be empty while selecting |
| if (config.onChanged != null) |
| config.onChanged(newInput); |
| |
| didUpdateScrollBehavior(scrollBehavior.updateExtents( |
| // TODO(mpcomplete): should just be able to pass |
| // scrollBehavior.containerExtent here (and remove the member var), but |
| // scrollBehavior gets re-created too often, and is sometimes |
| // uninitialized here. Investigate if this is a bug. |
| scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent) |
| )); |
| } |
| |
| /// Whether the blinking cursor is actually visible at this precise moment |
| /// (it's hidden half the time, since it blinks). |
| bool get cursorCurrentlyVisible => _showCursor; |
| |
| /// 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). |
| Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod; |
| |
| void _cursorTick(Timer timer) { |
| setState(() { |
| _showCursor = !_showCursor; |
| }); |
| } |
| |
| void _startCursorTimer() { |
| _showCursor = true; |
| _cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); |
| } |
| |
| @override |
| void dispose() { |
| if (_isAttachedToKeyboard) { |
| _textInputConnection.close(); |
| _textInputConnection = null; |
| } |
| if (_cursorTimer != null) |
| _stopCursorTimer(); |
| _selectionOverlay?.dispose(); |
| super.dispose(); |
| } |
| |
| void _stopCursorTimer() { |
| _cursorTimer.cancel(); |
| _cursorTimer = null; |
| _showCursor = false; |
| } |
| |
| @override |
| Widget buildContent(BuildContext context) { |
| assert(config.style != null); |
| assert(config.focusKey != null); |
| assert(config.cursorColor != null); |
| |
| bool focused = Focus.at(config.focusKey.currentContext); |
| _attachOrDetachKeyboard(focused); |
| |
| if (_cursorTimer == null && focused && config.value.selection.isCollapsed) |
| _startCursorTimer(); |
| else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed)) |
| _stopCursorTimer(); |
| |
| if (_selectionOverlay != null) { |
| if (focused) { |
| _selectionOverlay.update(config.value); |
| } else { |
| _selectionOverlay?.dispose(); |
| _selectionOverlay = null; |
| } |
| } |
| |
| return new ClipRect( |
| child: new _Editable( |
| value: _currentValue, |
| style: config.style, |
| cursorColor: config.cursorColor, |
| showCursor: _showCursor, |
| maxLines: config.maxLines, |
| selectionColor: config.selectionColor, |
| textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor, |
| hideText: config.hideText, |
| onSelectionChanged: _handleSelectionChanged, |
| paintOffset: scrollOffsetToPixelDelta(scrollOffset), |
| onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded |
| ) |
| ); |
| } |
| } |
| |
| class _Editable extends LeafRenderObjectWidget { |
| _Editable({ |
| Key key, |
| this.value, |
| this.style, |
| this.cursorColor, |
| this.showCursor, |
| this.maxLines, |
| this.selectionColor, |
| this.textScaleFactor, |
| this.hideText, |
| this.onSelectionChanged, |
| this.paintOffset, |
| this.onPaintOffsetUpdateNeeded |
| }) : super(key: key); |
| |
| final InputValue value; |
| final TextStyle style; |
| final Color cursorColor; |
| final bool showCursor; |
| final int maxLines; |
| final Color selectionColor; |
| final double textScaleFactor; |
| final bool hideText; |
| final SelectionChangedHandler onSelectionChanged; |
| final Offset paintOffset; |
| final RenderEditablePaintOffsetNeededCallback onPaintOffsetUpdateNeeded; |
| |
| @override |
| RenderEditable createRenderObject(BuildContext context) { |
| return new RenderEditable( |
| text: _styledTextSpan, |
| cursorColor: cursorColor, |
| showCursor: showCursor, |
| maxLines: maxLines, |
| selectionColor: selectionColor, |
| textScaleFactor: textScaleFactor, |
| selection: value.selection, |
| onSelectionChanged: onSelectionChanged, |
| paintOffset: paintOffset, |
| onPaintOffsetUpdateNeeded: onPaintOffsetUpdateNeeded |
| ); |
| } |
| |
| @override |
| void updateRenderObject(BuildContext context, RenderEditable renderObject) { |
| renderObject |
| ..text = _styledTextSpan |
| ..cursorColor = cursorColor |
| ..showCursor = showCursor |
| ..maxLines = maxLines |
| ..selectionColor = selectionColor |
| ..textScaleFactor = textScaleFactor |
| ..selection = value.selection |
| ..onSelectionChanged = onSelectionChanged |
| ..paintOffset = paintOffset |
| ..onPaintOffsetUpdateNeeded = onPaintOffsetUpdateNeeded; |
| } |
| |
| TextSpan get _styledTextSpan { |
| if (!hideText && value.composing.isValid) { |
| 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 (hideText) |
| text = new String.fromCharCodes(new List<int>.filled(text.length, 0x2022)); |
| return new TextSpan(style: style, text: text); |
| } |
| } |