From ef34436402beaab4822a7306d9ffca17ebbdd2e3 Mon Sep 17 00:00:00 2001 From: Yegor Date: Thu, 27 Jun 2024 07:38:32 -0700 Subject: [PATCH] add onFocus to text fields (#150648) Adds `onFocus` support to Cupertino and Material text field widgets (similar to https://github.com/flutter/flutter/pull/142942). --- .../flutter/lib/src/cupertino/text_field.dart | 29 +++ .../flutter/lib/src/material/text_field.dart | 29 +++ .../test/cupertino/text_field_test.dart | 158 ++++++++++++++++ .../test/material/text_field_test.dart | 179 ++++++++++++++++++ 4 files changed, 395 insertions(+) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index ec4d1694ceb..d769703bbd0 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -1445,6 +1445,35 @@ class _CupertinoTextFieldState extends State with Restoratio _requestKeyboard(); }, onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + onFocus: enabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.' + ); + + if (_effectiveFocusNode.canRequestFocus && !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, child: TextFieldTapRegion( child: IgnorePointer( ignoring: !enabled, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index c9f8dd112a8..13cfffd7976 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -1594,6 +1594,35 @@ class _TextFieldState extends State with RestorationMixin implements }, onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, onDidLoseAccessibilityFocus: handleDidLoseAccessibilityFocus, + onFocus: _isEnabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.' + ); + + if (_effectiveFocusNode.canRequestFocus && !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, child: child, ); }, diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 9a1f1e90898..cd12e34ace0 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -10280,4 +10280,162 @@ void main() { }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); + + testWidgets('when enabled listens to onFocus events and gains focus', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTextField(focusNode: focusNode), + ), + ); + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: [ + SemanticsAction.tap, + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS) + SemanticsAction.didGainAccessibilityFocus, + // TODO(gspencergoog): also test for the presence of SemanticsAction.focus when + // this iOS issue is addressed: https://github.com/flutter/flutter/issues/150030 + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + )); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when disabled does not listen to onFocus events or gain focus', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTextField(focusNode: focusNode, enabled: false), + ), + ); + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isReadOnly, + ], + actions: [ + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS) + SemanticsAction.didGainAccessibilityFocus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + )); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when receives SemanticsAction.focus while already focused, shows keyboard', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTextField(focusNode: focusNode), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log.single.method, 'TextInput.show'); + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when receives SemanticsAction.focus while focused but read-only, does not show keyboard', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTextField(focusNode: focusNode, readOnly: true), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log, isEmpty); + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index d16a53b8a29..17e0fc368d6 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -18350,6 +18350,185 @@ void main() { }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); + + + testWidgets('when enabled listens to onFocus events and gains focus', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(focusNode: focusNode), + ), + ), + ), + ); + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: [ + SemanticsAction.tap, + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didGainAccessibilityFocus, + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didLoseAccessibilityFocus, + // TODO(gspencergoog): also test for the presence of SemanticsAction.focus when + // this iOS issue is addressed: https://github.com/flutter/flutter/issues/150030 + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + )); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when disabled does not listen to onFocus events or gain focus', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(focusNode: focusNode, enabled: false), + ), + ), + ), + ); + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isReadOnly, + ], + actions: [ + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didGainAccessibilityFocus, + if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didLoseAccessibilityFocus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + )); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when receives SemanticsAction.focus while already focused, shows keyboard', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(focusNode: focusNode), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log.single.method, 'TextInput.show'); + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when receives SemanticsAction.focus while focused but read-only, does not show keyboard', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(focusNode: focusNode, readOnly: true), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log, isEmpty); + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); } /// A Simple widget for testing the obscure text.