mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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
This commit is contained in:
parent
35fcd9077f
commit
de2470ffa4
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user