Text field vertical align (#34355)

Adds the `textAlignVertical` param to TextField and InputDecorator, allowing arbitrary vertical positioning of text in its input.
diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart
index f43cf04..e1b601d 100644
--- a/packages/flutter/lib/src/material/input_decorator.dart
+++ b/packages/flutter/lib/src/material/input_decorator.dart
@@ -574,7 +574,6 @@
   const _RenderDecorationLayout({
     this.boxToBaseline,
     this.inputBaseline, // for InputBorderType.underline
-    this.outlineBaseline, // for InputBorderType.outline
     this.subtextBaseline,
     this.containerHeight,
     this.subtextHeight,
@@ -582,7 +581,6 @@
 
   final Map<RenderBox, double> boxToBaseline;
   final double inputBaseline;
-  final double outlineBaseline;
   final double subtextBaseline; // helper/error counter
   final double containerHeight;
   final double subtextHeight;
@@ -596,6 +594,7 @@
     @required TextBaseline textBaseline,
     @required bool isFocused,
     @required bool expands,
+    TextAlignVertical textAlignVertical,
   }) : assert(decoration != null),
        assert(textDirection != null),
        assert(textBaseline != null),
@@ -603,6 +602,7 @@
        _decoration = decoration,
        _textDirection = textDirection,
        _textBaseline = textBaseline,
+       _textAlignVertical = textAlignVertical,
        _isFocused = isFocused,
        _expands = expands;
 
@@ -746,6 +746,27 @@
     markNeedsLayout();
   }
 
+  TextAlignVertical get textAlignVertical {
+    if (_textAlignVertical == null) {
+      return _isOutlineAligned ? TextAlignVertical.center : TextAlignVertical.top;
+    }
+    return _textAlignVertical;
+  }
+  TextAlignVertical _textAlignVertical;
+  set textAlignVertical(TextAlignVertical value) {
+    assert(value != null);
+    if (_textAlignVertical == value) {
+      return;
+    }
+    // No need to relayout if the effective value is still the same.
+    if (textAlignVertical.y == value.y) {
+      _textAlignVertical = value;
+      return;
+    }
+    _textAlignVertical = value;
+    markNeedsLayout();
+  }
+
   bool get isFocused => _isFocused;
   bool _isFocused;
   set isFocused(bool value) {
@@ -766,6 +787,12 @@
     markNeedsLayout();
   }
 
