Update TextSelectionOverlay (#142463)

Fixes a bug where changing parameters in EditableText that affect the selection overlay didn't update the overlay.
This commit is contained in:
Justin McCandless 2024-02-02 13:35:11 -08:00 committed by GitHub
parent 102f6394d6
commit f1eeda7415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 374 additions and 13 deletions

View file

@ -23,8 +23,8 @@ const double _kSelectionHandleRadius = 6;
const double _kArrowScreenPadding = 26.0;
/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
const _TextSelectionHandlePainter(this.color);
class _CupertinoTextSelectionHandlePainter extends CustomPainter {
const _CupertinoTextSelectionHandlePainter(this.color);
final Color color;
@ -51,7 +51,7 @@ class _TextSelectionHandlePainter extends CustomPainter {
}
@override
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
bool shouldRepaint(_CupertinoTextSelectionHandlePainter oldPainter) => color != oldPainter.color;
}
/// iOS Cupertino styled text selection handle controls.
@ -116,7 +116,7 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
final Widget handle;
final Widget customPaint = CustomPaint(
painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
painter: _CupertinoTextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
);
// [buildHandle]'s widget is positioned at the selection cursor's bottom

View file

@ -2928,7 +2928,28 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
widget.controller.addListener(_didChangeTextEditingValue);
_updateRemoteEditingValueIfNeeded();
}
if (widget.controller.selection != oldWidget.controller.selection) {
if (_selectionOverlay != null
&& (widget.contextMenuBuilder != oldWidget.contextMenuBuilder ||
widget.selectionControls != oldWidget.selectionControls ||
widget.onSelectionHandleTapped != oldWidget.onSelectionHandleTapped ||
widget.dragStartBehavior != oldWidget.dragStartBehavior ||
widget.magnifierConfiguration != oldWidget.magnifierConfiguration)) {
final bool shouldShowToolbar = _selectionOverlay!.toolbarIsVisible;
final bool shouldShowHandles = _selectionOverlay!.handlesVisible;
_selectionOverlay!.dispose();
_selectionOverlay = _createSelectionOverlay();
if (shouldShowToolbar || shouldShowHandles) {
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
if (shouldShowToolbar) {
_selectionOverlay!.showToolbar();
}
if (shouldShowHandles) {
_selectionOverlay!.showHandles();
}
});
}
} else if (widget.controller.selection != oldWidget.controller.selection) {
_selectionOverlay?.update(_value);
}
_selectionOverlay?.handlesVisible = widget.showSelectionHandles;
@ -4266,7 +4287,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (!widget.focusNode.hasFocus) {
_flagInternalFocus();
widget.focusNode.requestFocus();
_selectionOverlay = _createSelectionOverlay();
_selectionOverlay ??= _createSelectionOverlay();
}
return;
}

View file

