flutter_flutter/packages/flutter/test/widgets/editable_text_utils.dart
Renzo Olivares 478367652c
Add TestTextField and migrate tests (#180494)
This PR introduces the convenience widget `TestTextField` in the
framework widget tests. This new widget should be used in place of
convenient `TextField` or `CupertinoTextField` usages in the widgets
library tests.

part of: #177415

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

---------

Co-authored-by: Renzo Olivares <roliv@google.com>
2026-01-29 17:21:52 +00:00

299 lines
9.5 KiB
Dart

// 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/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// On web, the context menu (aka toolbar) is provided by the browser.
const bool isContextMenuProvidedByPlatform = isBrowser;
// Returns the RenderEditable at the given index, or the first if not given.
RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) {
final RenderObject root = tester.renderObject(find.byType(EditableText).at(index));
expect(root, isNotNull);
late RenderEditable renderEditable;
void recursiveFinder(RenderObject child) {
if (child is RenderEditable) {
renderEditable = child;
return;
}
child.visitChildren(recursiveFinder);
}
root.visitChildren(recursiveFinder);
expect(renderEditable, isNotNull);
return renderEditable;
}
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(box.localToGlobal(point.point), point.direction);
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) {
final RenderEditable renderEditable = findRenderEditable(tester, index: index);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(TextSelection.collapsed(offset: offset)),
renderEditable,
);
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0);
}
/// Mimic key press events by sending key down and key up events via the [tester].
Future<void> sendKeys(
WidgetTester tester,
List<LogicalKeyboardKey> keys, {
bool shift = false,
bool wordModifier = false,
bool lineModifier = false,
bool shortcutModifier = false,
required TargetPlatform targetPlatform,
}) async {
final targetPlatformString = targetPlatform.toString();
final String platform = targetPlatformString
.substring(targetPlatformString.indexOf('.') + 1)
.toLowerCase();
if (shift) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shortcutModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.altLeft
: LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (lineModifier) {
await tester.sendKeyDownEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.altLeft,
platform: platform,
);
}
for (final key in keys) {
await tester.sendKeyEvent(key, platform: platform);
await tester.pump();
}
if (lineModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.altLeft,
platform: platform,
);
}
if (wordModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.altLeft
: LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shortcutModifier) {
await tester.sendKeyUpEvent(
platform == 'macos' || platform == 'ios'
? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.controlLeft,
platform: platform,
);
}
if (shift) {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
}
if (shift || wordModifier || lineModifier) {
await tester.pump();
}
}
// Simple controller that builds a WidgetSpan with 100 height.
class OverflowWidgetTextEditingController extends TextEditingController {
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
return TextSpan(
style: style,
children: <InlineSpan>[
const TextSpan(text: 'Hi'),
WidgetSpan(child: Container(color: const Color(0xffff0000), height: 100.0)),
],
);
}
}
/// A minimal wrapper around [EditableText] for use in widget tests.
///
/// This widget provides robust text selection gestures through
/// [TextSelectionGestureDetector].
///
/// This widget does not provide text selection context menus out of the box.
/// They can be added by providing [contextMenuBuilder].
///
/// This widget does not provide a concrete implementation of text selection handles
/// out of the box, and instead uses [testTextSelectionHandleControls] as a default.
/// A more robust implementation of text selection handles can be provided by
/// setting [selectionControls].
///
/// This input field manages its own internal [TextEditingController]
/// and [FocusNode] unless provided one. This field also provides defaults
/// for required [EditableText] members [EditableText.cursorColor],
/// [EditableText.backgroundCursorColor], and [EditableText.style].
class TestTextField extends StatefulWidget {
const TestTextField({
super.key,
this.autofillHints = const <String>[],
this.autofocus = false,
this.contextMenuBuilder,
this.cursorColor,
this.cursorOpacityAnimates = false,
this.focusNode,
this.groupId = EditableText,
this.maxLines = 1,
this.onChanged,
this.readOnly = false,
this.selectionControls,
this.showCursor,
this.style,
this.controller,
});
final Iterable<String>? autofillHints;
final bool autofocus;
final EditableTextContextMenuBuilder? contextMenuBuilder;
final Color? cursorColor;
final bool cursorOpacityAnimates;
final FocusNode? focusNode;
final Object groupId;
final int? maxLines;
final ValueChanged<String>? onChanged;
final bool readOnly;
final TextSelectionControls? selectionControls;
final bool? showCursor;
final TextStyle? style;
final TextEditingController? controller;
@override
State<TestTextField> createState() => _TestTextFieldState();
}
class _TestTextFieldState extends State<TestTextField>
implements TextSelectionGestureDetectorBuilderDelegate {
TextEditingController? _controller;
TextEditingController get _effectiveController =>
widget.controller ?? (_controller ??= TextEditingController());
FocusNode? _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
late _TestTextFieldSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
// API for TextSelectionGestureDetectorBuilderDelegate.
@override
bool get forcePressEnabled => false;
@override
final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
@override
bool get selectionEnabled => true;
// End of API for TextSelectionGestureDetectorBuilderDelegate.
@override
void initState() {
super.initState();
_selectionGestureDetectorBuilder = _TestTextFieldSelectionGestureDetectorBuilder(state: this);
}
@override
void dispose() {
_focusNode?.dispose();
_controller?.dispose();
super.dispose();
}
static const Color _red = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
@override
Widget build(BuildContext context) {
return _selectionGestureDetectorBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: EditableText(
key: editableTextKey,
autofillHints: widget.autofillHints,
autofocus: widget.autofocus,
backgroundCursorColor: _red, // Colors.red, required by editable text.
contextMenuBuilder: widget.contextMenuBuilder,
cursorColor: widget.cursorColor ?? _red, // Colors.red, required by editable text.
cursorOpacityAnimates: widget.cursorOpacityAnimates,
focusNode: _effectiveFocusNode, // required by editable text.
groupId: widget.groupId,
maxLines: widget.maxLines,
onChanged: widget.onChanged,
readOnly: widget.readOnly,
rendererIgnoresPointer: true, // gestures are provided by text selection gesture detector.
selectionControls: widget.selectionControls ?? testTextSelectionHandleControls,
showCursor: widget.showCursor,
style: widget.style ?? const TextStyle(), // required by editable text.
controller: _effectiveController, // required by editable text.
),
);
}
}
class _TestTextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
_TestTextFieldSelectionGestureDetectorBuilder({required _TestTextFieldState state})
: super(delegate: state);
@override
void onUserTap() {
// BasicTestTextField does not have an onTap callback.
}
@override
bool get onUserTapAlwaysCalled => false;
}
class TestTextSelectionHandleControls extends TextSelectionControls
with TextSelectionHandleControls {
@override
Widget buildHandle(
BuildContext context,
TextSelectionHandleType type,
double textLineHeight, [
VoidCallback? onTap,
]) {
return const SizedBox.shrink();
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
return Offset.zero;
}
@override
Size getHandleSize(double textLineHeight) {
return Size.zero;
}
}
final TextSelectionControls testTextSelectionHandleControls = TestTextSelectionHandleControls();