Created
June 12, 2018 11:28
-
-
Save IhorKlimov/53bea67b0e69a483600de9ad31b14b96 to your computer and use it in GitHub Desktop.
Flutter BetterTextField
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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:collection'; | |
| 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'; | |
| export 'package:flutter/services.dart' show TextInputType; | |
| /// A material design text field. | |
| /// | |
| /// A text field lets the user enter text, either with hardware keyboard or with | |
| /// an onscreen keyboard. | |
| /// | |
| /// The text field calls the [onChanged] callback whenever the user changes the | |
| /// text in the field. If the user indicates that they are done typing in the | |
| /// field (e.g., by pressing a button on the soft keyboard), the text field | |
| /// calls the [onSubmitted] callback. | |
| /// | |
| /// To control the text that is displayed in the text field, use the | |
| /// [controller]. For example, to set the initial value of the text field, use | |
| /// a [controller] that already contains some text. The [controller] can also | |
| /// control the selection and composing region (and to observe changes to the | |
| /// text, selection, and composing region). | |
| /// | |
| /// By default, a text field has a [decoration] that draws a divider below the | |
| /// text field. You can use the [decoration] property to control the decoration, | |
| /// for example by adding a label or an icon. If you set the [decoration] | |
| /// property to null, the decoration will be removed entirely, including the | |
| /// extra padding introduced by the decoration to save space for the labels. | |
| /// | |
| /// If [decoration] is non-null (which is the default), the text field requires | |
| /// one of its ancestors to be a [Material] widget. When the [BetterTextField] is | |
| /// tapped an ink splash that paints on the material is triggered, see | |
| /// [ThemeData.splashFactory]. | |
| /// | |
| /// To integrate the [BetterTextField] into a [Form] with other [FormField] widgets, | |
| /// consider using [TextFormField]. | |
| /// | |
| /// See also: | |
| /// | |
| /// * <https://material.google.com/components/text-fields.html> | |
| /// * [TextFormField], which integrates with the [Form] widget. | |
| /// * [InputDecorator], which shows the labels and other visual elements that | |
| /// surround the actual text editing widget. | |
| /// * [EditableText], which is the raw text editing control at the heart of a | |
| /// [BetterTextField]. (The [EditableText] widget is rarely used directly unless | |
| /// you are implementing an entirely different design language, such as | |
| /// Cupertino.) | |
| class BetterTextField extends StatefulWidget { | |
| /// Creates a Material Design text field. | |
| /// | |
| /// If [decoration] is non-null (which is the default), the text field requires | |
| /// one of its ancestors to be a [Material] widget. | |
| /// | |
| /// To remove the decoration entirely (including the extra padding introduced | |
| /// by the decoration to save space for the labels), set the [decoration] to | |
| /// null. | |
| /// | |
| /// 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 not be zero. If [maxLines] is not one, then | |
| /// [keyboardType] is ignored, and the [TextInputType.multiline] keyboard | |
| /// type is used. | |
| /// | |
| /// The [maxLength] property is set to null by default, which means the | |
| /// number of characters allowed in the text field is not restricted. If | |
| /// [maxLength] is set, a character counter will be displayed below the | |
| /// field, showing how many characters have been entered and how many are | |
| /// allowed. After [maxLength] characters have been input, additional input | |
| /// is ignored, unless [maxLengthEnforced] is set to false. The BetterTextField | |
| /// enforces the length with a [LengthLimitingTextInputFormatter], which is | |
| /// evaluated after the supplied [inputFormatters], if any. The [maxLength] | |
| /// value must be either null or greater than zero. | |
| /// | |
| /// If [maxLengthEnforced] is set to false, then more than [maxLength] | |
| /// characters may be entered, and the error counter and divider will | |
| /// switch to the [decoration.errorStyle] when the limit is exceeded. | |
| /// | |
| /// The [keyboardType], [textAlign], [autofocus], [obscureText], and | |
| /// [autocorrect] arguments must not be null. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [maxLength], which discusses the precise meaning of "number of | |
| /// characters" and how it may differ from the intuitive meaning. | |
| const BetterTextField({ | |
| Key key, | |
| this.controller, | |
| this.focusNode, | |
| this.decoration = const InputDecoration(), | |
| TextInputType keyboardType = TextInputType.text, | |
| this.style, | |
| this.textAlign = TextAlign.start, | |
| this.autofocus = false, | |
| this.obscureText = false, | |
| this.autocorrect = true, | |
| this.maxLines = 1, | |
| this.showCounter = true, | |
| this.maxLength, | |
| this.maxLengthEnforced = true, | |
| this.onChanged, | |
| this.onSubmitted, | |
| this.inputFormatters, | |
| this.enabled, | |
| }) : assert(keyboardType != null), | |
| assert(textAlign != null), | |
| assert(autofocus != null), | |
| assert(obscureText != null), | |
| assert(autocorrect != null), | |
| assert(maxLengthEnforced != null), | |
| assert(maxLines == null || maxLines > 0), | |
| assert(maxLength == null || maxLength > 0), | |
| keyboardType = maxLines == 1 ? keyboardType : TextInputType.multiline, | |
| super(key: key); | |
| /// Controls the text being edited. | |
| /// | |
| /// If null, this widget will create its own [TextEditingController]. | |
| final TextEditingController controller; | |
| final bool showCounter; | |
| /// Controls whether this widget has keyboard focus. | |
| /// | |
| /// If null, this widget will create its own [FocusNode]. | |
| final FocusNode focusNode; | |
| /// The decoration to show around the text field. | |
| /// | |
| /// By default, draws a horizontal line under the text field but can be | |
| /// configured to show an icon, label, hint text, and error text. | |
| /// | |
| /// Specify null to remove the decoration entirely (including the | |
| /// extra padding introduced by the decoration to save space for the labels). | |
| final InputDecoration decoration; | |
| /// The type of keyboard to use for editing the text. | |
| /// | |
| /// Defaults to [TextInputType.text]. Must not be null. If | |
| /// [maxLines] is not one, then [keyboardType] is ignored, and the | |
| /// [TextInputType.multiline] keyboard type is used. | |
| final TextInputType keyboardType; | |
| /// The style to use for the text being edited. | |
| /// | |
| /// This text style is also used as the base style for the [decoration]. | |
| /// | |
| /// If null, defaults to the `subhead` text style from the current [Theme]. | |
| final TextStyle style; | |
| /// How the text being edited should be aligned horizontally. | |
| /// | |
| /// Defaults to [TextAlign.start]. | |
| final TextAlign textAlign; | |
| /// Whether this text field should focus itself if nothing else is already | |
| /// focused. | |
| /// | |
| /// If true, the keyboard will open as soon as this text field obtains focus. | |
| /// Otherwise, the keyboard is only shown after the user taps the text field. | |
| /// | |
| /// Defaults to false. Cannot be null. | |
| // See https://github.com/flutter/flutter/issues/7035 for the rationale for this | |
| // keyboard behavior. | |
| final bool autofocus; | |
| /// Whether to hide the text being edited (e.g., for passwords). | |
| /// | |
| /// When this is set to true, all the characters in the text field are | |
| /// replaced by U+2022 BULLET characters (•). | |
| /// | |
| /// Defaults to false. Cannot be null. | |
| final bool obscureText; | |
| /// Whether to enable autocorrection. | |
| /// | |
| /// Defaults to true. Cannot be null. | |
| final bool autocorrect; | |
| /// 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; | |
| /// The maximum number of characters (Unicode scalar values) to allow in the | |
| /// text field. | |
| /// | |
| /// If set, a character counter will be displayed below the | |
| /// field, showing how many characters have been entered and how many are | |
| /// allowed. After [maxLength] characters have been input, additional input | |
| /// is ignored, unless [maxLengthEnforced] is set to false. The BetterTextField | |
| /// enforces the length with a [LengthLimitingTextInputFormatter], which is | |
| /// evaluated after the supplied [inputFormatters], if any. | |
| /// | |
| /// This value must be either null or greater than zero. If set to null | |
| /// (the default), there is no limit to the number of characters allowed. | |
| /// | |
| /// Whitespace characters (e.g. newline, space, tab) are included in the | |
| /// character count. | |
| /// | |
| /// If [maxLengthEnforced] is set to false, then more than [maxLength] | |
| /// characters may be entered, but the error counter and divider will | |
| /// switch to the [decoration.errorStyle] when the limit is exceeded. | |
| /// | |
| /// ## Limitations | |
| /// | |
| /// The BetterTextField does not currently count Unicode grapheme clusters (i.e. | |
| /// characters visible to the user), it counts Unicode scalar values, which | |
| /// leaves out a number of useful possible characters (like many emoji and | |
| /// composed characters), so this will be inaccurate in the presence of those | |
| /// characters. If you expect to encounter these kinds of characters, be | |
| /// generous in the maxLength used. | |
| /// | |
| /// For instance, the character "ö" can be represented as '\u{006F}\u{0308}', | |
| /// which is the letter "o" followed by a composed diaeresis "¨", or it can | |
| /// be represented as '\u{00F6}', which is the Unicode scalar value "LATIN | |
| /// SMALL LETTER O WITH DIAERESIS". In the first case, the text field will | |
| /// count two characters, and the second case will be counted as one | |
| /// character, even though the user can see no difference in the input. | |
| /// | |
| /// Similarly, some emoji are represented by multiple scalar values. The | |
| /// Unicode "THUMBS UP SIGN + MEDIUM SKIN TONE MODIFIER", "👍🏽", should be | |
| /// counted as a single character, but because it is a combination of two | |
| /// Unicode scalar values, '\u{1F44D}\u{1F3FD}', it is counted as two | |
| /// characters. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [LengthLimitingTextInputFormatter] for more information on how it | |
| /// counts characters, and how it may differ from the intuitive meaning. | |
| final int maxLength; | |
| /// If true, prevents the field from allowing more than [maxLength] | |
| /// characters. | |
| /// | |
| /// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to | |
| /// enforce the limit, or merely provide a character counter and warning when | |
| /// [maxLength] is exceeded. | |
| final bool maxLengthEnforced; | |
| /// 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; | |
| /// Optional input validation and formatting overrides. | |
| /// | |
| /// Formatters are run in the provided order when the text input changes. | |
| final List<TextInputFormatter> inputFormatters; | |
| /// If false the BetterTextField is "disabled": it ignores taps and its | |
| /// [decoration] is rendered in grey. | |
| /// | |
| /// If non-null this property overrides the [decoration]'s | |
| /// [Decoration.enabled] property. | |
| final bool enabled; | |
| @override | |
| _BetterTextFieldState createState() => new _BetterTextFieldState(); | |
| @override | |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
| super.debugFillProperties(properties); | |
| properties.add(new DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null)); | |
| properties.add(new DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); | |
| properties.add(new DiagnosticsProperty<InputDecoration>('decoration', decoration)); | |
| properties.add(new DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: TextInputType.text)); | |
| properties.add(new DiagnosticsProperty<TextStyle>('style', style, defaultValue: null)); | |
| properties.add(new DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false)); | |
| properties.add(new DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false)); | |
| properties.add(new DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: false)); | |
| properties.add(new IntProperty('maxLines', maxLines, defaultValue: 1)); | |
| properties.add(new IntProperty('maxLength', maxLength, defaultValue: null)); | |
| properties.add(new FlagProperty('maxLengthEnforced', value: maxLengthEnforced, ifTrue: 'max length enforced')); | |
| } | |
| } | |
| class _BetterTextFieldState extends State<BetterTextField> with AutomaticKeepAliveClientMixin { | |
| final GlobalKey<EditableTextState> _editableTextKey = new GlobalKey<EditableTextState>(); | |
| Set<InteractiveInkFeature> _splashes; | |
| InteractiveInkFeature _currentSplash; | |
| TextEditingController _controller; | |
| TextEditingController get _effectiveController => widget.controller ?? _controller; | |
| FocusNode _focusNode; | |
| FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= new FocusNode()); | |
| bool get needsCounter => widget.maxLength != null | |
| && widget.decoration != null | |
| && widget.decoration.counterText == null; | |
| InputDecoration _getEffectiveDecoration() { | |
| final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration()) | |
| .applyDefaults(Theme.of(context).inputDecorationTheme) | |
| .copyWith( | |
| enabled: widget.enabled, | |
| ); | |
| if (!needsCounter || !widget.showCounter) | |
| return effectiveDecoration; | |
| final String counterText = '${_effectiveController.value.text.runes.length}/${widget.maxLength}'; | |
| if (_effectiveController.value.text.runes.length > widget.maxLength) { | |
| final ThemeData themeData = Theme.of(context); | |
| return effectiveDecoration.copyWith( | |
| errorText: effectiveDecoration.errorText ?? '', | |
| counterStyle: effectiveDecoration.errorStyle | |
| ?? themeData.textTheme.caption.copyWith(color: themeData.errorColor), | |
| counterText: counterText, | |
| ); | |
| } | |
| return effectiveDecoration.copyWith(counterText: counterText); | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| if (widget.controller == null) | |
| _controller = new TextEditingController(); | |
| } | |
| @override | |
| void didUpdateWidget(BetterTextField oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.controller == null && oldWidget.controller != null) | |
| _controller = new TextEditingController.fromValue(oldWidget.controller.value); | |
| else if (widget.controller != null && oldWidget.controller == null) | |
| _controller = null; | |
| final bool isEnabled = widget.enabled ?? widget.decoration?.enabled ?? true; | |
| final bool wasEnabled = oldWidget.enabled ?? oldWidget.decoration?.enabled ?? true; | |
| if (wasEnabled && !isEnabled) { | |
| _effectiveFocusNode.unfocus(); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _focusNode?.dispose(); | |
| super.dispose(); | |
| } | |
| void _requestKeyboard() { | |
| _editableTextKey.currentState?.requestKeyboard(); | |
| } | |
| void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { | |
| if (cause == SelectionChangedCause.longPress) | |
| Feedback.forLongPress(context); | |
| } | |
| InteractiveInkFeature _createInkFeature(TapDownDetails details) { | |
| final MaterialInkController inkController = Material.of(context); | |
| final BuildContext editableContext = _editableTextKey.currentContext; | |
| final RenderBox referenceBox = InputDecorator.containerOf(editableContext) ?? editableContext.findRenderObject(); | |
| final Offset position = referenceBox.globalToLocal(details.globalPosition); | |
| final Color color = Theme.of(context).splashColor; | |
| InteractiveInkFeature splash; | |
| void handleRemoved() { | |
| if (_splashes != null) { | |
| assert(_splashes.contains(splash)); | |
| _splashes.remove(splash); | |
| if (_currentSplash == splash) | |
| _currentSplash = null; | |
| updateKeepAlive(); | |
| } // else we're probably in deactivate() | |
| } | |
| splash = Theme.of(context).splashFactory.create( | |
| controller: inkController, | |
| referenceBox: referenceBox, | |
| position: position, | |
| color: color, | |
| containedInkWell: true, | |
| // TODO(hansmuller): splash clip borderRadius should match the input decorator's border. | |
| borderRadius: BorderRadius.zero, | |
| onRemoved: handleRemoved, | |
| ); | |
| return splash; | |
| } | |
| RenderEditable get _renderEditable => _editableTextKey.currentState.renderEditable; | |
| void _handleTapDown(TapDownDetails details) { | |
| _renderEditable.handleTapDown(details); | |
| _startSplash(details); | |
| } | |
| void _handleTap() { | |
| _renderEditable.handleTap(); | |
| _requestKeyboard(); | |
| _confirmCurrentSplash(); | |
| } | |
| void _handleTapCancel() { | |
| _cancelCurrentSplash(); | |
| } | |
| void _handleLongPress() { | |
| _renderEditable.handleLongPress(); | |
| _confirmCurrentSplash(); | |
| } | |
| void _startSplash(TapDownDetails details) { | |
| if (_effectiveFocusNode.hasFocus) | |
| return; | |
| final InteractiveInkFeature splash = _createInkFeature(details); | |
| _splashes ??= new HashSet<InteractiveInkFeature>(); | |
| _splashes.add(splash); | |
| _currentSplash = splash; | |
| updateKeepAlive(); | |
| } | |
| void _confirmCurrentSplash() { | |
| _currentSplash?.confirm(); | |
| _currentSplash = null; | |
| } | |
| void _cancelCurrentSplash() { | |
| _currentSplash?.cancel(); | |
| } | |
| @override | |
| bool get wantKeepAlive => _splashes != null && _splashes.isNotEmpty; | |
| @override | |
| void deactivate() { | |
| if (_splashes != null) { | |
| final Set<InteractiveInkFeature> splashes = _splashes; | |
| _splashes = null; | |
| for (InteractiveInkFeature splash in splashes) | |
| splash.dispose(); | |
| _currentSplash = null; | |
| } | |
| assert(_currentSplash == null); | |
| super.deactivate(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| super.build(context); // See AutomaticKeepAliveClientMixin. | |
| assert(debugCheckHasMaterial(context)); | |
| final ThemeData themeData = Theme.of(context); | |
| final TextStyle style = widget.style ?? themeData.textTheme.subhead; | |
| final TextEditingController controller = _effectiveController; | |
| final FocusNode focusNode = _effectiveFocusNode; | |
| final List<TextInputFormatter> formatters = widget.inputFormatters ?? <TextInputFormatter>[]; | |
| if (widget.maxLength != null && widget.maxLengthEnforced) | |
| formatters.add(new LengthLimitingTextInputFormatter(widget.maxLength)); | |
| Widget child = new RepaintBoundary( | |
| child: new EditableText( | |
| key: _editableTextKey, | |
| controller: controller, | |
| focusNode: focusNode, | |
| keyboardType: widget.keyboardType, | |
| style: style, | |
| textAlign: widget.textAlign, | |
| autofocus: widget.autofocus, | |
| obscureText: widget.obscureText, | |
| autocorrect: widget.autocorrect, | |
| maxLines: widget.maxLines, | |
| cursorColor: themeData.textSelectionColor, | |
| selectionColor: themeData.textSelectionColor, | |
| selectionControls: themeData.platform == TargetPlatform.iOS | |
| ? cupertinoTextSelectionControls | |
| : materialTextSelectionControls, | |
| onChanged: widget.onChanged, | |
| onSubmitted: widget.onSubmitted, | |
| onSelectionChanged: _handleSelectionChanged, | |
| inputFormatters: formatters, | |
| rendererIgnoresPointer: true, | |
| ), | |
| ); | |
| if (widget.decoration != null) { | |
| child = new AnimatedBuilder( | |
| animation: new Listenable.merge(<Listenable>[ focusNode, controller ]), | |
| builder: (BuildContext context, Widget child) { | |
| return new InputDecorator( | |
| decoration: _getEffectiveDecoration(), | |
| baseStyle: widget.style, | |
| textAlign: widget.textAlign, | |
| isFocused: focusNode.hasFocus, | |
| isEmpty: controller.value.text.isEmpty, | |
| child: child, | |
| ); | |
| }, | |
| child: child, | |
| ); | |
| } | |
| return new Semantics( | |
| onTap: () { | |
| if (!_effectiveController.selection.isValid) | |
| _effectiveController.selection = new TextSelection.collapsed(offset: _effectiveController.text.length); | |
| _requestKeyboard(); | |
| }, | |
| child: new IgnorePointer( | |
| ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true), | |
| child: new GestureDetector( | |
| behavior: HitTestBehavior.translucent, | |
| onTapDown: _handleTapDown, | |
| onTap: _handleTap, | |
| onTapCancel: _handleTapCancel, | |
| onLongPress: _handleLongPress, | |
| excludeFromSemantics: true, | |
| child: child, | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment