Skip to content

Commit 81f969e

Browse files
authored
Fix ExpansionTile Expanded/Collapsed announcement is interrupted by VoiceOver (#143936)
fixes [`ExpansionTile` accessibility information doesn't read Expanded/Collapsed (iOS)](flutter/flutter#132264) ### Code sample <details> <summary>expand to view the code sample</summary> ```dart import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @OverRide Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( appBar: AppBar( title: const Text('ExpansionTile'), ), body: const ExpansionTile( title: Text("Title"), children: <Widget>[ Placeholder(), ], ), floatingActionButton: FloatingActionButton( onPressed: () {}, child: const Icon(Icons.add), ), ), ); } } ``` </details> ### Before https://github.com/flutter/flutter/assets/48603081/542d8392-52dc-4319-92ba-215a7164db49 ### After https://github.com/flutter/flutter/assets/48603081/c9225144-4c12-4e92-bc41-4ff82b370ad7
1 parent 388f321 commit 81f969e

File tree

2 files changed

+55
-2
lines changed

2 files changed

+55
-2
lines changed

packages/flutter/lib/src/material/expansion_tile.dart

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
6+
7+
import 'package:flutter/foundation.dart';
58
import 'package:flutter/rendering.dart';
69
import 'package:flutter/widgets.dart';
710

@@ -571,6 +574,7 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
571574

572575
bool _isExpanded = false;
573576
late ExpansionTileController _tileController;
577+
Timer? _timer;
574578

575579
@override
576580
void initState() {
@@ -597,6 +601,8 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
597601
void dispose() {
598602
_tileController._state = null;
599603
_animationController.dispose();
604+
_timer?.cancel();
605+
_timer = null;
600606
super.dispose();
601607
}
602608

@@ -621,7 +627,19 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
621627
PageStorage.maybeOf(context)?.writeState(context, _isExpanded);
622628
});
623629
widget.onExpansionChanged?.call(_isExpanded);
624-
SemanticsService.announce(stateHint, textDirection);
630+
631+
if (defaultTargetPlatform == TargetPlatform.iOS) {
632+
// TODO(tahatesser): This is a workaround for VoiceOver interrupting
633+
// semantic announcements on iOS. https://github.com/flutter/flutter/issues/122101.
634+
_timer?.cancel();
635+
_timer = Timer(const Duration(seconds: 1), () {
636+
SemanticsService.announce(stateHint, textDirection);
637+
_timer?.cancel();
638+
_timer = null;
639+
});
640+
} else {
641+
SemanticsService.announce(stateHint, textDirection);
642+
}
625643
}
626644

627645
void _handleTap() {

packages/flutter/test/material/expansion_tile_test.dart

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
@Tags(<String>['reduced-test-set'])
88
library;
99

10+
import 'package:flutter/foundation.dart';
1011
import 'package:flutter/material.dart';
1112
import 'package:flutter/rendering.dart';
1213
import 'package:flutter_test/flutter_test.dart';
@@ -788,7 +789,41 @@ void main() {
788789
// "Collapsed".
789790
expect(tester.takeAnnouncements().first.message, localizations.expandedHint);
790791
handle.dispose();
791-
});
792+
}, skip: defaultTargetPlatform == TargetPlatform.iOS); // [intended] https://github.com/flutter/flutter/issues/122101.
793+
794+
// This is a regression test for https://github.com/flutter/flutter/issues/132264.
795+
testWidgets('ExpansionTile Semantics announcement is delayed on iOS', (WidgetTester tester) async {
796+
final SemanticsHandle handle = tester.ensureSemantics();
797+
const DefaultMaterialLocalizations localizations = DefaultMaterialLocalizations();
798+
await tester.pumpWidget(
799+
const MaterialApp(
800+
home: Material(
801+
child: ExpansionTile(
802+
title: Text('Title'),
803+
children: <Widget>[
804+
SizedBox(height: 100, width: 100),
805+
],
806+
),
807+
),
808+
),
809+
);
810+
811+
// There is no semantics announcement without tap action.
812+
expect(tester.takeAnnouncements(), isEmpty);
813+
814+
// Tap the title to expand ExpansionTile.
815+
await tester.tap(find.text('Title'));
816+
await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made.
817+
818+
expect(tester.takeAnnouncements().first.message, localizations.collapsedHint);
819+
820+
// Tap the title to collapse ExpansionTile.
821+
await tester.tap(find.text('Title'));
822+
await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made.
823+
824+
expect(tester.takeAnnouncements().first.message, localizations.expandedHint);
825+
handle.dispose();
826+
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
792827

793828
testWidgets('Semantics with the onTapHint is an ancestor of ListTile', (WidgetTester tester) async {
794829
// This is a regression test for https://github.com/flutter/flutter/pull/121624

0 commit comments

Comments
 (0)