Stop TextFields from wobbling within scrollables (#24015)
diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 7062bee..f1a01bb 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart
@@ -984,11 +984,7 @@ assert(targetOffset != null); - if (duration == Duration.zero) { - offset.jumpTo(targetOffset.offset); - } else { - offset.animateTo(targetOffset.offset, duration: duration, curve: curve); - } + offset.moveTo(targetOffset.offset, duration: duration, curve: curve); return targetOffset.rect; } }
diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index 341a1ac..62c5e1f 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart
@@ -181,6 +181,27 @@ @required Curve curve, }); + /// Calls [jumpTo] if duration is null or [Duration.zero], otherwise + /// [animateTo] is called. + /// + /// If [animateTo] is called then [curve] defaults to [Curves.ease]. The + /// [clamp] parameter is ignored by this stub implementation but subclasses + /// like [ScrollPosition] handle it by adjusting [to] to prevent over or + /// underscroll. + Future<void> moveTo(double to, { + Duration duration, + Curve curve, + bool clamp, + }) { + assert(to != null); + if (duration == null || duration == Duration.zero) { + jumpTo(to); + return Future<void>.value(); + } else { + return animateTo(to, duration: duration, curve: curve ?? Curves.ease); + } + } + /// The direction in which the user is trying to change [pixels], relative to /// the viewport's [RenderViewport.axisDirection]. ///
diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index b550e15..c7d6448 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart
@@ -562,6 +562,28 @@ @override void jumpTo(double value); + /// Calls [jumpTo] if duration is null or [Duration.zero], otherwise + /// [animateTo] is called. + /// + /// If [clamp] is true (the default) then [to] is adjusted to prevent over or + /// underscroll. + /// + /// If [animateTo] is called then [curve] defaults to [Curves.ease]. + @override + Future<void> moveTo(double to, { + Duration duration, + Curve curve, + bool clamp = true, + }) { + assert(to != null); + assert(clamp != null); + + if (clamp) + to = to.clamp(minScrollExtent, maxScrollExtent); + + return super.moveTo(to, duration: duration, curve: curve); + } + @override bool get allowImplicitScrolling => physics.allowImplicitScrolling;
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 1dfe834..0afdd51 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart
@@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:math' as math; import 'dart:ui' as ui show window; import 'package:flutter/material.dart'; @@ -3182,4 +3183,148 @@ final Rect fieldRect = tester.getRect(find.text('Just some text')); expect(labelRect.bottom, lessThanOrEqualTo(fieldRect.top)); }); + + testWidgets('TextField scrolls into view but does not bounce (SingleChildScrollView)', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/20485 + + final Key textField1 = UniqueKey(); + final Key textField2 = UniqueKey(); + final ScrollController scrollController = ScrollController(); + + double minOffset; + double maxOffset; + + scrollController.addListener(() { + final double offset = scrollController.offset; + minOffset = math.min(minOffset ?? offset, offset); + maxOffset = math.max(maxOffset ?? offset, offset); + }); + + Widget buildFrame(Axis scrollDirection) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + controller: scrollController, + child: Column( + children: <Widget>[ + SizedBox( // visible when scrollOffset is 0.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField1, scrollPadding: const EdgeInsets.all(200.0)), + ), + const SizedBox( + height: 600.0, // Same size as the frame. Initially + width: 800.0, // textField2 is not visible + ), + SizedBox( // visible when scrollOffset is 200.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField2, scrollPadding: const EdgeInsets.all(200.0)), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Axis.vertical)); + await tester.enterText(find.byKey(textField1), '1'); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 200.0); + + minOffset = null; + maxOffset = null; + + await tester.pumpWidget(buildFrame(Axis.horizontal)); + await tester.enterText(find.byKey(textField1), '1'); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 200.0); + }); + + testWidgets('TextField scrolls into view but does not bounce (ListView)', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/20485 + + final Key textField1 = UniqueKey(); + final Key textField2 = UniqueKey(); + final ScrollController scrollController = ScrollController(); + + double minOffset; + double maxOffset; + + scrollController.addListener(() { + final double offset = scrollController.offset; + minOffset = math.min(minOffset ?? offset, offset); + maxOffset = math.max(maxOffset ?? offset, offset); + }); + + Widget buildFrame(Axis scrollDirection) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: ListView( + physics: const BouncingScrollPhysics(), + controller: scrollController, + children: <Widget>[ + SizedBox( // visible when scrollOffset is 0.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField1, scrollPadding: const EdgeInsets.all(200.0)), + ), + const SizedBox( + height: 450.0, // 50.0 smaller than the overall frame so that both + width: 650.0, // textfields are always partially visible. + ), + SizedBox( // visible when scrollOffset = 50.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField2, scrollPadding: const EdgeInsets.all(200.0)), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Axis.vertical)); + await tester.enterText(find.byKey(textField1), '1'); // textfield1 is visible + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 50.0); + + minOffset = null; + maxOffset = null; + + await tester.pumpWidget(buildFrame(Axis.horizontal)); + await tester.enterText(find.byKey(textField1), '1'); // textfield1 is visible + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 50.0); + }); }