blob: 2c4a9edbc5576ca69efdde84d3bba28e28e17bd7 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/foundation/constants.dart';
import 'package:flutter_test/flutter_test.dart';
class _TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
_TestSliverPersistentHeaderDelegate({
required this.minExtent,
required this.maxExtent,
required this.child,
this.vsync = const TestVSync(),
});
final Widget child;
@override
final double maxExtent;
@override
final double minExtent;
@override
final TickerProvider? vsync;
@override
final PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration = const PersistentHeaderShowOnScreenConfiguration();
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
@override
bool shouldRebuild(_TestSliverPersistentHeaderDelegate oldDelegate) => true;
}
void main() {
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
final FocusNode focusNode = FocusNode();
testWidgets('tapping on a partly visible editable brings it fully on screen', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
const SizedBox(
height: 350.0,
),
],
),
),
),
));
// Scroll the EditableText half off screen.
final RenderBox render = tester.renderObject(find.byType(EditableText));
scrollController.jumpTo(render.size.height / 2);
await tester.pumpAndSettle();
expect(scrollController.offset, render.size.height / 2);
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
});
testWidgets('tapping on a partly visible editable brings it fully on screen with scrollInsets', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
const SizedBox(
height: 200.0,
),
EditableText(
backgroundCursorColor: Colors.grey,
scrollPadding: const EdgeInsets.all(50.0),
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
const SizedBox(
height: 850.0,
),
],
),
),
),
));
// Scroll the EditableText half off screen.
final RenderBox render = tester.renderObject(find.byType(EditableText));
scrollController.jumpTo(200 + render.size.height / 2);
await tester.pumpAndSettle();
expect(scrollController.offset, 200 + render.size.height / 2);
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
// Container above the text is 200 in height, the scrollInsets are 50
// Tolerance of 5 units (The actual value was 152.0 in the current tests instead of 150.0)
expect(scrollController.offset, lessThan(200.0 - 50.0 + 5.0));
expect(scrollController.offset, greaterThan(200.0 - 50.0 - 5.0));
});
testWidgets('editable comes back on screen when entering text while it is off-screen', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController(initialScrollOffset: 100.0);
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
const SizedBox(
height: 350.0,
),
EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
const SizedBox(
height: 350.0,
),
],
),
),
),
));
// Focus the EditableText and scroll it off screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(find.byType(EditableText), findsNothing);
// Entering text brings it back on screen.
tester.testTextInput.enterText('Hello');
await tester.pumpAndSettle();
expect(scrollController.offset, greaterThan(0.0));
expect(find.byType(EditableText), findsOneWidget);
});
testWidgets('entering text does not scroll when scrollPhysics.allowImplicitScrolling = false', (WidgetTester tester) async {
// regression test for https://github.com/flutter/flutter/issues/19523
final ScrollController scrollController = ScrollController(initialScrollOffset: 100.0);
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
physics: const NoImplicitScrollPhysics(),
controller: scrollController,
children: <Widget>[
const SizedBox(
height: 350.0,
),
EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
const SizedBox(
height: 350.0,
),
],
),
),
),
));
// Focus the EditableText and scroll it off screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(find.byType(EditableText), findsNothing);
// Entering text brings it not back on screen.
tester.testTextInput.enterText('Hello');
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(find.byType(EditableText), findsNothing);
});
testWidgets('entering text does not scroll a surrounding PageView', (WidgetTester tester) async {
// regression test for https://github.com/flutter/flutter/issues/19523
final TextEditingController textController = TextEditingController();
final PageController pageController = PageController(initialPage: 1);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Material(
child: PageView(
controller: pageController,
children: <Widget>[
Container(
color: Colors.red,
),
ColoredBox(
color: Colors.green,
child: TextField(
controller: textController,
),
),
Container(
color: Colors.red,
),
],
),
),
),
),
),
);
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(textController.text, '');
tester.testTextInput.enterText('H');
final int frames = await tester.pumpAndSettle();
// The text input should not trigger any animations, which would indicate
// that the surrounding PageView is incorrectly scrolling back-and-forth.
expect(frames, 1);
expect(textController.text, 'H');
});
testWidgets('focused multi-line editable scrolls caret back into view when typing', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
controller.text = "Start${'\n' * 39}End";
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
EditableText(
backgroundCursorColor: Colors.grey,
maxLines: null, // multiline
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
],
),
),
),
));
// Bring keyboard up and verify that end of EditableText is not on screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
final RenderBox render = tester.renderObject(find.byType(EditableText));
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, 0.0);
// Enter text at end, which is off-screen.
final String textToEnter = '${controller.text} HELLO';
tester.testTextInput.updateEditingValue(TextEditingValue(
text: textToEnter,
selection: TextSelection.collapsed(offset: textToEnter.length),
));
await tester.pumpAndSettle();
// Caret scrolls into view.
expect(find.byType(EditableText), findsOneWidget);
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, greaterThan(0.0));
});
testWidgets('focused multi-line editable does not scroll to old position when non-collapsed selection set', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
final String text = "Start${'\n' * 39}End";
controller.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length - 3));
await tester.pumpWidget(MaterialApp(
home: Center(
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
EditableText(
backgroundCursorColor: Colors.grey,
maxLines: null, // multiline
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
],
),
),
),
));
// Bring keyboard up and verify that end of EditableText is not on screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
final RenderBox render = tester.renderObject(find.byType(EditableText));
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, 0.0);
// Change selection to non-collapased so that cursor isn't shown
// and the location requires a bit of scroll.
tester.testTextInput.updateEditingValue(TextEditingValue(
text: text,
selection: const TextSelection(baseOffset: 26, extentOffset: 27),
));
await tester.pumpAndSettle();
// Selection extent scrolls into view.
expect(find.byType(EditableText), findsOneWidget);
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, 28.0);
});
testWidgets('scrolls into view with scrollInserts after the keyboard pops up', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
const Key container = Key('container');
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 300.0,
child: ListView(
controller: scrollController,
children: <Widget>[
const SizedBox(
key: container,
height: 200.0,
),
EditableText(
backgroundCursorColor: Colors.grey,
scrollPadding: const EdgeInsets.only(bottom: 300.0),
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
const SizedBox(
height: 400.0,
),
],
),
),
),
));
expect(scrollController.offset, 0.0);
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(scrollController.offset, greaterThan(0.0));
expect(find.byKey(container), findsNothing);
});
testWidgets(
'A pinned persistent header should not scroll when its descendant EditableText gains focus',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/25507.
ScrollController controller;
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
const Key headerKey = Key('header');
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
height: 600.0,
width: 600.0,
child: CustomScrollView(
controller: controller = ScrollController(),
slivers: List<Widget>.generate(50, (int i) {
return i == 10
? SliverPersistentHeader(
pinned: true,
delegate: _TestSliverPersistentHeaderDelegate(
minExtent: 50,
maxExtent: 50,
child: Container(
alignment: Alignment.topCenter,
child: EditableText(
key: headerKey,
backgroundCursorColor: Colors.grey,
controller: textEditingController,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
)
: SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: Text('Tile $i'),
),
);
}),
),
),
),
),
);
// The persistent header should now be pinned at the top.
controller.jumpTo(100.0 * 15);
await tester.pumpAndSettle();
expect(controller.offset, 100.0 * 15);
focusNode.requestFocus();
await tester.pumpAndSettle();
// The scroll offset should remain the same.
expect(controller.offset, 100.0 * 15);
},
);
testWidgets(
'A pinned persistent header should not scroll when its descendant EditableText gains focus (no animation)',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/25507.
ScrollController controller;
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
const Key headerKey = Key('header');
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
height: 600.0,
width: 600.0,
child: CustomScrollView(
controller: controller = ScrollController(),
slivers: List<Widget>.generate(50, (int i) {
return i == 10
? SliverPersistentHeader(
pinned: true,
delegate: _TestSliverPersistentHeaderDelegate(
minExtent: 50,
maxExtent: 50,
vsync: null,
child: Container(
alignment: Alignment.topCenter,
child: EditableText(
key: headerKey,
backgroundCursorColor: Colors.grey,
controller: textEditingController,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
)
: SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: Text('Tile $i'),
),
);
}),
),
),
),
),
);
// The persistent header should now be pinned at the top.
controller.jumpTo(100.0 * 15);
await tester.pumpAndSettle();
expect(controller.offset, 100.0 * 15);
focusNode.requestFocus();
await tester.pumpAndSettle();
// The scroll offset should remain the same.
expect(controller.offset, 100.0 * 15);
},
);
void testShowCaretOnScreen({ required bool readOnly }) {
group('EditableText._showCaretOnScreen, readOnly=$readOnly', () {
final TextEditingController textEditingController = TextEditingController();
final TextInputFormatter rejectEverythingFormatter = TextInputFormatter.withFunction((TextEditingValue old, TextEditingValue value) => old);
bool isCaretOnScreen(WidgetTester tester) {
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
final RenderEditable renderEditable = state.renderEditable;
final Rect localRect = renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base);
final Offset caretOrigin = renderEditable.localToGlobal(localRect.topLeft);
final Rect caretRect = caretOrigin & localRect.size;
return const Rect.fromLTWH(0, 0, 800, 600).intersect(caretRect) == caretRect;
}
Widget buildEditableText({
required bool rejectUserInputs,
ScrollController? scrollController,
ScrollController? editableScrollController,
}) {
return MaterialApp(
home: Scaffold(
body: ListView(
controller: scrollController,
cacheExtent: 1000,
children: <Widget>[
// The text field is not fully visible.
const SizedBox(height: 599),
EditableText(
backgroundCursorColor: Colors.grey,
controller: textEditingController,
scrollController: editableScrollController,
inputFormatters: <TextInputFormatter>[if (rejectUserInputs) rejectEverythingFormatter],
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
readOnly: readOnly,
),
],
),
),
);
}
testWidgets('focus-triggered showCaretOnScreen', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 100);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
if (kIsWeb) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
// On web, the entire field is selected, and only part of that selection
// is visible on the screen.
expect(isCaretOnScreen(tester), !readOnly && !kIsWeb);
expect(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
});
testWidgets('selection-triggered showCaretOnScreen: virtual keyboard', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 80);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Change the selection. Show caret on screen when readyOnly is true,
// as a read-only text field rejects everything from the software
// keyboard (except for web).
state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
// Reject user input.
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: true,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
});
testWidgets('selection-triggered showCaretOnScreen: text selection delegate', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 80);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Change the selection. Show caret on screen even when readyOnly is
// false.
state.userUpdateTextEditingValue(
state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)),
null,
);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isTrue);
expect(scrollController.offset, greaterThan(0.0));
expect(editableScrollController.offset, greaterThan(0.0));
// Rejects user input.
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: true,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
state.userUpdateTextEditingValue(
state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)),
null,
);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isTrue);
expect(scrollController.offset, greaterThan(0.0));
expect(editableScrollController.offset, greaterThan(0.0));
});
// Regression text for https://github.com/flutter/flutter/pull/74722.
testWidgets('does NOT randomly trigger when cursor blinks', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 0);
final ScrollController editableScrollController = ScrollController();
final bool deterministicCursor = EditableText.debugDeterministicCursor;
EditableText.debugDeterministicCursor = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EditableText(
backgroundCursorColor: Colors.grey,
controller: textEditingController,
scrollController: editableScrollController,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Ensure the text was initially visible.
expect(isCaretOnScreen(tester), true);
expect(editableScrollController.offset, 0.0);
// Change the text but keep the cursor location.
state.updateEditingValue(textEditingController.value.copyWith(
text: 'a' * 101,
));
await tester.pumpAndSettle();
// The caret should stay where it was, since the selection didn't change.
expect(isCaretOnScreen(tester), true);
expect(editableScrollController.offset, 0.0);
// Now move to hide the cursor.
editableScrollController.jumpTo(100.0);
// Does not trigger showCaretOnScreen.
await tester.pump();
await tester.pumpAndSettle();
expect(editableScrollController.offset, 100.0);
expect(isCaretOnScreen(tester), isFalse);
EditableText.debugDeterministicCursor = deterministicCursor;
});
});
}
testShowCaretOnScreen(readOnly: true);
testShowCaretOnScreen(readOnly: false);
}
class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics {
const NoImplicitScrollPhysics({ super.parent });
@override
bool get allowImplicitScrolling => false;
@override
NoImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) {
return NoImplicitScrollPhysics(parent: buildParent(ancestor));
}
}