Unverified Commit de2470ff authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

InputDecorator Count Widget (#25095)

* Allow a widget to be specified for the textfield count, and allow no count at all

* Test all possible states for counter and counterText

* Docs for counter

* counter is a function that generates a widget

* Tests use counter as function

* Fix analyze error in docs

* InputDecoration has counter widget, TextField has buildCounter function

* InputDecorator tests expect counter to be widget again and include
buildCounter

* counter widget example that might actually fit

* Clarify accessiblity concerns in docs

* Include isFocused param for accessibility

* Fix analyze error

* Improve docs per code review

* Rearrange getEffectiveDecoration a bit for clarity

* Fix analyze error about hashValues params

* Clean up docs and redundant code per code review

* Code review doc improvement

* Automatically wrap buildCounter widget in a Semantics widget for accessibility
parent 35fcd907
......@@ -1856,8 +1856,11 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
errorMaxLines: decoration.errorMaxLines,
);
final Widget counter = decoration.counterText == null ? null :
Semantics(
Widget counter;
if (decoration.counter != null) {
counter = decoration.counter;
} else if (decoration.counterText != null && decoration.counterText != '') {
counter = Semantics(
container: true,
liveRegion: isFocused,
child: Text(
......@@ -1867,6 +1870,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
semanticsLabel: decoration.semanticCounterText,
),
);
}
// The _Decoration widget and _RenderDecoration assume that contentPadding
// has been resolved to EdgeInsets.
......@@ -1982,6 +1986,7 @@ class InputDecoration {
this.suffix,
this.suffixText,
this.suffixStyle,
this.counter,
this.counterText,
this.counterStyle,
this.filled,
......@@ -2034,6 +2039,7 @@ class InputDecoration {
suffixIcon = null,
suffixText = null,
suffixStyle = null,
counter = null,
counterText = null,
counterStyle = null,
errorBorder = null,
......@@ -2330,8 +2336,16 @@ class InputDecoration {
/// null.
///
/// The semantic label can be replaced by providing a [semanticCounterText].
///
/// If null or an empty string and [counter] isn't specified, then nothing
/// will appear in the counter's location.
final String counterText;
/// Optional custom counter widget to go in the place otherwise occupied by
/// [counterText]. If this property is non null, then [counterText] is
/// ignored.
final Widget counter;
/// The style to use for the [counterText].
///
/// If null, defaults to the [helperStyle].
......@@ -2561,6 +2575,7 @@ class InputDecoration {
Widget suffix,
String suffixText,
TextStyle suffixStyle,
Widget counter,
String counterText,
TextStyle counterStyle,
bool filled,
......@@ -2598,6 +2613,7 @@ class InputDecoration {
suffix: suffix ?? this.suffix,
suffixText: suffixText ?? this.suffixText,
suffixStyle: suffixStyle ?? this.suffixStyle,
counter: counter ?? this.counter,
counterText: counterText ?? this.counterText,
counterStyle: counterStyle ?? this.counterStyle,
filled: filled ?? this.filled,
......@@ -2674,6 +2690,7 @@ class InputDecoration {
&& typedOther.suffix == suffix
&& typedOther.suffixText == suffixText
&& typedOther.suffixStyle == suffixStyle
&& typedOther.counter == counter
&& typedOther.counterText == counterText
&& typedOther.counterStyle == counterStyle
&& typedOther.filled == filled
......@@ -2722,6 +2739,7 @@ class InputDecoration {
suffix,
suffixText,
suffixStyle,
counter,
counterText,
counterStyle,
filled,
......@@ -2732,8 +2750,8 @@ class InputDecoration {
disabledBorder,
enabledBorder,
border,
enabled,
hashValues(
enabled,
semanticCounterText,
alignLabelWithHint,
),
......@@ -2784,6 +2802,8 @@ class InputDecoration {
description.add('suffixText: $suffixText');
if (suffixStyle != null)
description.add('suffixStyle: $suffixStyle');
if (counter != null)
description.add('counter: $counter');
if (counterText != null)
description.add('counterText: $counterText');
if (counterStyle != null)
......
......@@ -21,6 +21,21 @@ import 'theme.dart';
export 'package:flutter/services.dart' show TextInputType, TextInputAction, TextCapitalization;
/// Signature for the [TextField.buildCounter] callback.
typedef InputCounterWidgetBuilder = Widget Function(
/// The build context for the TextField
BuildContext context,
{
/// The length of the string currently in the input.
@required int currentLength,
/// The maximum string length that can be entered into the TextField.
@required int maxLength,
/// Whether or not the TextField is currently focused. Mainly provided for
/// the [liveRegion] parameter in the [Semantics] widget for accessibility.
@required bool isFocused,
}
);
/// A material design text field.
///
/// A text field lets the user enter text, either with hardware keyboard or with
......@@ -131,6 +146,7 @@ class TextField extends StatefulWidget {
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection,
this.onTap,
this.buildCounter,
}) : assert(textAlign != null),
assert(autofocus != null),
assert(obscureText != null),
......@@ -377,6 +393,35 @@ class TextField extends StatefulWidget {
/// text field's internal gesture detector, use a [Listener].
final GestureTapCallback onTap;
/// Callback that generates a custom [InputDecorator.counter] widget.
///
/// See [InputCounterWidgetBuilder] for an explanation of the passed in
/// arguments. The returned widget will be placed below the line in place of
/// the default widget built when [counterText] is specified.
///
/// The returned widget will be wrapped in a [Semantics] widget for
/// accessibility, but it also needs to be accessible itself. For example,
/// if returning a Text widget, set the [semanticsLabel] property.
///
/// {@tool sample}
/// ```dart
/// Widget counter(
/// BuildContext context,
/// {
/// int currentLength,
/// int maxLength,
/// bool isFocused,
/// }
/// ) {
/// return Text(
/// '$currentLength of $maxLength characters',
/// semanticsLabel: 'character count',
/// );
/// }
/// ```
/// {@end-tool}
final InputCounterWidgetBuilder buildCounter;
@override
_TextFieldState createState() => _TextFieldState();
......@@ -434,10 +479,33 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines
);
if (!needsCounter)
// No need to build anything if counter or counterText were given directly.
if (effectiveDecoration.counter != null || effectiveDecoration.counterText != null)
return effectiveDecoration;
// If buildCounter was provided, use it to generate a counter widget.
Widget counter;
final int currentLength = _effectiveController.value.text.runes.length;
if (effectiveDecoration.counter == null
&& effectiveDecoration.counterText == null
&& widget.buildCounter != null) {
final bool isFocused = _effectiveFocusNode.hasFocus;
counter = Semantics(
container: true,
liveRegion: isFocused,
child: widget.buildCounter(
context,
currentLength: currentLength,
maxLength: widget.maxLength,
isFocused: isFocused,
),
);
return effectiveDecoration.copyWith(counter: counter);
}
if (widget.maxLength == null)
return effectiveDecoration; // No counter widget
String counterText = '$currentLength';
String semanticCounterText = '';
......
......@@ -101,6 +101,7 @@ class TextFormField extends FormField<String> {
Brightness keyboardAppearance,
EdgeInsets scrollPadding = const EdgeInsets.all(20.0),
bool enableInteractiveSelection = true,
InputCounterWidgetBuilder buildCounter,
}) : assert(initialValue == null || controller == null),
assert(textAlign != null),
assert(autofocus != null),
......@@ -150,6 +151,7 @@ class TextFormField extends FormField<String> {
scrollPadding: scrollPadding,
keyboardAppearance: keyboardAppearance,
enableInteractiveSelection: enableInteractiveSelection,
buildCounter: buildCounter,
);
},
);
......
......@@ -660,6 +660,103 @@ void main() {
expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 56.0));
});
testWidgets('InputDecorator counter text, widget, and null', (WidgetTester tester) async {
Widget buildFrame({
InputCounterWidgetBuilder buildCounter,
String counterText,
Widget counter,
int maxLength,
}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextFormField(
buildCounter: buildCounter,
maxLength: maxLength,
decoration: InputDecoration(
counterText: counterText,
counter: counter,
),
),
],
),
),
),
);
}
// When counter, counterText, and buildCounter are null, defaults to showing
// the built-in counter.
int maxLength = 10;
await tester.pumpWidget(buildFrame(maxLength: maxLength));
Finder counterFinder = find.byType(Text);
expect(counterFinder, findsOneWidget);
final Text counterWidget = tester.widget(counterFinder);
expect(counterWidget.data, '0/${maxLength.toString()}');
// When counter, counterText, and buildCounter are set, shows the counter
// widget.
final Key counterKey = UniqueKey();
final Key buildCounterKey = UniqueKey();
const String counterText = 'I show instead of count';
final Widget counter = Text('hello', key: counterKey);
final InputCounterWidgetBuilder buildCounter =
(BuildContext context, { int currentLength, int maxLength, bool isFocused }) {
return Text(
'${currentLength.toString()} of ${maxLength.toString()}',
key: buildCounterKey,
);
};
await tester.pumpWidget(buildFrame(
counterText: counterText,
counter: counter,
buildCounter: buildCounter,
maxLength: maxLength,
));
counterFinder = find.byKey(counterKey);
expect(counterFinder, findsOneWidget);
expect(find.text(counterText), findsNothing);
expect(find.byKey(buildCounterKey), findsNothing);
// When counter is null but counterText and buildCounter are set, shows the
// counterText.
await tester.pumpWidget(buildFrame(
counterText: counterText,
buildCounter: buildCounter,
maxLength: maxLength,
));
expect(find.text(counterText), findsOneWidget);
counterFinder = find.byKey(counterKey);
expect(counterFinder, findsNothing);
expect(find.byKey(buildCounterKey), findsNothing);
// When counter and counterText are null but buildCounter is set, shows the
// generated widget.
await tester.pumpWidget(buildFrame(
buildCounter: buildCounter,
maxLength: maxLength,
));
expect(find.byKey(buildCounterKey), findsOneWidget);
expect(counterFinder, findsNothing);
expect(find.text(counterText), findsNothing);
// When counterText is empty string and counter and buildCounter are null,
// shows nothing.
await tester.pumpWidget(buildFrame(counterText: '', maxLength: maxLength));
expect(find.byType(Text), findsNothing);
// When no maxLength, can still show a counter
maxLength = null;
await tester.pumpWidget(buildFrame(
buildCounter: buildCounter,
maxLength: maxLength,
));
expect(find.byKey(buildCounterKey), findsOneWidget);
});
testWidgets('InputDecoration errorMaxLines', (WidgetTester tester) async {
const String kError1 = 'e0';
const String kError2 = 'e0\ne1';
......
......@@ -1936,6 +1936,29 @@ void main() {
expect(find.text('5'), findsOneWidget);
});
testWidgets('passing a buildCounter shows returned widget', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Material(
child: Center(
child: TextField(
buildCounter: (BuildContext context, {int currentLength, int maxLength, bool isFocused}) {
return Text('${currentLength.toString()} of ${maxLength.toString()}');
},
maxLength: 10,
),
),
),
),
);
expect(find.text('0 of 10'), findsOneWidget);
await tester.enterText(find.byType(TextField), '01234');
await tester.pump();
expect(find.text('5 of 10'), findsOneWidget);
});
testWidgets('TextField identifies as text field in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
......
......@@ -190,4 +190,27 @@ void main() {
await tester.pump();
expect(_validateCalled, 2);
});
testWidgets('passing a buildCounter shows returned widget', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Material(
child: Center(
child: TextFormField(
buildCounter: (BuildContext context, {int currentLength, int maxLength, bool isFocused}) {
return Text('${currentLength.toString()} of ${maxLength.toString()}');
},
maxLength: 10,
),
),
),
),
);
expect(find.text('0 of 10'), findsOneWidget);
await tester.enterText(find.byType(TextField), '01234');
await tester.pump();
expect(find.text('5 of 10'), findsOneWidget);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment