blob: 2fd5bf0448235a979194fcba42d27405427612ad [file] [log] [blame]
// 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.
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'text_editing.dart';
import 'text_input.dart';
/// A [TextInputFormatter] can be optionally injected into an [EditableText]
/// to provide as-you-type validation and formatting of the text being edited.
///
/// Text modification should only be applied when text is being committed by the
/// IME and not on text under composition (i.e., only when
/// [TextEditingValue.composing] is collapsed).
///
/// Concrete implementations [BlacklistingTextInputFormatter], which removes
/// blacklisted characters upon edit commit, and
/// [WhitelistingTextInputFormatter], which only allows entries of whitelisted
/// characters, are provided.
///
/// To create custom formatters, extend the [TextInputFormatter] class and
/// implement the [formatEditUpdate] method.
///
/// See also:
///
/// * [EditableText] on which the formatting apply.
/// * [BlacklistingTextInputFormatter], a provided formatter for blacklisting
/// characters.
/// * [WhitelistingTextInputFormatter], a provided formatter for whitelisting
/// characters.
abstract class TextInputFormatter {
/// Called when text is being typed or cut/copy/pasted in the [EditableText].
///
/// You can override the resulting text based on the previous text value and
/// the incoming new text value.
///
/// When formatters are chained, `oldValue` reflects the initial value of
/// [TextEditingValue] at the beginning of the chain.
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
);
/// A shorthand to creating a custom [TextInputFormatter] which formats
/// incoming text input changes with the given function.
static TextInputFormatter withFunction(
TextInputFormatFunction formatFunction,
) {
return _SimpleTextInputFormatter(formatFunction);
}
}
/// Function signature expected for creating custom [TextInputFormatter]
/// shorthands via [TextInputFormatter.withFunction];
typedef TextInputFormatFunction = TextEditingValue Function(
TextEditingValue oldValue,
TextEditingValue newValue,
);
/// Wiring for [TextInputFormatter.withFunction].
class _SimpleTextInputFormatter extends TextInputFormatter {
_SimpleTextInputFormatter(this.formatFunction)
: assert(formatFunction != null);
final TextInputFormatFunction formatFunction;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return formatFunction(oldValue, newValue);
}
}
/// A [TextInputFormatter] that prevents the insertion of blacklisted
/// characters patterns.
///
/// Instances of blacklisted characters found in the new [TextEditingValue]s
/// will be replaced with the [replacementString] which defaults to the empty
/// string.
///
/// Since this formatter only removes characters from the text, it attempts to
/// preserve the existing [TextEditingValue.selection] to values it would now
/// fall at with the removed characters.
///
/// See also:
///
/// * [WhitelistingTextInputFormatter], which uses a whitelist instead of a
/// blacklist.
class BlacklistingTextInputFormatter extends TextInputFormatter {
/// Creates a formatter that prevents the insertion of blacklisted characters patterns.
///
/// The [blacklistedPattern] must not be null.
BlacklistingTextInputFormatter(
this.blacklistedPattern, {
this.replacementString = '',
}) : assert(blacklistedPattern != null);
/// A [Pattern] to match and replace incoming [TextEditingValue]s.
final Pattern blacklistedPattern;
/// String used to replace found patterns.
final String replacementString;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return substring.replaceAll(blacklistedPattern, replacementString);
},
);
}
/// A [BlacklistingTextInputFormatter] that forces input to be a single line.
static final BlacklistingTextInputFormatter singleLineFormatter
= BlacklistingTextInputFormatter(RegExp(r'\n'));
}
/// A [TextInputFormatter] that prevents the insertion of more characters
/// (currently defined as Unicode scalar values) than allowed.
///
/// Since this formatter only prevents new characters from being added to the
/// text, it preserves the existing [TextEditingValue.selection].
///
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
class LengthLimitingTextInputFormatter extends TextInputFormatter {
/// Creates a formatter that prevents the insertion of more characters than a
/// limit.
///
/// The [maxLength] must be null, -1 or greater than zero. If it is null or -1
/// then no limit is enforced.
LengthLimitingTextInputFormatter(this.maxLength)
: assert(maxLength == null || maxLength == -1 || maxLength > 0);
/// The limit on the number of characters (i.e. Unicode scalar values) this formatter
/// will allow.
///
/// The value must be null or greater than zero. If it is null, then no limit
/// is enforced.
///
/// This formatter 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.
final int maxLength;
// TODO(justinmc): This should be updated to use characters instead of runes,
// see the comment in formatEditUpdate.
/// Truncate the given TextEditingValue to maxLength runes.
@visibleForTesting
static TextEditingValue truncate(TextEditingValue value, int maxLength) {
final TextSelection newSelection = value.selection.copyWith(
baseOffset: math.min(value.selection.start, maxLength),
extentOffset: math.min(value.selection.end, maxLength),
);
final RuneIterator iterator = RuneIterator(value.text);
if (iterator.moveNext())
for (int count = 0; count < maxLength; ++count)
if (!iterator.moveNext())
break;
final String truncated = value.text.substring(0, iterator.rawIndex);
return TextEditingValue(
text: truncated,
selection: newSelection,
composing: TextRange.empty,
);
}
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
// This does not count grapheme clusters (i.e. characters visible to the user),
// it counts Unicode runes, which leaves out a number of useful possible
// characters (like many emoji), so this will be inaccurate in the
// presence of those characters. The Dart lang bug
// https://github.com/dart-lang/sdk/issues/28404 has been filed to
// address this in Dart.
// TODO(justinmc): convert this to count actual characters using Dart's
// characters package (https://pub.dev/packages/characters).
if (maxLength != null && maxLength > 0 && newValue.text.runes.length > maxLength) {
// If already at the maximum and tried to enter even more, keep the old
// value.
if (oldValue.text.runes.length == maxLength) {
return oldValue;
}
return truncate(newValue, maxLength);
}
return newValue;
}
}
/// A [TextInputFormatter] that allows only the insertion of whitelisted
/// characters patterns.
///
/// Since this formatter only removes characters from the text, it attempts to
/// preserve the existing [TextEditingValue.selection] to values it would now
/// fall at with the removed characters.
///
/// See also:
///
/// * [BlacklistingTextInputFormatter], which uses a blacklist instead of a
/// whitelist.
class WhitelistingTextInputFormatter extends TextInputFormatter {
/// Creates a formatter that allows only the insertion of whitelisted characters patterns.
///
/// The [whitelistedPattern] must not be null.
WhitelistingTextInputFormatter(this.whitelistedPattern)
: assert(whitelistedPattern != null);
/// A [Pattern] to extract all instances of allowed characters.
///
/// [RegExp] with multiple groups is not supported.
final Pattern whitelistedPattern;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return whitelistedPattern
.allMatches(substring)
.map<String>((Match match) => match.group(0))
.join();
} ,
);
}
/// A [WhitelistingTextInputFormatter] that takes in digits `[0-9]` only.
static final WhitelistingTextInputFormatter digitsOnly
= WhitelistingTextInputFormatter(RegExp(r'\d+'));
}
TextEditingValue _selectionAwareTextManipulation(
TextEditingValue value,
String substringManipulation(String substring),
) {
final int selectionStartIndex = value.selection.start;
final int selectionEndIndex = value.selection.end;
String manipulatedText;
TextSelection manipulatedSelection;
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
manipulatedText = substringManipulation(value.text);
} else {
final String beforeSelection = substringManipulation(
value.text.substring(0, selectionStartIndex)
);
final String inSelection = substringManipulation(
value.text.substring(selectionStartIndex, selectionEndIndex)
);
final String afterSelection = substringManipulation(
value.text.substring(selectionEndIndex)
);
manipulatedText = beforeSelection + inSelection + afterSelection;
if (value.selection.baseOffset > value.selection.extentOffset) {
manipulatedSelection = value.selection.copyWith(
baseOffset: beforeSelection.length + inSelection.length,
extentOffset: beforeSelection.length,
);
} else {
manipulatedSelection = value.selection.copyWith(
baseOffset: beforeSelection.length,
extentOffset: beforeSelection.length + inSelection.length,
);
}
}
return TextEditingValue(
text: manipulatedText,
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
composing: manipulatedText == value.text
? value.composing
: TextRange.empty,
);
}