+  // Indicates that the decoration should be aligned to accommodate an outline
+  // border.
+  bool get _isOutlineAligned {
+    return !decoration.isCollapsed && decoration.border.isOutline;
+  }
+
   @override
   void attach(PipelineOwner owner) {
     super.attach(owner);
@@ -862,7 +889,7 @@
 
   EdgeInsets get contentPadding => decoration.contentPadding;
 
-  // Lay out the given box if needed, and return its baseline
+  // Lay out the given box if needed, and return its baseline.
   double _layoutLineBox(RenderBox box, BoxConstraints constraints) {
     if (box == null) {
       return 0.0;
@@ -1006,21 +1033,34 @@
       ? maxContainerHeight
       : math.min(contentHeight, maxContainerHeight);
 
-    // Always position the prefix/suffix in the same place (baseline).
+    // Try to consider the prefix/suffix as part of the text when aligning it.
+    // If the prefix/suffix overflows however, allow it to extend outside of the
+    // input and align the remaining part of the text and prefix/suffix.
     final double overflow = math.max(0, contentHeight - maxContainerHeight);
-    final double baselineAdjustment = fixAboveInput - overflow;
+    // Map textAlignVertical from -1:1 to 0:1 so that it can be used to scale
+    // the baseline from its minimum to maximum values.
+    final double textAlignVerticalFactor = (textAlignVertical.y + 1.0) / 2.0;
+    // Adjust to try to fit top overflow inside the input on an inverse scale of
+    // textAlignVertical, so that top aligned text adjusts the most and bottom
+    // aligned text doesn't adjust at all.
+    final double baselineAdjustment = fixAboveInput - overflow * (1 - textAlignVerticalFactor);
 
     // The baselines that will be used to draw the actual input text content.
-    final double inputBaseline = contentPadding.top
+    final double topInputBaseline = contentPadding.top
       + topHeight
       + inputInternalBaseline
       + baselineAdjustment;
-    // The text in the input when an outline border is present is centered
-    // within the container less 2.0 dps at the top to account for the vertical
-    // space occupied by the floating label.
-    final double outlineBaseline = inputInternalBaseline
-      + baselineAdjustment / 2
-      + (containerHeight - (2.0 + inputHeight)) / 2.0;
+    final double maxContentHeight = containerHeight
+      - contentPadding.top
+      - topHeight
+      - contentPadding.bottom;
+    final double alignableHeight = fixAboveInput + inputHeight + fixBelowInput;
+    // When outline aligned, the baseline is vertically centered by default, and
+    // outlinePadding is used to account for the presence of the border and
+    // floating label.
+    final double outlinePadding = _isOutlineAligned ? 10.0 : 0;
+    final double textAlignVerticalOffset = (maxContentHeight - alignableHeight - outlinePadding) * textAlignVerticalFactor;
+    final double inputBaseline = topInputBaseline + textAlignVerticalOffset;
 
     // Find the positions of the text below the input when it exists.
     double subtextCounterBaseline = 0;
@@ -1050,7 +1090,6 @@
       boxToBaseline: boxToBaseline,
       containerHeight: containerHeight,
       inputBaseline: inputBaseline,
-      outlineBaseline: outlineBaseline,
       subtextBaseline: subtextBaseline,
       subtextHeight: subtextHeight,
     );
@@ -1160,9 +1199,7 @@
     final double right = overallWidth - contentPadding.right;
 
     height = layout.containerHeight;
-    baseline = decoration.isCollapsed || !decoration.border.isOutline
-      ? layout.inputBaseline
-      : layout.outlineBaseline;
+    baseline = layout.inputBaseline;
 
     if (icon != null) {
       double x;
@@ -1213,12 +1250,13 @@
           start -= contentPadding.left;
           start += centerLayout(prefixIcon, start);
         }
-        if (label != null)
+        if (label != null) {
           if (decoration.alignLabelWithHint) {
             baselineLayout(label, start);
           } else {
             centerLayout(label, start);
           }
+        }
         if (prefix != null)
           start += baselineLayout(prefix, start);
         if (input != null)
@@ -1512,6 +1550,7 @@
 class _Decorator extends RenderObjectWidget {
   const _Decorator({
     Key key,
+    @required this.textAlignVertical,
     @required this.decoration,
     @required this.textDirection,
     @required this.textBaseline,
@@ -1526,6 +1565,7 @@
   final _Decoration decoration;
   final TextDirection textDirection;
   final TextBaseline textBaseline;
+  final TextAlignVertical textAlignVertical;
   final bool isFocused;
   final bool expands;
 
@@ -1538,6 +1578,7 @@
       decoration: decoration,
       textDirection: textDirection,
       textBaseline: textBaseline,
+      textAlignVertical: textAlignVertical,
       isFocused: isFocused,
       expands: expands,
     );
@@ -1612,6 +1653,7 @@
     this.decoration,
     this.baseStyle,
     this.textAlign,
+    this.textAlignVertical,
     this.isFocused = false,
     this.isHovering = false,
     this.expands = false,
@@ -1643,6 +1685,20 @@
   /// How the text in the decoration should be aligned horizontally.
   final TextAlign textAlign;
 
+  /// {@template flutter.widgets.inputDecorator.textAlignVertical}
+  /// How the text should be aligned vertically.
+  ///
+  /// Determines the alignment of the baseline within the available space of
+  /// the input (typically a TextField). For example, TextAlignVertical.top will
+  /// place the baseline such that the text, and any attached decoration like
+  /// prefix and suffix, is as close to the top of the input as possible without
+  /// overflowing. The heights of the prefix and suffix are similarly included
+  /// for other alignment values. If the height is greater than the height
+  /// available, then the prefix and suffix will be allowed to overflow first
+  /// before the text scrolls.
+  /// {@endtemplate}
+  final TextAlignVertical textAlignVertical;
+
   /// Whether the input field has focus.
   ///
   /// Determines the position of the label text and the color and weight of the
@@ -2148,6 +2204,7 @@
       ),
       textDirection: textDirection,
       textBaseline: textBaseline,
+      textAlignVertical: widget.textAlignVertical,
       isFocused: isFocused,
       expands: widget.expands,
     );
@@ -3468,3 +3525,42 @@
     properties.add(DiagnosticsProperty<bool>('alignLabelWithHint', alignLabelWithHint, defaultValue: defaultTheme.alignLabelWithHint));
   }
 }
