Skip to content

Commit 58eb0e8

Browse files
authored
Update a11y for SliverAppBar (#144437)
1. Set cacheExtent for sliverAppBar so it's not dropped from the semantics tree. 2. Update its toolbarOpacity in a11y mode to 1.0. When scrolling in a11y mode and the focus is back to the sliverAppBar, the content should be visible. fixes: flutter/flutter#143437 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] 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/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#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/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [Data Driven Fixes]: https://github.com/flutter/flutter/wiki/Data-driven-Fixes
1 parent eeff96d commit 58eb0e8

File tree

4 files changed

+212
-8
lines changed

4 files changed

+212
-8
lines changed

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
11991199
required this.forceMaterialTransparency,
12001200
required this.clipBehavior,
12011201
required this.variant,
1202+
required this.accessibleNavigation,
12021203
}) : assert(primary || topPadding == 0.0),
12031204
_bottomHeight = bottom?.preferredSize.height ?? 0.0;
12041205

@@ -1236,6 +1237,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
12361237
final bool forceMaterialTransparency;
12371238
final Clip? clipBehavior;
12381239
final _SliverAppVariant variant;
1240+
final bool accessibleNavigation;
12391241

12401242
@override
12411243
double get minExtent => collapsedHeight;
@@ -1263,7 +1265,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
12631265

12641266
final bool isScrolledUnder = overlapsContent || forceElevated || (pinned && shrinkOffset > maxExtent - minExtent);
12651267
final bool isPinnedWithOpacityFade = pinned && floating && bottom != null && extraToolbarHeight == 0.0;
1266-
final double toolbarOpacity = !pinned || isPinnedWithOpacityFade
1268+
final double toolbarOpacity = !accessibleNavigation && (!pinned || isPinnedWithOpacityFade)
12671269
? clampDouble(visibleToolbarHeight / (toolbarHeight ?? kToolbarHeight), 0.0, 1.0)
12681270
: 1.0;
12691271
final Widget? effectiveTitle = switch (variant) {
@@ -1354,7 +1356,8 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
13541356
|| toolbarTextStyle != oldDelegate.toolbarTextStyle
13551357
|| titleTextStyle != oldDelegate.titleTextStyle
13561358
|| systemOverlayStyle != oldDelegate.systemOverlayStyle
1357-
|| forceMaterialTransparency != oldDelegate.forceMaterialTransparency;
1359+
|| forceMaterialTransparency != oldDelegate.forceMaterialTransparency
1360+
|| accessibleNavigation != oldDelegate.accessibleNavigation;
13581361
}
13591362

13601363
@override
@@ -2036,6 +2039,7 @@ class _SliverAppBarState extends State<SliverAppBar> with TickerProviderStateMix
20362039
forceMaterialTransparency: widget.forceMaterialTransparency,
20372040
clipBehavior: widget.clipBehavior,
20382041
variant: widget._variant,
2042+
accessibleNavigation: MediaQuery.of(context).accessibleNavigation,
20392043
),
20402044
),
20412045
);

packages/flutter/lib/src/rendering/sliver_persistent_header.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,14 @@ abstract class RenderSliverScrollingPersistentHeader extends RenderSliverPersist
348348
}
349349
final double maxExtent = this.maxExtent;
350350
final double paintExtent = maxExtent - constraints.scrollOffset;
351+
final double cacheExtent = calculateCacheOffset(
352+
constraints,
353+
from: 0.0,
354+
to: maxExtent,
355+
);
356+
351357
geometry = SliverGeometry(
358+
cacheExtent: cacheExtent,
352359
scrollExtent: maxExtent,
353360
paintOrigin: math.min(constraints.overlap, 0.0),
354361
paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),

packages/flutter/test/widgets/sliver_appbar_opacity_test.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,67 @@ void main() {
2727
expect(render.text.style!.color!.opacity, 0.0);
2828
});
2929

