From de2470ffa41bb69db26667a72e770622e9b0bd5f Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 14 Jan 2019 15:22:42 -0800 Subject: [PATCH] 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 --- .../lib/src/material/input_decorator.dart | 26 ++++- .../flutter/lib/src/material/text_field.dart | 70 ++++++++++++- .../lib/src/material/text_form_field.dart | 2 + .../test/material/input_decorator_test.dart | 97 +++++++++++++++++++ .../test/material/text_field_test.dart | 23 +++++ .../test/material/text_form_field_test.dart | 23 +++++ 6 files changed, 237 insertions(+), 4 deletions(-) diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index e5b1458abe0..38a8ee7ded6 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -1856,8 +1856,11 @@ class _InputDecoratorState extends State 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 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) diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 7adf7105e50..cf58f619b7e 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -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 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 = ''; diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index 4dca8b7d616..9670e39b09f 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -101,6 +101,7 @@ class TextFormField extends FormField { 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 { scrollPadding: scrollPadding, keyboardAppearance: keyboardAppearance, enableInteractiveSelection: enableInteractiveSelection, + buildCounter: buildCounter, ); }, ); diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index 7caa45d984f..827e68cac3c 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -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: [ + 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'; diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index cb72bb7d38a..805f7b563cd 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -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); diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index c40af9154ce..190486b133a 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -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); + }); }