Skip to content

Commit 24db45e

Browse files
Disable backspace/delete handling on iOS & macOS (flutter#115900)
* Disable backspace/delete handling on iOS * fix tests * review * macOS too * review
1 parent e669683 commit 24db45e

File tree

4 files changed

+188
-80
lines changed

4 files changed

+188
-80
lines changed

packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import 'text_editing_intents.dart';
1919
/// lower in the widget tree than this. See the [Action] class for an example
2020
/// of remapping an [Intent] to a custom [Action].
2121
///
22+
/// The [Shortcuts] widget usually takes precedence over system keybindings.
23+
/// Proceed with caution if the shortcut you wish to override is also used by
24+
/// the system. For example, overriding [LogicalKeyboardKey.backspace] could
25+
/// cause CJK input methods to discard more text than they should when the
26+
/// backspace key is pressed during text composition on iOS.
27+
///
2228
/// {@tool snippet}
2329
///
2430
/// This example shows how to use an additional [Shortcuts] widget to override
@@ -440,13 +446,28 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
440446

441447
static final Map<ShortcutActivator, Intent> _macDisablingTextShortcuts = <ShortcutActivator, Intent>{
442448
..._commonDisablingTextShortcuts,
449+
..._iOSDisablingTextShortcuts,
443450
const SingleActivator(LogicalKeyboardKey.escape): const DoNothingAndStopPropagationTextIntent(),
444451
const SingleActivator(LogicalKeyboardKey.tab): const DoNothingAndStopPropagationTextIntent(),
445452
const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingAndStopPropagationTextIntent(),
446453
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const DoNothingAndStopPropagationTextIntent(),
447454
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const DoNothingAndStopPropagationTextIntent(),
448455
};
449456

457+
// Hand backspace/delete events that do not depend on text layout (delete
458+
// character and delete to the next word) back to the IME to allow it to
459+
// update composing text properly.
460+
static const Map<ShortcutActivator, Intent> _iOSDisablingTextShortcuts = <ShortcutActivator, Intent>{
461+
SingleActivator(LogicalKeyboardKey.backspace): DoNothingAndStopPropagationTextIntent(),
462+
SingleActivator(LogicalKeyboardKey.backspace, shift: true): DoNothingAndStopPropagationTextIntent(),
463+
SingleActivator(LogicalKeyboardKey.delete): DoNothingAndStopPropagationTextIntent(),
464+
SingleActivator(LogicalKeyboardKey.delete, shift: true): DoNothingAndStopPropagationTextIntent(),
465+
SingleActivator(LogicalKeyboardKey.backspace, alt: true, shift: true): DoNothingAndStopPropagationTextIntent(),
466+
SingleActivator(LogicalKeyboardKey.backspace, alt: true): DoNothingAndStopPropagationTextIntent(),
467+
SingleActivator(LogicalKeyboardKey.delete, alt: true, shift: true): DoNothingAndStopPropagationTextIntent(),
468+
SingleActivator(LogicalKeyboardKey.delete, alt: true): DoNothingAndStopPropagationTextIntent(),
469+
};
470+
450471
static Map<ShortcutActivator, Intent> get _shortcuts {
451472
switch (defaultTargetPlatform) {
452473
case TargetPlatform.android:
@@ -469,13 +490,13 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
469490
return _webDisablingTextShortcuts;
470491
}
471492
switch (defaultTargetPlatform) {
472-
473493
case TargetPlatform.android:
474494
case TargetPlatform.fuchsia:
475-
case TargetPlatform.iOS:
476495
case TargetPlatform.linux:
477496
case TargetPlatform.windows:
478497
return null;
498+
case TargetPlatform.iOS:
499+
return _iOSDisablingTextShortcuts;
479500
case TargetPlatform.macOS:
480501
return _macDisablingTextShortcuts;
481502
}

packages/flutter/test/widgets/default_text_editing_shortcuts_test.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,87 @@ void main() {
4444
),
4545
);
4646
}
47+
48+
group('iOS: do not delete/backspace events', () {
49+
final TargetPlatformVariant iOS = TargetPlatformVariant.only(TargetPlatform.iOS);
50+
final FocusNode editable = FocusNode();
51+
final FocusNode spy = FocusNode();
52+
53+
testWidgets('backspace with and without word modifier', (WidgetTester tester) async {
54+
tester.binding.testTextInput.unregister();
55+
addTearDown(tester.binding.testTextInput.register);
56+
57+
await tester.pumpWidget(
58+
buildSpyAboveEditableText(
59+
editableFocusNode: editable,
60+
spyFocusNode: spy,
61+
),
62+
);
63+
editable.requestFocus();
64+
await tester.pump();
65+
final ActionSpyState state = tester.state<ActionSpyState>(find.byType(ActionSpy));
66+
67+
for (int altShiftState = 0; altShiftState < 1 << 2; altShiftState += 1) {
68+
final bool alt = altShiftState & 0x1 != 0;
69+
final bool shift = altShiftState & 0x2 != 0;
70+
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.backspace, alt: alt, shift: shift));
71+
}
72+
await tester.pump();
73+
74+
expect(state.lastIntent, isNull);
75+
}, variant: iOS);
76+
77+
testWidgets('delete with and without word modifier', (WidgetTester tester) async {
78+
tester.binding.testTextInput.unregister();
79+
addTearDown(tester.binding.testTextInput.register);
80+
81+
await tester.pumpWidget(
82+
buildSpyAboveEditableText(
83+
editableFocusNode: editable,
84+
spyFocusNode: spy,
85+
),
86+
);
87+
editable.requestFocus();
88+
await tester.pump();
89+
final ActionSpyState state = tester.state<ActionSpyState>(find.byType(ActionSpy));
90+
91+
for (int altShiftState = 0; altShiftState < 1 << 2; altShiftState += 1) {
92+
final bool alt = altShiftState & 0x1 != 0;
93+
final bool shift = altShiftState & 0x2 != 0;
94+
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.delete, alt: alt, shift: shift));
95+
}
96+
await tester.pump();
97+
98+
expect(state.lastIntent, isNull);
99+
}, variant: iOS);
100+
101+
testWidgets('Exception: deleting to line boundary is handled by the framework', (WidgetTester tester) async {
102+
tester.binding.testTextInput.unregister();
103+
addTearDown(tester.binding.testTextInput.register);
104+
105+
await tester.pumpWidget(
106+
buildSpyAboveEditableText(
107+
editableFocusNode: editable,
108+
spyFocusNode: spy,
109+
),
110+
);
111+
editable.requestFocus();
112+
await tester.pump();
113+
final ActionSpyState state = tester.state<ActionSpyState>(find.byType(ActionSpy));
114+
115+
for (int keyState = 0; keyState < 1 << 2; keyState += 1) {
116+
final bool shift = keyState & 0x1 != 0;
117+
final LogicalKeyboardKey key = keyState & 0x2 != 0 ? LogicalKeyboardKey.delete : LogicalKeyboardKey.backspace;
118+
119+
state.lastIntent = null;
120+
final SingleActivator activator = SingleActivator(key, meta: true, shift: shift);
121+
await sendKeyCombination(tester, activator);
122+
await tester.pump();
123+
expect(state.lastIntent, isA<DeleteToLineBreakIntent>(), reason: '$activator');
124+
}
125+
}, variant: iOS);
126+
}, skip: kIsWeb); // [intended] specific tests target non-web.
127+
47128
group('macOS does not accept shortcuts if focus under EditableText', () {
48129
final TargetPlatformVariant macOSOnly = TargetPlatformVariant.only(TargetPlatform.macOS);
49130

@@ -400,6 +481,10 @@ class ActionSpyState extends State<ActionSpy> {
400481
ExtendSelectionVerticallyToAdjacentLineIntent: CallbackAction<ExtendSelectionVerticallyToAdjacentLineIntent>(onInvoke: _captureIntent),
401482
ExtendSelectionToDocumentBoundaryIntent: CallbackAction<ExtendSelectionToDocumentBoundaryIntent>(onInvoke: _captureIntent),
402483
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: CallbackAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(onInvoke: _captureIntent),
484+
485+
DeleteToLineBreakIntent: CallbackAction<DeleteToLineBreakIntent>(onInvoke: _captureIntent),
486+
DeleteToNextWordBoundaryIntent: CallbackAction<DeleteToNextWordBoundaryIntent>(onInvoke: _captureIntent),
487+
DeleteCharacterIntent: CallbackAction<DeleteCharacterIntent>(onInvoke: _captureIntent),
403488
};
404489

405490
// ignore: use_setters_to_change_properties

packages/flutter/test/widgets/editable_text_shortcuts_test.dart

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ void main() {
150150
controller.selection,
151151
const TextSelection.collapsed(offset: 19),
152152
);
153-
}, variant: TargetPlatformVariant.all());
153+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
154154