30+
testWidgets('a11y mode ===> 1.0 opacity', (WidgetTester tester) async {
31+
final ScrollController controller = ScrollController();
32+
addTearDown(controller.dispose);
33+
await tester.pumpWidget(
34+
MediaQuery(
35+
data: const MediaQueryData(accessibleNavigation: true),
36+
child: _TestWidget(
37+
pinned: false,
38+
floating: false,
39+
bottom: false,
40+
controller: controller,
41+
),
42+
),
43+
);
44+
45+
final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1'));
46+
expect(render.text.style!.color!.opacity, 1.0);
47+
48+
controller.jumpTo(100.0);
49+
await tester.pumpAndSettle();
50+
expect(render.text.style!.color!.opacity, 1.0);
51+
});
52+
53+
testWidgets('turn on/off a11y mode to change opacity', (WidgetTester tester) async {
54+
final ScrollController controller = ScrollController();
55+
addTearDown(controller.dispose);
56+
addTearDown(tester.platformDispatcher.clearAllTestValues);
57+
addTearDown(tester.view.reset);
58+
59+
tester.platformDispatcher
60+
..textScaleFactorTestValue = 123
61+
..platformBrightnessTestValue = Brightness.dark
62+
..accessibilityFeaturesTestValue = const FakeAccessibilityFeatures();
63+
64+
await tester.pumpWidget(
65+
_TestWidget(
66+
pinned: false,
67+
floating: false,
68+
bottom: false,
69+
controller: controller,
70+
),
71+
);
72+
73+
// AccessibleNavigation is off
74+
final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1'));
75+
controller.jumpTo(100.0);
76+
await tester.pumpAndSettle();
77+
expect(render.text.style!.color!.opacity < 1.0, true);
78+
79+
// Turn on accessibleNavigation
80+
tester.platformDispatcher.accessibilityFeaturesTestValue =
81+
const FakeAccessibilityFeatures(accessibleNavigation: true);
82+
await tester.pumpAndSettle();
83+
expect(render.text.style!.color!.opacity, 1.0);
84+
85+
// Turn off accessibleNavigation
86+
tester.platformDispatcher.accessibilityFeaturesTestValue =
87+
const FakeAccessibilityFeatures();
88+
await tester.pumpAndSettle();
89+
expect(render.text.style!.color!.opacity < 1.0, true);
90+
});
3091
testWidgets('!pinned && !floating && bottom ==> fade opacity', (WidgetTester tester) async {
3192
final ScrollController controller = ScrollController();
3293
addTearDown(controller.dispose);

packages/flutter/test/widgets/slivers_appbar_scrolling_test.dart

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ void main() {
6969
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
7070
position.animateTo(RenderBigSliver.height + delegate.maxExtent - 5.0, curve: Curves.linear, duration: const Duration(minutes: 1));
7171
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
72-
final RenderBox box = tester.renderObject<RenderBox>(find.byType(Container));
72+
final RenderBox box = tester.renderObject<RenderBox>(find.text('Sliver App Bar'));
7373
final Rect rect = Rect.fromPoints(box.localToGlobal(Offset.zero), box.localToGlobal(box.size.bottomRight(Offset.zero)));
7474
expect(rect, equals(const Rect.fromLTWH(0.0, -195.0, 800.0, 200.0)));
7575
});
@@ -95,14 +95,14 @@ void main() {
9595
),
9696
);
9797

98-
expect(tester.getTopLeft(find.byType(Container)), Offset.zero);
98+
expect(tester.getTopLeft(find.text('Sliver App Bar')), Offset.zero);
9999
expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 200.0));
100100

101101
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
102102
position.jumpTo(-50.0);
103103
await tester.pump();
104104

105-
expect(tester.getTopLeft(find.byType(Container)), Offset.zero);
105+
expect(tester.getTopLeft(find.text('Sliver App Bar')), Offset.zero);
106106
expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 250.0));
107107
});
108108

@@ -127,16 +127,148 @@ void main() {
127127
),
128128
);
129129

130-
expect(tester.getTopLeft(find.byType(Container)), Offset.zero);
130+
expect(tester.getTopLeft(find.text('Sliver App Bar')), Offset.zero);
131131
expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 200.0));
132132

133133
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
134134
position.jumpTo(-50.0);
135135
await tester.pump();
136136

