From a44675cb392e48beff3379e21a237f2f303ec668 Mon Sep 17 00:00:00 2001 From: Simon Hadenius Date: Fri, 23 May 2025 03:02:18 +0200 Subject: [PATCH] Add flag to exclude focus for hidden children in Visibility, maintainFocusability. Set maintainFocusability to false in IndexedStack (#159133) Fixes: https://github.com/flutter/flutter/issues/114213 and is a prerequisite for fixing: https://github.com/flutter/flutter/issues/148405 ## 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. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- packages/flutter/lib/src/widgets/basic.dart | 9 +- .../flutter/lib/src/widgets/visibility.dart | 10 ++- packages/flutter/test/widgets/stack_test.dart | 68 ++++++++++++++ .../flutter/test/widgets/visibility_test.dart | 89 +++++++++++++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 8e1efdc9dfe..8ceff607b19 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4242,7 +4242,14 @@ class IndexedStack extends StatelessWidget { @override Widget build(BuildContext context) { final List wrappedChildren = List.generate(children.length, (int i) { - return Visibility.maintain(visible: i == index, child: children[i]); + return Visibility( + visible: i == index, + maintainInteractivity: true, + maintainSize: true, + maintainState: true, + maintainAnimation: true, + child: children[i], + ); }); return _RawIndexedStack( alignment: alignment, diff --git a/packages/flutter/lib/src/widgets/visibility.dart b/packages/flutter/lib/src/widgets/visibility.dart index 974a14eca49..3df5c46c0c4 100644 --- a/packages/flutter/lib/src/widgets/visibility.dart +++ b/packages/flutter/lib/src/widgets/visibility.dart @@ -12,6 +12,7 @@ library; import 'package:flutter/rendering.dart'; import 'basic.dart'; +import 'focus_scope.dart'; import 'framework.dart'; import 'sliver.dart'; import 'ticker_provider.dart'; @@ -66,6 +67,7 @@ class Visibility extends StatelessWidget { this.maintainSize = false, this.maintainSemantics = false, this.maintainInteractivity = false, + this.maintainFocusability = false, }) : assert( maintainState || !maintainAnimation, 'Cannot maintain animations if the state is not also maintained.', @@ -95,6 +97,7 @@ class Visibility extends StatelessWidget { maintainSize = true, maintainSemantics = true, maintainInteractivity = true, + maintainFocusability = true, replacement = const SizedBox.shrink(); // Unused since maintainState is always true. /// The widget to show or hide, as controlled by [visible]. @@ -215,6 +218,11 @@ class Visibility extends StatelessWidget { /// true, then touch events will nonetheless be passed through. final bool maintainInteractivity; + /// Whether to allow the widget to receive focus when hidden. Only in effect if [visible] is false. + /// + /// Defaults to false. + final bool maintainFocusability; + /// Tells the visibility state of an element in the tree based off its /// ancestor [Visibility] elements. /// @@ -245,7 +253,7 @@ class Visibility extends StatelessWidget { @override Widget build(BuildContext context) { - Widget result = child; + Widget result = ExcludeFocus(excluding: !visible && !maintainFocusability, child: child); if (maintainSize) { result = _Visibility( visible: visible, diff --git a/packages/flutter/test/widgets/stack_test.dart b/packages/flutter/test/widgets/stack_test.dart index 90cbc1121e6..4a9a26c7fd7 100644 --- a/packages/flutter/test/widgets/stack_test.dart +++ b/packages/flutter/test/widgets/stack_test.dart @@ -489,6 +489,74 @@ void main() { } }); + testWidgets('IndexedStack excludes focus for hidden children', (WidgetTester tester) async { + const List children = [ + Focus(child: Text('child 0')), + Focus(child: Text('child 1')), + ]; + + Future pumpIndexedStack(int? activeIndex) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: IndexedStack(index: activeIndex, children: children), + ), + ); + } + + Future requestFocusAndPump(FocusNode node) async { + node.requestFocus(); + await tester.pump(); + } + + await pumpIndexedStack(0); + + final Element child0 = tester.element(find.text('child 0', skipOffstage: false)); + final Element child1 = tester.element(find.text('child 1', skipOffstage: false)); + final FocusNode child0FocusNode = Focus.of(child0); + final FocusNode child1FocusNode = Focus.of(child1); + + await requestFocusAndPump(child0FocusNode); + + expect(child0FocusNode.hasFocus, true); + expect(child1FocusNode.hasFocus, false); + + await requestFocusAndPump(child1FocusNode); + + expect(child0FocusNode.hasFocus, true); + expect(child1FocusNode.hasFocus, false); + + await pumpIndexedStack(1); + await requestFocusAndPump(child1FocusNode); + + expect(child0FocusNode.hasFocus, false); + expect(child1FocusNode.hasFocus, true); + + await requestFocusAndPump(child0FocusNode); + + expect(child0FocusNode.hasFocus, false); + expect(child1FocusNode.hasFocus, true); + }); + + testWidgets('IndexedStack: hidden children can not receive tap events', ( + WidgetTester tester, + ) async { + bool tapped = false; + final List children = [ + const Text('child'), + GestureDetector(onTap: () => tapped = true, child: const Text('hiddenChild')), + ]; + + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: IndexedStack(children: children)), + ); + + await tester.tap(find.text('hiddenChild', skipOffstage: false), warnIfMissed: false); + await tester.pump(); + + expect(tapped, false); + }); + testWidgets('Stack clip test', (WidgetTester tester) async { await tester.pumpWidget( const Directionality( diff --git a/packages/flutter/test/widgets/visibility_test.dart b/packages/flutter/test/widgets/visibility_test.dart index e3c331bb219..fe662b7d3e4 100644 --- a/packages/flutter/test/widgets/visibility_test.dart +++ b/packages/flutter/test/widgets/visibility_test.dart @@ -396,6 +396,95 @@ void main() { semantics.dispose(); }); + testWidgets('Visibility with maintain* false excludes focus of child when not visible', ( + WidgetTester tester, + ) async { + Future pumpVisibility(bool visible) async { + await tester.pumpWidget( + Visibility( + visible: visible, + child: const Focus(child: Text('child', textDirection: TextDirection.ltr)), + ), + ); + } + + await pumpVisibility(true); + + final Element child = tester.element(find.text('child', skipOffstage: false)); + final FocusNode childFocusNode = Focus.of(child); + + childFocusNode.requestFocus(); + await tester.pump(); + + expect(childFocusNode.hasFocus, true); + + await pumpVisibility(false); + childFocusNode.requestFocus(); + + expect(childFocusNode.hasFocus, false); + }); + + testWidgets('Visibility with maintain* true does not exclude focus of child when not visible', ( + WidgetTester tester, + ) async { + Future pumpVisibility(bool visible) async { + await tester.pumpWidget( + Visibility.maintain( + visible: visible, + child: const Focus(child: Text('child', textDirection: TextDirection.ltr)), + ), + ); + } + + await pumpVisibility(true); + + final Element child = tester.element(find.text('child', skipOffstage: false)); + final FocusNode childFocusNode = Focus.of(child); + + childFocusNode.requestFocus(); + await tester.pump(); + + expect(childFocusNode.hasFocus, true); + + await pumpVisibility(false); + + expect(childFocusNode.hasFocus, true); + }); + + testWidgets( + 'Visibility with maintain* true except maintainFocusability which is false excludes focus of child when not visible', + (WidgetTester tester) async { + Future pumpVisibility(bool visible) async { + await tester.pumpWidget( + Visibility( + visible: visible, + maintainState: true, + maintainAnimation: true, + maintainInteractivity: true, + maintainSemantics: true, + maintainSize: true, + child: const Focus(child: Text('child', textDirection: TextDirection.ltr)), + ), + ); + } + + await pumpVisibility(true); + + final Element child = tester.element(find.text('child', skipOffstage: false)); + final FocusNode childFocusNode = Focus.of(child); + + childFocusNode.requestFocus(); + await tester.pump(); + + expect(childFocusNode.hasFocus, true); + + await pumpVisibility(false); + childFocusNode.requestFocus(); + + expect(childFocusNode.hasFocus, false); + }, + ); + testWidgets('Visibility does not force compositing when visible and maintain*', ( WidgetTester tester, ) async {