155155
testWidgets('backspace readonly', (WidgetTester tester) async {
156156
controller.text = testText;
@@ -215,7 +215,7 @@ void main() {
215215
controller.selection,
216216
const TextSelection.collapsed(offset: 71),
217217
);
218-
}, variant: TargetPlatformVariant.all());
218+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
219219

220220
testWidgets('backspace inside of a cluster', (WidgetTester tester) async {
221221
controller.text = testCluster;
@@ -236,7 +236,7 @@ void main() {
236236
controller.selection,
237237
const TextSelection.collapsed(offset: 0),
238238
);
239-
}, variant: TargetPlatformVariant.all());
239+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
240240

241241
testWidgets('backspace at cluster boundary', (WidgetTester tester) async {
242242
controller.text = testCluster;
@@ -257,7 +257,7 @@ void main() {
257257
controller.selection,
258258
const TextSelection.collapsed(offset: 0),
259259
);
260-
}, variant: TargetPlatformVariant.all());
260+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
261261
});
262262

263263
group('delete: ', () {
@@ -287,7 +287,7 @@ void main() {
287287
controller.selection,
288288
const TextSelection.collapsed(offset: 20),
289289
);
290-
}, variant: TargetPlatformVariant.all());
290+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
291291

292292
testWidgets('delete readonly', (WidgetTester tester) async {
293293
controller.text = testText;
@@ -305,7 +305,7 @@ void main() {
305305
controller.selection,
306306
const TextSelection.collapsed(offset: 20, affinity: TextAffinity.upstream),
307307
);
308-
}, variant: TargetPlatformVariant.all());
308+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
309309