+
+/// The vertical alignment of text within an input.
+///
+/// A single [y] value that can range from -1.0 to 1.0. -1.0 aligns to the top
+/// of the input so that the top of the first line of text fits within the input
+/// and its padding. 0.0 aligns to the center of the input. 1.0 aligns so that
+/// the bottom of the last line of text aligns with the bottom interior edge of
+/// the input.
+///
+/// See also:
+///
+///  * [TextField.textAlignVertical], which is passed on to the [InputDecorator].
+///  * [InputDecorator.textAlignVertical], which defines the alignment of
+///    prefix, input, and suffix, within the [InputDecorator].
+class TextAlignVertical {
+  /// Construct TextAlignVertical from any given y value.
+  const TextAlignVertical({
+    @required this.y,
+  }) : assert(y != null),
+       assert(y >= -1.0 && y <= 1.0);
+
+  /// A value ranging from -1.0 to 1.0 that defines the topmost and bottommost
+  /// locations of the top and bottom of the input text box.
+  final double y;
+
+  /// Aligns a TextField's input Text with the topmost location within the
+  /// TextField.
+  static const TextAlignVertical top = TextAlignVertical(y: -1.0);
+  /// Aligns a TextField's input Text to the center of the TextField.
+  static const TextAlignVertical center = TextAlignVertical(y: 0.0);
+  /// Aligns a TextField's input Text with the bottommost location within the
+  /// TextField.
+  static const TextAlignVertical bottom = TextAlignVertical(y: 1.0);
+
+  @override
+  String toString() {
+    return '$runtimeType(y: $y)';
+  }
+}
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 1c7cc2c..e8061e2 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -149,6 +149,7 @@
     this.style,
     this.strutStyle,
     this.textAlign = TextAlign.start,
+    this.textAlignVertical,
     this.textDirection,
     this.readOnly = false,
     this.showCursor,
@@ -278,6 +279,9 @@
   /// {@macro flutter.widgets.editableText.textAlign}
   final TextAlign textAlign;
 
+  /// {@macro flutter.material.inputDecorator.textAlignVertical}
+  final TextAlignVertical textAlignVertical;
+
   /// {@macro flutter.widgets.editableText.textDirection}
   final TextDirection textDirection;
 
@@ -506,6 +510,7 @@
     properties.add(EnumProperty<TextInputAction>('textInputAction', textInputAction, defaultValue: null));
     properties.add(EnumProperty<TextCapitalization>('textCapitalization', textCapitalization, defaultValue: TextCapitalization.none));
     properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: TextAlign.start));
+    properties.add(DiagnosticsProperty<TextAlignVertical>('textAlignVertical', textAlignVertical, defaultValue: null));
     properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
     properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
     properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
@@ -1009,6 +1014,7 @@
             decoration: _getEffectiveDecoration(),
             baseStyle: widget.style,
             textAlign: widget.textAlign,
+            textAlignVertical: widget.textAlignVertical,
             isHovering: _isHovering,
             isFocused: focusNode.hasFocus,
             isEmpty: controller.value.text.isEmpty,
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index a8b674b..db37d04 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -2153,8 +2153,9 @@
       renderers.add(renderer);
     }
     final Matrix4 transform = Matrix4.identity();
-    for (int index = renderers.length - 1; index > 0; index -= 1)
+    for (int index = renderers.length - 1; index > 0; index -= 1) {
       renderers[index].applyPaintTransform(renderers[index - 1], transform);
+    }
     return transform;
   }
 
diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart
index 88678da..a34039d 100644
--- a/packages/flutter/test/material/input_decorator_test.dart
+++ b/packages/flutter/test/material/input_decorator_test.dart
@@ -15,10 +15,12 @@
   InputDecoration decoration = const InputDecoration(),
   InputDecorationTheme inputDecorationTheme,
   TextDirection textDirection = TextDirection.ltr,
+  bool expands = false,
   bool isEmpty = false,
   bool isFocused = false,
   bool isHovering = false,
   TextStyle baseStyle,
+  TextAlignVertical textAlignVertical,
   Widget child = const Text(
     'text',
     style: TextStyle(fontFamily: 'Ahem', fontSize: 16.0),
@@ -37,11 +39,13 @@
               child: Directionality(
                 textDirection: textDirection,
                 child: InputDecorator(
+                  expands: expands,
                   decoration: decoration,
                   isEmpty: isEmpty,
                   isFocused: isFocused,
                   isHovering: isHovering,
                   baseStyle: baseStyle,
+                  textAlignVertical: textAlignVertical,
                   child: child,
                 ),
               ),
@@ -277,69 +281,216 @@
     expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
   });
 
