Unverified Commit de44daf1 authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Add a parameter to configure InputDecorator hint fade animations duration (#135211)

## Description

This PR adds a parameter to configure the input decorator hint fade transition duration.

This animation is not part of the Material specification.
Removing it was considered but it breaks internal tests (see https://github.com/flutter/flutter/pull/107406).
I also considered several ways to avoid the fade animation (setting duration to 0, removing the hint text, etc) but it breaks many existing tests that assumes the hint text to be visible.

To mitigate the issue in a non disruptive way, I set the default duration to 20ms (an arbitrary short value).

## Related Issue

Fixes https://github.com/flutter/flutter/issues/20283.

## Tests

Adds 3 tests, updates 3 tests.
parent 4204f07d
......@@ -30,6 +30,13 @@ const Duration _kTransitionDuration = Duration(milliseconds: 167);
const Curve _kTransitionCurve = Curves.fastOutSlowIn;
const double _kFinalLabelScale = 0.75;
// The default duration for hint fade in/out transitions.
//
// Animating hint is not mentioned in the Material specification.
// The animation is kept for backard compatibility and a short duration
// is used to mitigate the UX impact.
const Duration _kHintFadeTransitionDuration = Duration(milliseconds: 20);
// Defines the gap in the InputDecorator's outline border where the
// floating label will appear.
class _InputBorderGap extends ChangeNotifier {
......@@ -2192,7 +2199,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
final String? hintText = decoration.hintText;
final Widget? hint = hintText == null ? null : AnimatedOpacity(
opacity: (isEmpty && !_hasInlineLabel) ? 1.0 : 0.0,
duration: _kTransitionDuration,
duration: decoration.hintFadeDuration ?? _kHintFadeTransitionDuration,
curve: _kTransitionCurve,
child: Text(
hintText,
......@@ -2571,6 +2578,7 @@ class InputDecoration {
this.hintStyle,
this.hintTextDirection,
this.hintMaxLines,
this.hintFadeDuration,
this.error,
this.errorText,
this.errorStyle,
......@@ -2641,6 +2649,7 @@ class InputDecoration {
helperStyle = null,
helperMaxLines = null,
hintMaxLines = null,
hintFadeDuration = null,
error = null,
errorText = null,
errorStyle = null,
......@@ -2854,6 +2863,12 @@ class InputDecoration {
/// used to handle the overflow when it is limited to single line.
final int? hintMaxLines;
/// The duration of the [hintText] fade in and fade out animations.
///
/// If null, defaults to [InputDecorationTheme.hintFadeDuration].
/// If [InputDecorationTheme.hintFadeDuration] is null defaults to 20ms.
final Duration? hintFadeDuration;
/// Optional widget that appears below the [InputDecorator.child] and the border.
///
/// If non-null, the border's color animates to red and the [helperText] is not shown.
......@@ -3507,6 +3522,7 @@ class InputDecoration {
String? hintText,
TextStyle? hintStyle,
TextDirection? hintTextDirection,
Duration? hintFadeDuration,
int? hintMaxLines,
Widget? error,
String? errorText,
......@@ -3561,6 +3577,7 @@ class InputDecoration {
hintStyle: hintStyle ?? this.hintStyle,
hintTextDirection: hintTextDirection ?? this.hintTextDirection,
hintMaxLines: hintMaxLines ?? this.hintMaxLines,
hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration,
error: error ?? this.error,
errorText: errorText ?? this.errorText,
errorStyle: errorStyle ?? this.errorStyle,
......@@ -3614,6 +3631,7 @@ class InputDecoration {
helperStyle: helperStyle ?? theme.helperStyle,
helperMaxLines : helperMaxLines ?? theme.helperMaxLines,
hintStyle: hintStyle ?? theme.hintStyle,
hintFadeDuration: hintFadeDuration ?? theme.hintFadeDuration,
errorStyle: errorStyle ?? theme.errorStyle,
errorMaxLines: errorMaxLines ?? theme.errorMaxLines,
floatingLabelBehavior: floatingLabelBehavior ?? theme.floatingLabelBehavior,
......@@ -3664,6 +3682,7 @@ class InputDecoration {
&& other.hintStyle == hintStyle
&& other.hintTextDirection == hintTextDirection
&& other.hintMaxLines == hintMaxLines
&& other.hintFadeDuration == hintFadeDuration
&& other.error == error
&& other.errorText == errorText
&& other.errorStyle == errorStyle
......@@ -3720,6 +3739,7 @@ class InputDecoration {
hintStyle,
hintTextDirection,
hintMaxLines,
hintFadeDuration,
error,
errorText,
errorStyle,
......@@ -3774,6 +3794,7 @@ class InputDecoration {
if (helperMaxLines != null) 'helperMaxLines: "$helperMaxLines"',
if (hintText != null) 'hintText: "$hintText"',
if (hintMaxLines != null) 'hintMaxLines: "$hintMaxLines"',
if (hintFadeDuration != null) 'hintFadeDuration: "$hintFadeDuration"',
if (error != null) 'error: "$error"',
if (errorText != null) 'errorText: "$errorText"',
if (errorStyle != null) 'errorStyle: "$errorStyle"',
......@@ -3836,6 +3857,7 @@ class InputDecorationTheme with Diagnosticable {
this.helperStyle,
this.helperMaxLines,
this.hintStyle,
this.hintFadeDuration,
this.errorStyle,
this.errorMaxLines,
this.floatingLabelBehavior = FloatingLabelBehavior.auto,
......@@ -3906,6 +3928,9 @@ class InputDecorationTheme with Diagnosticable {
/// input field and the current [Theme].
final TextStyle? hintStyle;
/// The duration of the [InputDecoration.hintText] fade in and fade out animations.
final Duration? hintFadeDuration;
/// {@macro flutter.material.inputDecoration.errorStyle}
final TextStyle? errorStyle;
......@@ -4243,6 +4268,7 @@ class InputDecorationTheme with Diagnosticable {
TextStyle? helperStyle,
int? helperMaxLines,
TextStyle? hintStyle,
Duration? hintFadeDuration,
TextStyle? errorStyle,
int? errorMaxLines,
FloatingLabelBehavior? floatingLabelBehavior,
......@@ -4277,6 +4303,7 @@ class InputDecorationTheme with Diagnosticable {
helperStyle: helperStyle ?? this.helperStyle,
helperMaxLines: helperMaxLines ?? this.helperMaxLines,
hintStyle: hintStyle ?? this.hintStyle,
hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration,
errorStyle: errorStyle ?? this.errorStyle,
errorMaxLines: errorMaxLines ?? this.errorMaxLines,
floatingLabelBehavior: floatingLabelBehavior ?? this.floatingLabelBehavior,
......@@ -4326,6 +4353,7 @@ class InputDecorationTheme with Diagnosticable {
helperStyle: helperStyle ?? inputDecorationTheme.helperStyle,
helperMaxLines: helperMaxLines ?? inputDecorationTheme.helperMaxLines,
hintStyle: hintStyle ?? inputDecorationTheme.hintStyle,
hintFadeDuration: hintFadeDuration ?? inputDecorationTheme.hintFadeDuration,
errorStyle: errorStyle ?? inputDecorationTheme.errorStyle,
errorMaxLines: errorMaxLines ?? inputDecorationTheme.errorMaxLines,
contentPadding: contentPadding ?? inputDecorationTheme.contentPadding,
......@@ -4385,6 +4413,7 @@ class InputDecorationTheme with Diagnosticable {
border,
alignLabelWithHint,
constraints,
hintFadeDuration,
),
);
......@@ -4402,6 +4431,7 @@ class InputDecorationTheme with Diagnosticable {
&& other.helperStyle == helperStyle
&& other.helperMaxLines == helperMaxLines
&& other.hintStyle == hintStyle
&& other.hintFadeDuration == hintFadeDuration
&& other.errorStyle == errorStyle
&& other.errorMaxLines == errorMaxLines
&& other.isDense == isDense
......@@ -4441,6 +4471,7 @@ class InputDecorationTheme with Diagnosticable {
properties.add(DiagnosticsProperty<TextStyle>('helperStyle', helperStyle, defaultValue: defaultTheme.helperStyle));
properties.add(IntProperty('helperMaxLines', helperMaxLines, defaultValue: defaultTheme.helperMaxLines));
properties.add(DiagnosticsProperty<TextStyle>('hintStyle', hintStyle, defaultValue: defaultTheme.hintStyle));
properties.add(DiagnosticsProperty<Duration>('hintFadeDuration', hintFadeDuration, defaultValue: defaultTheme.hintFadeDuration));
properties.add(DiagnosticsProperty<TextStyle>('errorStyle', errorStyle, defaultValue: defaultTheme.errorStyle));
properties.add(IntProperty('errorMaxLines', errorMaxLines, defaultValue: defaultTheme.errorMaxLines));
properties.add(DiagnosticsProperty<FloatingLabelBehavior>('floatingLabelBehavior', floatingLabelBehavior, defaultValue: defaultTheme.floatingLabelBehavior));
......
......@@ -1089,14 +1089,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0));
}
await tester.pumpAndSettle();
......@@ -1123,14 +1123,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms));
}
await tester.pumpAndSettle();
......@@ -1210,6 +1210,219 @@ void runAllTests({ required bool useMaterial3 }) {
expect(getBorderWeight(tester), 2.0);
});
testWidgetsWithLeakTracking('InputDecorator default hint animation duration', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint is not visible (opacity 0.0).
expect(getOpacity(tester, 'hint'), 0.0);
// Focus to show the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
isFocused: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0));
await tester.pump(const Duration(milliseconds: 9));
expect(getOpacity(tester, 'hint'), 1.0);
}
// Unfocus to hide the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms));
await tester.pump(const Duration(milliseconds: 9));
expect(getOpacity(tester, 'hint'), 0.0);
}
});
testWidgetsWithLeakTracking('InputDecorator custom hint animation duration', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
hintFadeDuration: Duration(milliseconds: 120),
),
),
);
// The hint is not visible (opacity 0.0).
expect(getOpacity(tester, 'hint'), 0.0);
// Focus to show the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
isFocused: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
hintFadeDuration: Duration(milliseconds: 120),
),
),
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's duration is set to 120ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0));
await tester.pump(const Duration(milliseconds: 50));
expect(getOpacity(tester, 'hint'), 1.0);
}
// Unfocus to hide the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
hintFadeDuration: Duration(milliseconds: 120),
),
),
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms));
await tester.pump(const Duration(milliseconds: 50));
expect(getOpacity(tester, 'hint'), 0.0);
}
});
testWidgetsWithLeakTracking('InputDecorator custom hint animation duration from theme', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
inputDecorationTheme: const InputDecorationTheme(
hintFadeDuration: Duration(milliseconds: 120),
),
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint is not visible (opacity 0.0).
expect(getOpacity(tester, 'hint'), 0.0);
// Focus to show the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
inputDecorationTheme: const InputDecorationTheme(
hintFadeDuration: Duration(milliseconds: 120),
),
isEmpty: true,
isFocused: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's duration is set to 120ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0));
await tester.pump(const Duration(milliseconds: 50));
expect(getOpacity(tester, 'hint'), 1.0);
}
// Unfocus to hide the hint.
await tester.pumpWidget(
buildInputDecorator(
useMaterial3: useMaterial3,
inputDecorationTheme: const InputDecorationTheme(
hintFadeDuration: Duration(milliseconds: 120),
),
isEmpty: true,
decoration: const InputDecoration(
labelText: 'label',
hintText: 'hint',
),
),
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's duration is set to 160ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms));
await tester.pump(const Duration(milliseconds: 50));
expect(getOpacity(tester, 'hint'), 0.0);
}
});
testWidgetsWithLeakTracking('InputDecorator with no input border', (WidgetTester tester) async {
// Label is visible, hint is not (opacity 0.0).
await tester.pumpWidget(
......@@ -2246,14 +2459,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0));
}
await tester.pumpAndSettle();
......@@ -2281,14 +2494,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms));
}
await tester.pumpAndSettle();
......@@ -2343,14 +2556,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 0.0 to 1.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0));
}
await tester.pumpAndSettle();
......@@ -2378,14 +2591,14 @@ void runAllTests({ required bool useMaterial3 }) {
);
// The hint's opacity animates from 1.0 to 0.0.
// The animation's duration is 167ms.
// The animation's default duration is 20ms.
{
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity50ms = getOpacity(tester, 'hint');
expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 50));
final double hintOpacity100ms = getOpacity(tester, 'hint');
expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity9ms = getOpacity(tester, 'hint');
expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0));
await tester.pump(const Duration(milliseconds: 9));
final double hintOpacity18ms = getOpacity(tester, 'hint');
expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms));
}
await tester.pumpAndSettle();
......
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