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 {