-  testWidgets('InputDecorator alignLabelWithHint for multiline TextField no-strut', (WidgetTester tester) async {
-    Widget buildFrame(bool alignLabelWithHint) {
-      return MaterialApp(
-        home: Material(
-          child: Directionality(
-            textDirection: TextDirection.ltr,
-            child: TextField(
-              maxLines: 8,
-              decoration: InputDecoration(
-                labelText: 'label',
-                alignLabelWithHint: alignLabelWithHint,
-                hintText: 'hint',
-              ),
-              strutStyle: StrutStyle.disabled,
-            ),
-          ),
-        ),
-      );
-    }
-
-    // alignLabelWithHint: false centers the label in the TextField
-    await tester.pumpWidget(buildFrame(false));
-    await tester.pumpAndSettle();
-    expect(tester.getTopLeft(find.text('label')).dy, 76.0);
-    expect(tester.getBottomLeft(find.text('label')).dy, 92.0);
-
-    // alignLabelWithHint: true aligns the label with the hint.
-    await tester.pumpWidget(buildFrame(true));
-    await tester.pumpAndSettle();
-    expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
-    expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
-  });
-
-  testWidgets('InputDecorator alignLabelWithHint for multiline TextField', (WidgetTester tester) async {
-    Widget buildFrame(bool alignLabelWithHint) {
-      return MaterialApp(
-        home: Material(
-          child: Directionality(
-            textDirection: TextDirection.ltr,
-            child: TextField(
-              maxLines: 8,
-              decoration: InputDecoration(
-                labelText: 'label',
-                alignLabelWithHint: alignLabelWithHint,
-                hintText: 'hint',
+  group('alignLabelWithHint', () {
+    group('expands false', () {
+      testWidgets('multiline TextField no-strut', (WidgetTester tester) async {
+        const String text = 'text';
+        final FocusNode focusNode = FocusNode();
+        final TextEditingController controller = TextEditingController();
+        Widget buildFrame(bool alignLabelWithHint) {
+          return MaterialApp(
+            home: Material(
+              child: Directionality(
+                textDirection: TextDirection.ltr,
+                child: TextField(
+                  controller: controller,
+                  focusNode: focusNode,
+                  maxLines: 8,
+                  decoration: InputDecoration(
+                    labelText: 'label',
+                    alignLabelWithHint: alignLabelWithHint,
+                    hintText: 'hint',
+                  ),
+                  strutStyle: StrutStyle.disabled,
+                ),
               ),
             ),
-          ),
-        ),
-      );
-    }
+          );
+        }
 
-    // alignLabelWithHint: false centers the label in the TextField
-    await tester.pumpWidget(buildFrame(false));
-    await tester.pumpAndSettle();
-    expect(tester.getTopLeft(find.text('label')).dy, 76.0);
-    expect(tester.getBottomLeft(find.text('label')).dy, 92.0);
+        // alignLabelWithHint: false centers the label in the TextField.
+        await tester.pumpWidget(buildFrame(false));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 76.0);
+        expect(tester.getBottomLeft(find.text('label')).dy, 92.0);
 
-    // alignLabelWithHint: true aligns the label with the hint.
-    await tester.pumpWidget(buildFrame(true));
-    await tester.pumpAndSettle();
-    expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
-    expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(TextField), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+
+        // alignLabelWithHint: true aligns the label with the hint.
+        await tester.pumpWidget(buildFrame(true));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
+        expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
+
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(TextField), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+      });
+
+      testWidgets('multiline TextField', (WidgetTester tester) async {
+        const String text = 'text';
+        final FocusNode focusNode = FocusNode();
+        final TextEditingController controller = TextEditingController();
+        Widget buildFrame(bool alignLabelWithHint) {
+          return MaterialApp(
+            home: Material(
+              child: Directionality(
+                textDirection: TextDirection.ltr,
+                child: TextField(
+                  controller: controller,
+                  focusNode: focusNode,
+                  maxLines: 8,
+                  decoration: InputDecoration(
+                    labelText: 'label',
+                    alignLabelWithHint: alignLabelWithHint,
+                    hintText: 'hint',
+                  ),
+                ),
+              ),
+            ),
+          );
+        }
+
+        // alignLabelWithHint: false centers the label in the TextField.
+        await tester.pumpWidget(buildFrame(false));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 76.0);
+        expect(tester.getBottomLeft(find.text('label')).dy, 92.0);
+
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+
+        // alignLabelWithHint: true aligns the label with the hint.
+        await tester.pumpWidget(buildFrame(true));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
+        expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
+
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+      });
+    });
+
+    group('expands true', () {
+      testWidgets('multiline TextField', (WidgetTester tester) async {
+        const String text = 'text';
+        final FocusNode focusNode = FocusNode();
+        final TextEditingController controller = TextEditingController();
+        Widget buildFrame(bool alignLabelWithHint) {
+          return MaterialApp(
+            home: Material(
+              child: Directionality(
+                textDirection: TextDirection.ltr,
+                child: TextField(
+                  controller: controller,
+                  focusNode: focusNode,
+                  maxLines: null,
+                  expands: true,
+                  decoration: InputDecoration(
+                    labelText: 'label',
+                    alignLabelWithHint: alignLabelWithHint,
+                    hintText: 'hint',
+                  ),
+                ),
+              ),
+            ),
+          );
+        }
+
+        // alignLabelWithHint: false centers the label in the TextField.
+        await tester.pumpWidget(buildFrame(false));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 292.0);
+        expect(tester.getBottomLeft(find.text('label')).dy, 308.0);
+
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+
+        // alignLabelWithHint: true aligns the label with the hint at the top.
+        await tester.pumpWidget(buildFrame(true));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 28.0);
+        expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
+        expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
+
+        // Entering text still happens at the top.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 28.0);
+        controller.clear();
+        focusNode.unfocus();
+      });
+
+      testWidgets('multiline TextField with outline border', (WidgetTester tester) async {
+        const String text = 'text';
+        final FocusNode focusNode = FocusNode();
+        final TextEditingController controller = TextEditingController();
+        Widget buildFrame(bool alignLabelWithHint) {
+          return MaterialApp(
+            home: Material(
+              child: Directionality(
+                textDirection: TextDirection.ltr,
+                child: TextField(
+                  controller: controller,
+                  focusNode: focusNode,
+                  maxLines: null,
+                  expands: true,
+                  decoration: InputDecoration(
+                    labelText: 'label',
+                    alignLabelWithHint: alignLabelWithHint,
+                    hintText: 'hint',
+                    border: OutlineInputBorder(
+                      borderSide: const BorderSide(width: 1, color: Colors.black, style: BorderStyle.solid),
+                      borderRadius: BorderRadius.circular(0),
+                    ),
+                  ),
+                ),
+              ),
+            ),
+          );
+        }
+
+        // alignLabelWithHint: false centers the label in the TextField.
+        await tester.pumpWidget(buildFrame(false));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 292.0);
+        expect(tester.getBottomLeft(find.text('label')).dy, 308.0);
+
+        // Entering text happens in the center as well.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 291.0);
+        controller.clear();
+        focusNode.unfocus();
+
+        // alignLabelWithHint: true aligns keeps the label in the center because
+        // that's where the hint is.
+        await tester.pumpWidget(buildFrame(true));
+        await tester.pumpAndSettle();
+        expect(tester.getTopLeft(find.text('label')).dy, 291.0);
+        expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy);
+        expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy);
+
+        // Entering text still happens in the center.
+        await tester.enterText(find.byType(InputDecorator), text);
+        expect(tester.getTopLeft(find.text(text)).dy, 291.0);
+        controller.clear();
+        focusNode.unfocus();
+      });
+    });
   });
 
   // Overall height for this InputDecorator is 40.0dps