310310
testWidgets('delete at start', (WidgetTester tester) async {
311311
controller.text = testText;
@@ -328,7 +328,7 @@ void main() {
328328
controller.selection,
329329
const TextSelection.collapsed(offset: 0),
330330
);
331-
}, variant: TargetPlatformVariant.all());
331+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
332332

333333
testWidgets('delete at end', (WidgetTester tester) async {
334334
controller.text = testText;
@@ -373,7 +373,7 @@ void main() {
373373
controller.selection,
374374
const TextSelection.collapsed(offset: 0),
375375
);
376-
}, variant: TargetPlatformVariant.all());
376+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
377377

378378
testWidgets('delete at cluster boundary', (WidgetTester tester) async {
379379
controller.text = testCluster;
@@ -394,7 +394,7 @@ void main() {
394394
controller.selection,
395395
const TextSelection.collapsed(offset: 8),
396396
);
397-
}, variant: TargetPlatformVariant.all());
397+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
398398
});
399399

400400
group('Non-collapsed delete', () {
@@ -420,7 +420,7 @@ void main() {
420420
controller.selection,
421421
const TextSelection.collapsed(offset: 8),
422422
);
423-
}, variant: TargetPlatformVariant.all());
423+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
424424

425425
testWidgets('at the boundaries of a cluster', (WidgetTester tester) async {
426426
controller.text = testCluster;
@@ -441,7 +441,7 @@ void main() {
441441
controller.selection,
442442
const TextSelection.collapsed(offset: 8),
443443
);
444-
}, variant: TargetPlatformVariant.all());
444+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
445445

446446
testWidgets('cross-cluster', (WidgetTester tester) async {
447447
controller.text = testCluster;
@@ -462,7 +462,7 @@ void main() {
462462
controller.selection,
463463
const TextSelection.collapsed(offset: 0),
464464
);
465-
}, variant: TargetPlatformVariant.all());
465+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
466466

467467
testWidgets('cross-cluster obscured text', (WidgetTester tester) async {
468468
controller.text = testCluster;
@@ -483,7 +483,7 @@ void main() {
483483
controller.selection,
484484
const TextSelection.collapsed(offset: 1),
485485
);
486-
}, variant: TargetPlatformVariant.all());
486+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
487487
});
488488

489489
group('word modifier + backspace', () {
@@ -516,7 +516,7 @@ void main() {
516516
controller.selection,
517517
const TextSelection.collapsed(offset: 24),
518518
);
519-
}, variant: TargetPlatformVariant.all());
519+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
520520

521521
testWidgets('readonly', (WidgetTester tester) async {
522522
controller.text = testText;
@@ -581,7 +581,7 @@ void main() {
581581
controller.selection,
582582
const TextSelection.collapsed(offset: 71),
583583
);
584-
}, variant: TargetPlatformVariant.all());
584+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
585585

586586
testWidgets('inside of a cluster', (WidgetTester tester) async {
587587
controller.text = testCluster;
@@ -602,7 +602,7 @@ void main() {
602602
controller.selection,
603603
const TextSelection.collapsed(offset: 0),
604604
);
605-
}, variant: TargetPlatformVariant.all());
605+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
606606

607607
testWidgets('at cluster boundary', (WidgetTester tester) async {
608608
controller.text = testCluster;
@@ -623,7 +623,7 @@ void main() {
623623
controller.selection,
624624
const TextSelection.collapsed(offset: 0),
625625
);
626-
}, variant: TargetPlatformVariant.all());
626+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
627627
});
628628

629629
group('word modifier + delete', () {
@@ -656,7 +656,7 @@ void main() {
656656
controller.selection,
657657
const TextSelection.collapsed(offset: 23),
658658
);
659-
}, variant: TargetPlatformVariant.all());
659+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
660660

661661
testWidgets('readonly', (WidgetTester tester) async {
662662
controller.text = testText;
@@ -697,7 +697,7 @@ void main() {
697697
controller.selection,
698698
const TextSelection.collapsed(offset: 0),
699699
);
700-
}, variant: TargetPlatformVariant.all());
700+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
701701

702702
testWidgets('at end', (WidgetTester tester) async {
703703
controller.text = testText;
@@ -735,7 +735,7 @@ void main() {
735735
controller.selection,
736736
const TextSelection.collapsed(offset: 0),
737737
);
738-
}, variant: TargetPlatformVariant.all());
738+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
739739

740740
testWidgets('at cluster boundary', (WidgetTester tester) async {
741741
controller.text = testCluster;
@@ -756,7 +756,7 @@ void main() {
756756
controller.selection,
757757
const TextSelection.collapsed(offset: 8),
758758
);
759-
}, variant: TargetPlatformVariant.all());
759+
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
760760
});
761761

762762
group('line modifier + backspace', () {

0 commit comments

Comments
 (0)