blob: f8b4c82b4a6595aad6e865756d405fd50876dabe [file]
// 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 'package:meta/meta.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;
const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);
/// 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 editiable 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;
set text(String newText) {
value = value.copyWith(text: newText, 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;
set selection(TextSelection 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.
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.
void clearComposing() {
value = value.copyWith(composing: TextRange.empty);
}
@override
String toString() {
return '$runtimeType#$hashCode($value)';
}
}
/// 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 [InputField], which
/// adds tap-to-focus and cut, copy, and paste commands, or [TextField], which
/// is a full-featured, material-design text input field with placeholder text,
/// labels, and [Form] integration.
///
/// See also:
///
/// * [InputField], which adds tap-to-focus and cut, copy, and paste commands.
/// * [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 [controller], [focusNode], [style], and [cursorColor] arguments must
/// not be null.
EditableText({
Key key,
@required this.controller,
@required this.focusNode,
this.obscureText: false,
@required this.style,
@required this.cursorColor,
this.textAlign,
this.textScaleFactor,
this.maxLines: 1,
this.autofocus: false,
this.selectionColor,
this.selectionControls,
this.keyboardType,
this.onChanged,
this.onSubmitted,
}) : super(key: key) {
assert(controller != null);
assert(focusNode != null);
assert(obscureText != null);
assert(style != null);
assert(cursorColor != null);
assert(maxLines != null);
assert(autofocus != null);
}
/// 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;
/// The text style to use for the editable text.
final TextStyle style;
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// 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;
/// 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;
@override
EditableTextState createState() => new EditableTextState();
}
/// State for a [EditableText].
class EditableTextState extends State<EditableText> implements TextInputClient {
Timer _cursorTimer;
final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false);
TextInputConnection _textInputConnection;
TextSelectionOverlay _selectionOverlay;
final ScrollController _scrollController = new ScrollController();
bool _didAutoFocus = false;
// State lifecycle:
@override
void initState() {
super.initState();
config.controller.addListener(_didChangeTextEditingValue);
config.focusNode.addListener(_handleFocusChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_didAutoFocus && config.autofocus) {
_didRequestKeyboard = true;
FocusScope.of(context).autofocus(config.focusNode);
_didAutoFocus = true;
}
}
@override
void didUpdateConfig(EditableText oldConfig) {
if (config.controller != oldConfig.controller) {
oldConfig.controller.removeListener(_didChangeTextEditingValue);
config.controller.addListener(_didChangeTextEditingValue);
_updateRemoteEditingValueIfNeeded();
}
if (config.focusNode != oldConfig.focusNode) {
oldConfig.focusNode.removeListener(_handleFocusChanged);
config.focusNode.addListener(_handleFocusChanged);
}
}
@override
void dispose() {
config.controller.removeListener(_didChangeTextEditingValue);
_closeInputConnectionIfNeeded();
assert(!_hasInputConnection);
_stopCursorTimer();
assert(_cursorTimer == null);
_selectionOverlay?.dispose();
_selectionOverlay = null;
config.focusNode.removeListener(_handleFocusChanged);
super.dispose();
}
// TextInputClient implementation:
TextEditingValue _lastKnownRemoteTextEditingValue;
@override
void updateEditingValue(TextEditingValue value) {
if (value.text != _value.text)
_hideSelectionOverlayIfNeeded();
_lastKnownRemoteTextEditingValue = value;
_value = value;
if (config.onChanged != null)
config.onChanged(value.text);
}
@override
void performAction(TextInputAction action) {
config.controller.clearComposing();
config.focusNode.unfocus();
if (config.onSubmitted != null)
config.onSubmitted(_value.text);
}
void _updateRemoteEditingValueIfNeeded() {
if (!_hasInputConnection)
return;
final TextEditingValue localValue = _value;
if (localValue == _lastKnownRemoteTextEditingValue)
return;
_lastKnownRemoteTextEditingValue = localValue;
_textInputConnection.setEditingState(localValue);
}
TextEditingValue get _value => config.controller.value;
set _value(TextEditingValue value) {
config.controller.value = value;
}
bool get _hasFocus => config.focusNode.hasFocus;
bool get _isMultiline => config.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 _didRequestKeyboard = false;
bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached;
void _openInputConnectionIfNeeded() {
if (!_hasInputConnection) {
final TextEditingValue localValue = _value;
_lastKnownRemoteTextEditingValue = localValue;
_textInputConnection = TextInput.attach(this, new TextInputConfiguration(inputType: config.keyboardType))
..setEditingState(localValue)
..show();
}
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_textInputConnection.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
}
}
void _openOrCloseInputConnectionIfNeeded() {
if (_hasFocus && _didRequestKeyboard) {
_openInputConnectionIfNeeded();
} else if (!_hasFocus) {
_closeInputConnectionIfNeeded();
config.controller.clearComposing();
}
_didRequestKeyboard = false;
}
/// 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 (_hasInputConnection) {
_textInputConnection.show();
} else {
if (_hasFocus) {
_openInputConnectionIfNeeded();
} else {
_didRequestKeyboard = true;
FocusScope.of(context).requestFocus(config.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, bool longPress) {
// Note that this will show the keyboard for all selection changes on the
// EditableWidget, not just changes triggered by user gestures.
requestKeyboard();
_hideSelectionOverlayIfNeeded();
config.controller.selection = selection;
if (config.selectionControls != null) {
_selectionOverlay = new TextSelectionOverlay(
context: context,
value: _value,
debugRequiredFor: config,
renderObject: renderObject,
onSelectionOverlayChanged: _handleSelectionOverlayChanged,
selectionControls: config.selectionControls,
);
if (_value.text.isNotEmpty || longPress)
_selectionOverlay.showHandles();
if (longPress)
_selectionOverlay.showToolbar();
}
}
void _handleSelectionOverlayChanged(TextEditingValue value, Rect caretRect) {
assert(!value.composing.isValid); // composing range must be empty while selecting.
_value = value;
_scrollController.jumpTo(_getScrollOffsetForCaret(caretRect));
}
/// 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;
void _cursorTick(Timer timer) {
_showCursor.value = !_showCursor.value;
}
void _startCursorTimer() {
_showCursor.value = true;
_cursorTimer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
}
void _stopCursorTimer() {
_cursorTimer?.cancel();
_cursorTimer = null;
_showCursor.value = false;
}
void _startOrStopCursorTimerIfNeeded() {
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed)
_startCursorTimer();
else if (_cursorTimer != null && (!_hasFocus || !_value.selection.isCollapsed))
_stopCursorTimer();
}
void _didChangeTextEditingValue() {
_updateRemoteEditingValueIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
// to avoid this setState().
setState(() { /* We use config.controller.value in build(). */ });
}
void _handleFocusChanged() {
_openOrCloseInputConnectionIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
}
@override
Widget build(BuildContext context) {
FocusScope.of(context).reparentIfNeeded(config.focusNode);
return new Scrollable(
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: const ClampingScrollPhysics(),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return new _Editable(
value: _value,
style: config.style,
cursorColor: config.cursorColor,
showCursor: _showCursor,
maxLines: config.maxLines,
selectionColor: config.selectionColor,
textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
textAlign: config.textAlign,
obscureText: config.obscureText,
offset: offset,
onSelectionChanged: _handleSelectionChanged,
);
},
);
}
}
class _Editable extends LeafRenderObjectWidget {
_Editable({
Key key,
this.value,
this.style,
this.cursorColor,
this.showCursor,
this.maxLines,
this.selectionColor,
this.textScaleFactor,
this.textAlign,
this.obscureText,
this.offset,
this.onSelectionChanged,
}) : super(key: key);
final TextEditingValue value;
final TextStyle style;
final Color cursorColor;
final ValueNotifier<bool> showCursor;
final int maxLines;
final Color selectionColor;
final double textScaleFactor;
final TextAlign textAlign;
final bool obscureText;
final ViewportOffset offset;
final SelectionChangedHandler onSelectionChanged;
@override
RenderEditable createRenderObject(BuildContext context) {
return new RenderEditable(
text: _styledTextSpan,
cursorColor: cursorColor,
showCursor: showCursor,
maxLines: maxLines,
selectionColor: selectionColor,
textScaleFactor: textScaleFactor,
textAlign: textAlign,
selection: value.selection,
offset: offset,
onSelectionChanged: onSelectionChanged,
);
}
@override
void updateRenderObject(BuildContext context, RenderEditable renderObject) {
renderObject
..text = _styledTextSpan
..cursorColor = cursorColor
..showCursor = showCursor
..maxLines = maxLines
..selectionColor = selectionColor
..textScaleFactor = textScaleFactor
..textAlign = textAlign
..selection = value.selection
..offset = offset
..onSelectionChanged = onSelectionChanged;
}
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 = new String.fromCharCodes(new List<int>.filled(text.length, 0x2022));
return new TextSpan(style: style, text: text);
}
}