@ -1509,13 +1509,7 @@ class SelectionOverlay {
/// {@endtemplate}
void hide() {
_magnifierController.hide();
if (_handles != null) {
_handles!.start.remove();
_handles!.start.dispose();
_handles!.end.remove();
_handles!.end.dispose();
_handles = null;
}
hideHandles();
if (_toolbar != null || _contextMenuController.isShown || _spellCheckToolbarController.isShown) {
hideToolbar();
}

View file

@ -15030,6 +15030,352 @@ void main() {
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('contextMenuBuilder can be updated to display a new menu', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/142077.
late StateSetter setState;
final GlobalKey keyOne = GlobalKey();
final GlobalKey keyTwo = GlobalKey();
GlobalKey key = keyOne;
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter localSetState) {
setState = localSetState;
return EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: focusNode,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return SizedBox(
key: key,
width: 10.0,
height: 10.0,
);
},
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(keyOne), findsNothing);
expect(find.byKey(keyTwo), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(keyOne), findsOneWidget);
expect(find.byKey(keyTwo), findsNothing);
setState(() {
key = keyTwo;
});
await tester.pumpAndSettle();
expect(find.byKey(keyOne), findsNothing);
expect(find.byKey(keyTwo), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('selectionControls can be updated', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/142077.
controller.text = 'test';
late StateSetter setState;
TextSelectionControls selectionControls = materialTextSelectionControls;
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter localSetState) {
setState = localSetState;
return EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: focusNode,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: selectionControls,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
final Finder materialHandleFinder = find.byWidgetPredicate((Widget widget) {
if (widget.runtimeType != CustomPaint) {
return false;
}
final CustomPaint customPaint = widget as CustomPaint;
return '${customPaint.painter.runtimeType}' == '_TextSelectionHandlePainter';
});
final Finder cupertinoHandleFinder = find.byWidgetPredicate((Widget widget) {
if (widget.runtimeType != CustomPaint) {
return false;
}
final CustomPaint customPaint = widget as CustomPaint;
return '${customPaint.painter.runtimeType}' == '_CupertinoTextSelectionHandlePainter';
});
expect(materialHandleFinder, findsOneWidget);
expect(cupertinoHandleFinder, findsNothing);
// Long-press to select the text because Cupertino doesn't show a selection
// handle when the selection is collapsed.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(materialHandleFinder, findsNWidgets(2));
expect(cupertinoHandleFinder, findsNothing);
setState(() {
selectionControls = cupertinoTextSelectionControls;
});
await tester.pumpAndSettle();
expect(materialHandleFinder, findsNothing);
expect(cupertinoHandleFinder, findsNWidgets(2));
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('onSelectionHandleTapped can be updated', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/142077.
late StateSetter setState;
int tapCount = 0;
VoidCallback? onSelectionHandleTapped;
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter localSetState) {
setState = localSetState;
return EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: focusNode,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: materialTextSelectionControls,
onSelectionHandleTapped: onSelectionHandleTapped,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
final Finder materialHandleFinder = find.byWidgetPredicate((Widget widget) {
if (widget.runtimeType != CustomPaint) {
return false;
}
final CustomPaint customPaint = widget as CustomPaint;
return '${customPaint.painter.runtimeType}' == '_TextSelectionHandlePainter';
});
expect(materialHandleFinder, findsOneWidget);
expect(tapCount, equals(0));
await tester.tap(materialHandleFinder);
await tester.pump();
expect(tapCount, equals(0));
setState(() {
onSelectionHandleTapped = () => tapCount += 1;
});
await tester.pumpAndSettle();
await tester.tap(materialHandleFinder);
await tester.pump();
expect(tapCount, equals(1));
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('dragStartBehavior can be updated', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/142077.
late StateSetter setState;
DragStartBehavior dragStartBehavior = DragStartBehavior.down;
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter localSetState) {
setState = localSetState;
return EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: focusNode,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: materialTextSelectionControls,
dragStartBehavior: dragStartBehavior,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
final Finder handleOverlayFinder = find.descendant(
of: find.byType(Overlay),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'),
);
expect(handleOverlayFinder, findsOneWidget);
// Expects that the selection handle has the given DragStartBehavior.
void checkDragStartBehavior(DragStartBehavior dragStartBehavior) {
final RawGestureDetector rawGestureDetector = tester.widget(find.descendant(
of: handleOverlayFinder,
matching: find.byType(RawGestureDetector)
).first);
final GestureRecognizerFactory<GestureRecognizer>? recognizerFactory = rawGestureDetector.gestures[PanGestureRecognizer];
final PanGestureRecognizer recognizer = PanGestureRecognizer();
recognizerFactory?.initializer(recognizer);
expect(recognizer.dragStartBehavior, dragStartBehavior);
recognizer.dispose();
}
checkDragStartBehavior(DragStartBehavior.down);
setState(() {
dragStartBehavior = DragStartBehavior.start;
});
await tester.pumpAndSettle();
expect(handleOverlayFinder, findsOneWidget);
checkDragStartBehavior(DragStartBehavior.start);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('magnifierConfiguration can be updated to display a new magnifier', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/142077.
late StateSetter setState;
final GlobalKey keyOne = GlobalKey();
final GlobalKey keyTwo = GlobalKey();
GlobalKey key = keyOne;
final TextMagnifierConfiguration magnifierConfiguration = TextMagnifierConfiguration(
magnifierBuilder: (BuildContext context, MagnifierController controller, ValueNotifier<MagnifierInfo>? info) {
return Placeholder(
key: key,
);
},
);
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter localSetState) {
setState = localSetState;
return EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: focusNode,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: materialTextSelectionHandleControls,
magnifierConfiguration: magnifierConfiguration,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
void checkMagnifierKey(Key testKey) {
final EditableText editableText = tester.widget(find.byType(EditableText));
final BuildContext context = tester.firstElement(find.byType(EditableText));
final ValueNotifier<MagnifierInfo> magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
magnifierInfo,
),
isA<Widget>().having(
(Widget widget) => widget.key,
'built magnifier key equal to passed in magnifier key',
equals(testKey),
),
);
}
checkMagnifierKey(keyOne);
setState(() {
key = keyTwo;
});
await tester.pumpAndSettle();
checkMagnifierKey(keyTwo);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
group('Spell check', () {
testWidgets(
'Spell check configured properly when spell check disabled by default',