137-
expect(tester.getTopLeft(find.byType(Container)), Offset.zero);
137+
expect(tester.getTopLeft(find.text('Sliver App Bar')), Offset.zero);
138138
expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 250.0));
139139
});
140+
141+
group('has correct semantics when', () {
142+
testWidgets('within viewport', (WidgetTester tester) async {
143+
const double cacheExtent = 250;
144+
final SemanticsHandle handle = tester.ensureSemantics();
145+
146+
await tester.pumpWidget(
147+
Directionality(
148+
textDirection: TextDirection.ltr,
149+
child: CustomScrollView(
150+
cacheExtent: cacheExtent,
151+
physics: const BouncingScrollPhysics(),
152+
slivers: <Widget>[
153+
SliverPersistentHeader(delegate: TestDelegate()),
154+
const SliverList(
155+
delegate: SliverChildListDelegate.fixed(<Widget>[
156+
SizedBox(
157+
height: 300.0,
158+
child: Text('X'),
159+
),
160+
]),
161+
),
162+
],
163+
),
164+
),
165+
);
166+
167+
final SemanticsFinder sliverAppBar = find.semantics.byLabel(
168+
'Sliver App Bar',
169+
);
170+
171+
expect(sliverAppBar, findsOne);
172+
expect(sliverAppBar, containsSemantics(isHidden: false));
173+
handle.dispose();
174+
});
175+
176+
testWidgets('partially scrolling off screen', (WidgetTester tester) async {
177+
final GlobalKey key = GlobalKey();
178+
final TestDelegate delegate = TestDelegate();
179+
final SemanticsHandle handle = tester.ensureSemantics();
180+
const double cacheExtent = 250;
181+
await tester.pumpWidget(
182+
Directionality(
183+
textDirection: TextDirection.ltr,
184+
child: CustomScrollView(
185+
cacheExtent: cacheExtent,
186+
slivers: <Widget>[
187+
SliverPersistentHeader(key: key, delegate: delegate),
188+
const BigSliver(),
189+
const BigSliver(),
190+
],
191+
),
192+
),
193+
);
194+
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
195+
position.animateTo(delegate.maxExtent - 20.0, curve: Curves.linear, duration: const Duration(minutes: 1));
196+
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
197+
final RenderBox box = tester.renderObject<RenderBox>(find.text('Sliver App Bar'));
198+
final Rect rect = Rect.fromPoints(box.localToGlobal(Offset.zero), box.localToGlobal(box.size.bottomRight(Offset.zero)));
199+
expect(rect, equals(const Rect.fromLTWH(0.0, -180.0, 800.0, 200.0)));
200+
201+
final SemanticsFinder sliverAppBar = find.semantics.byLabel(
202+
'Sliver App Bar',
203+
);
204+
205+
expect(sliverAppBar, findsOne);
206+
expect(sliverAppBar, containsSemantics(isHidden: false));
207+
handle.dispose();
208+
});
209+
210+
211+
testWidgets('completely scrolling off screen but within cache extent', (WidgetTester tester) async {
212+
final GlobalKey key = GlobalKey();
213+
final TestDelegate delegate = TestDelegate();
214+
final SemanticsHandle handle = tester.ensureSemantics();
215+
const double cacheExtent = 250;
216+
await tester.pumpWidget(
217+
Directionality(
218+
textDirection: TextDirection.ltr,
219+
child: CustomScrollView(
220+
cacheExtent: cacheExtent,
221+
slivers: <Widget>[
222+
SliverPersistentHeader(key: key, delegate: delegate),
223+
const BigSliver(),
224+
const BigSliver(),
225+
],
226+
),
227+
),
228+
);
229+
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
230+
position.animateTo(delegate.maxExtent + 20.0, curve: Curves.linear, duration: const Duration(minutes: 1));
231+
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
232+
233+
final SemanticsFinder sliverAppBar = find.semantics.byLabel(
234+
'Sliver App Bar',
235+
);
236+
237+
expect(sliverAppBar, findsOne);
238+
expect(sliverAppBar, containsSemantics(isHidden: true));
239+
handle.dispose();
240+
});
241+
242+
testWidgets('completely scrolling off screen and not within cache extent', (WidgetTester tester) async {
243+
final GlobalKey key = GlobalKey();
244+
final TestDelegate delegate = TestDelegate();
245+
final SemanticsHandle handle = tester.ensureSemantics();
246+
const double cacheExtent = 250;
247+
await tester.pumpWidget(
248+
Directionality(
249+
textDirection: TextDirection.ltr,
250+
child: CustomScrollView(
251+
cacheExtent: cacheExtent,
252+
slivers: <Widget>[
253+
SliverPersistentHeader(key: key, delegate: delegate),
254+
const BigSliver(),
255+
const BigSliver(),
256+
],
257+
),
258+
),
259+
);
260+
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
261+
position.animateTo(delegate.maxExtent + 300.0, curve: Curves.linear, duration: const Duration(minutes: 1));
262+
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
263+
264+
final SemanticsFinder sliverAppBar = find.semantics.byLabel(
265+
'Sliver App Bar',
266+
);
267+
268+
expect(sliverAppBar, findsNothing);
269+
handle.dispose();
270+
});
271+
});
140272
}
141273

142274
class TestDelegate extends SliverPersistentHeaderDelegate {
@@ -148,7 +280,7 @@ class TestDelegate extends SliverPersistentHeaderDelegate {
148280

149281
@override
150282
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
151-
return Container(height: maxExtent);
283+
return SizedBox(height: maxExtent, child: const Text('Sliver App Bar'),);
152284
}
153285

154286
@override

0 commit comments

Comments
 (0)