From 7f9f8627f2cbbb7574d05bac2a89bb2adf6c36b5 Mon Sep 17 00:00:00 2001 From: Sana Ullah Date: Fri, 11 Sep 2026 11:40:12 +0500 Subject: [PATCH] [material_ui] Fix ExpansionTile TalkBack double-swipe on Android Combine the Android live-region announcement into the same Semantics node as the header hint so TalkBack no longer stops twice on one tile. Fixes https://github.com/flutter/flutter/issues/190601 --- .../material_ui/lib/src/expansion_tile.dart | 12 +++---- .../change_2026_09_11_1789108674488.yaml | 3 ++ .../material_ui/test/expansion_tile_test.dart | 36 ++++++++++++++++--- 3 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 packages/material_ui/pending_changelogs/change_2026_09_11_1789108674488.yaml diff --git a/packages/material_ui/lib/src/expansion_tile.dart b/packages/material_ui/lib/src/expansion_tile.dart index 3d59612ccc4e..0837ec22df2d 100644 --- a/packages/material_ui/lib/src/expansion_tile.dart +++ b/packages/material_ui/lib/src/expansion_tile.dart @@ -691,13 +691,13 @@ class _ExpansionTileState extends State { if (defaultTargetPlatform == TargetPlatform.android) { return Semantics( - // Live region used to announce state changes (e.g., "expanded" or "collapsed") - // without taking focus. - // blockNode prevents this node from being part of the focus traversal. - label: semanticsHint, + // Live region announces expand/collapse without a separate focusable + // wrapper node. Nesting a liveRegion+label Semantics over the hint node + // caused TalkBack to stop twice on the same tile (flutter/flutter#190601). liveRegion: true, - accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, - child: Semantics(hint: semanticsHint, onTapHint: onTapHint, child: child), + hint: semanticsHint, + onTapHint: onTapHint, + child: child, ); } return Semantics(hint: semanticsHint, onTapHint: onTapHint, child: child); diff --git a/packages/material_ui/pending_changelogs/change_2026_09_11_1789108674488.yaml b/packages/material_ui/pending_changelogs/change_2026_09_11_1789108674488.yaml new file mode 100644 index 000000000000..e18eee95a82f --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_09_11_1789108674488.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fixes ExpansionTile TalkBack requiring two swipes on Android by combining the live-region announcement into a single semantics node. +version: skip diff --git a/packages/material_ui/test/expansion_tile_test.dart b/packages/material_ui/test/expansion_tile_test.dart index bfdb8b020b1f..422dc4c89cef 100644 --- a/packages/material_ui/test/expansion_tile_test.dart +++ b/packages/material_ui/test/expansion_tile_test.dart @@ -2243,7 +2243,7 @@ void main() { ), ); - // Initially collapsed - live region label is "Collapsed". + // Initially collapsed - live region hint is "Collapsed". SemanticsNode liveRegionSemantics = tester.getSemantics( find.ancestor( @@ -2253,7 +2253,7 @@ void main() { ), ), ); - expect(liveRegionSemantics.label, localizations.expandedHint); + expect(liveRegionSemantics.hint, localizations.expandedHint); // Tap to expand. await tester.tap(find.text('Test Tile')); @@ -2268,7 +2268,7 @@ void main() { ), ), ); - expect(liveRegionSemantics.label, localizations.collapsedHint); + expect(liveRegionSemantics.hint, localizations.collapsedHint); // Tap to collapse. await tester.tap(find.text('Test Tile')); @@ -2283,7 +2283,35 @@ void main() { ), ), ); - expect(liveRegionSemantics.label, localizations.expandedHint); + expect(liveRegionSemantics.hint, localizations.expandedHint); + + handle.dispose(); + }, variant: const TargetPlatformVariant({TargetPlatform.android})); + + // Regression test for https://github.com/flutter/flutter/issues/190601. + testWidgets('Android header uses a single live-region Semantics node', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile(title: Text('Filter'), children: [Text('Child')]), + ), + ), + ); + + final Finder liveRegionSemantics = find.ancestor( + of: find.byType(ListTile), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ); + expect(liveRegionSemantics, findsOneWidget); + + final Semantics widget = tester.widget(liveRegionSemantics); + expect(widget.properties.hint, isNotNull); + expect(widget.properties.accessibilityFocusBlockType, isNull); handle.dispose(); }, variant: const TargetPlatformVariant({TargetPlatform.android}));