@@ -1178,6 +1329,471 @@
     expect(tester.getTopLeft(find.byKey(prefixKey)).dy, 0.0);
   });
 
+  group('textAlignVertical position', () {
+    group('simple case', () {
+      testWidgets('align top (default)', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true, // so we have a tall input where align can vary
+            decoration: const InputDecoration(
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.top, // default when no border
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Same as the default case above.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(12.0, .0001));
+      });
+
+      testWidgets('align center', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: const InputDecoration(
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.center,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Below the top aligned case.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(290.0, .0001));
+      });
+
+      testWidgets('align bottom', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: const InputDecoration(
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.bottom,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Below the center aligned case.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(568.0, .0001));
+      });
+
+      testWidgets('align as a double', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: const InputDecoration(
+              filled: true,
+            ),
+            textAlignVertical: const TextAlignVertical(y: 0.75),
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // In between the center and bottom aligned cases.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(498.5, .0001));
+      });
+    });
+
+    group('outline border', () {
+      testWidgets('align top', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true, // so we have a tall input where align can vary
+            decoration: const InputDecoration(
+              filled: true,
+              border: OutlineInputBorder(),
+            ),
+            textAlignVertical: TextAlignVertical.top,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Similar to the case without a border, but with a little extra room at
+        // the top to make room for the border.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(24.0, .0001));
+      });
+
+      testWidgets('align center (default)', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: const InputDecoration(
+              filled: true,
+              border: OutlineInputBorder(),
+            ),
+            textAlignVertical: TextAlignVertical.center, // default when border
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Below the top aligned case.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(289.0, .0001));
+      });
+
+      testWidgets('align bottom', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: const InputDecoration(
+              filled: true,
+              border: OutlineInputBorder(),
+            ),
+            textAlignVertical: TextAlignVertical.bottom,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Below the center aligned case.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(554.0, .0001));
+      });
+    });
+
+    group('prefix', () {
+      testWidgets('InputDecorator tall prefix align top', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            decoration: InputDecoration(
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.top, // default when no border
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Same as the default case above.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(96, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0);
+      });
+
+      testWidgets('InputDecorator tall prefix align center', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            decoration: InputDecoration(
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.center,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Same as the default case above.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(96.0, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0);
+      });
+
+      testWidgets('InputDecorator tall prefix align bottom', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            decoration: InputDecoration(
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.bottom,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Top of the input + 100 prefix height - overlap
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(96.0, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0);
+      });
+    });
+
+    group('outline border and prefix', () {
+      testWidgets('InputDecorator tall prefix align center', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: InputDecoration(
+              border: const OutlineInputBorder(),
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.center, // default when border
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // In the middle of the expanded InputDecorator.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(331.0, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, closeTo(247.0, .0001));
+      });
+
+      testWidgets('InputDecorator tall prefix with border align top', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: InputDecoration(
+              border: const OutlineInputBorder(),
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.top,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Above the center example.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(108.0, .0001));
+        // The prefix is positioned at the top of the input, so this value is
+        // the same as the top aligned test without a prefix.
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, 24.0);
+      });
+
+      testWidgets('InputDecorator tall prefix with border align bottom', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: InputDecoration(
+              border: const OutlineInputBorder(),
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.bottom,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Below the center example.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(554.0, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, closeTo(470.0, .0001));
+      });
+
+      testWidgets('InputDecorator tall prefix with border align double', (WidgetTester tester) async {
+        const Key pKey = Key('p');
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true,
+            decoration: InputDecoration(
+              border: const OutlineInputBorder(),
+              prefix: Container(
+                key: pKey,
+                height: 100,
+                width: 10,
+              ),
+              filled: true,
+            ),
+            textAlignVertical: const TextAlignVertical(y: 0.1),
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // Between the top and center examples.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(353.3, .0001));
+        expect(tester.getTopLeft(find.byKey(pKey)).dy, closeTo(269.3, .0001));
+      });
+    });
+
+    group('label', () {
+      testWidgets('align top (default)', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true, // so we have a tall input where align can vary
+            decoration: const InputDecoration(
+              labelText: 'label',
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.top, // default
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // The label causes the text to start slightly lower than it would
+        // otherwise.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(28.0, .0001));
+      });
+
+      testWidgets('align center', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true, // so we have a tall input where align can vary
+            decoration: const InputDecoration(
+              labelText: 'label',
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.center,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // The label reduces the amount of space available for text, so the
+        // center is slightly lower.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(298.0, .0001));
+      });
+
+      testWidgets('align bottom', (WidgetTester tester) async {
+        const String text = 'text';
+        await tester.pumpWidget(
+          buildInputDecorator(
+            // isEmpty: false (default)
+            // isFocused: false (default)
+            expands: true, // so we have a tall input where align can vary
+            decoration: const InputDecoration(
+              labelText: 'label',
+              filled: true,
+            ),
+            textAlignVertical: TextAlignVertical.bottom,
+            // Set the fontSize so that everything works out to whole numbers.
+            child: const Text(
+              text,
+              style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+            ),
+          ),
+        );
+
+        // The label reduces the amount of space available for text, but the
+        // bottom line is still in the same place.
+        expect(tester.getTopLeft(find.text(text)).dy, closeTo(568.0, .0001));
+      });
+    });
+  });
+
   testWidgets('counter text has correct right margin - LTR, not dense', (WidgetTester tester) async {
     await tester.pumpWidget(
       buildInputDecorator(