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].

<!-- Links -->
[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
This commit is contained in:
Simon Hadenius 2025-05-23 03:02:18 +02:00 committed by GitHub
parent 4226470eb6
commit a44675cb39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 174 additions and 2 deletions

View File

@ -4242,7 +4242,14 @@ class IndexedStack extends StatelessWidget {
@override
Widget build(BuildContext context) {
final List<Widget> wrappedChildren = List<Widget>.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,

View File

@ -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,

View File

@ -489,6 +489,74 @@ void main() {
}
});
testWidgets('IndexedStack excludes focus for hidden children', (WidgetTester tester) async {
const List<Widget> children = <Widget>[
Focus(child: Text('child 0')),
Focus(child: Text('child 1')),
];
Future<void> pumpIndexedStack(int? activeIndex) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: IndexedStack(index: activeIndex, children: children),
),
);
}
Future<void> 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<Widget> children = <Widget>[
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(

View File

@ -396,6 +396,95 @@ void main() {
semantics.dispose();
});
testWidgets('Visibility with maintain* false excludes focus of child when not visible', (
WidgetTester tester,
) async {
Future<void> 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<void> 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<void> 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 {