mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
SliverEnsureSemantics (#165589)
Currently when using a `CustomScrollView`, screen readers cannot list or move focus to elements that are outside the current Viewport and cache extent because we do not create semantic nodes for these elements. This change introduces `SliverEnsureSemantics` which ensures its sliver child is included in the semantics tree, whether or not it is currently visible on the screen or within the cache extent. This way screen readers are aware the elements are there and can navigate to them / create accessibility traversal menus with this information. * Under the hood a new flag has been added to `RenderSliver` called `ensureSemantics`. `RenderViewportBase` uses this in its `visitChildrenForSemantics` to ensure a sliver is visited when creating the semantics tree. Previously a sliver was not visited if it was not visible or within the cache extent. `RenderViewportBase` also uses this in `describeSemanticsClip` and `describeApproximatePaintClip` to ensure a sliver child that wants to "ensure semantics" is not clipped out if it is not currently visible in the viewport or outside the cache extent. * `RenderSliverMultiBoxAdaptor.semanticBounds` now leverages its first child as an anchor for assistive technologies to be able to reach it if the Sliver is a child of `SliverEnsureSemantics`. If not it will still be dropped from the semantics tree. * `RenderProxySliver` now considers child overrides of `semanticBounds`. On the engine side we move from using a joystick method to scroll with `SemanticsAction.scrollUp` and `SemanticsAction.scrollDown` to using `SemanticsAction.scrollToOffset` completely letting the browser drive the scrolling with its current dom scroll position "scrollTop" or "scrollLeft". This is possible by calculating the total quantity of content under the scrollable and sizing the scroll element based on that. <details open><summary>Code sample</summary> ```dart // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; /// Flutter code sample for [SliverEnsureSemantics]. void main() => runApp(const SliverEnsureSemanticsExampleApp()); class SliverEnsureSemanticsExampleApp extends StatelessWidget { const SliverEnsureSemanticsExampleApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp(home: SliverEnsureSemanticsExample()); } } class SliverEnsureSemanticsExample extends StatefulWidget { const SliverEnsureSemanticsExample({super.key}); @override State<SliverEnsureSemanticsExample> createState() => _SliverEnsureSemanticsExampleState(); } class _SliverEnsureSemanticsExampleState extends State<SliverEnsureSemanticsExample> { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); return Scaffold( appBar: AppBar( backgroundColor: theme.colorScheme.inversePrimary, title: const Text('SliverEnsureSemantics Demo'), ), body: Center( child: CustomScrollView( semanticChildCount: 106, slivers: <Widget>[ SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 0, child: Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Semantics( header: true, headingLevel: 3, child: Text( 'Steps to reproduce', style: theme.textTheme.headlineSmall, ), ), const Text('Issue description'), Semantics( header: true, headingLevel: 3, child: Text( 'Expected Results', style: theme.textTheme.headlineSmall, ), ), Semantics( header: true, headingLevel: 3, child: Text( 'Actual Results', style: theme.textTheme.headlineSmall, ), ), Semantics( header: true, headingLevel: 3, child: Text( 'Code Sample', style: theme.textTheme.headlineSmall, ), ), Semantics( header: true, headingLevel: 3, child: Text( 'Screenshots', style: theme.textTheme.headlineSmall, ), ), Semantics( header: true, headingLevel: 3, child: Text( 'Logs', style: theme.textTheme.headlineSmall, ), ), ], ), ), ), ), ), ), SliverFixedExtentList( itemExtent: 44.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Text('Item $index'), ), ); }, childCount: 50, semanticIndexOffset: 1, ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 51, child: Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Semantics( header: true, child: const Text('Footer 1'), ), ), ), ), ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 52, child: Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Semantics( header: true, child: const Text('Footer 2'), ), ), ), ), ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 53, child: Semantics(link: true, child: const Text('Link #1')), ), ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 54, child: OverflowBar( children: <Widget>[ TextButton( onPressed: () {}, child: const Text('Button 1'), ), TextButton( onPressed: () {}, child: const Text('Button 2'), ), ], ), ), ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 55, child: Semantics(link: true, child: const Text('Link #2')), ), ), ), SliverEnsureSemantics( sliver: SliverSemanticsList( sliver: SliverFixedExtentList( itemExtent: 44.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return Semantics( role: SemanticsRole.listItem, child: Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Text('Second List Item $index'), ), ), ); }, childCount: 50, semanticIndexOffset: 56, ), ), ), ), SliverEnsureSemantics( sliver: SliverToBoxAdapter( child: IndexedSemantics( index: 107, child: Semantics(link: true, child: const Text('Link #3')), ), ), ), ], ), ), ); } } // A sliver that assigns the role of SemanticsRole.list to its sliver child. class SliverSemanticsList extends SingleChildRenderObjectWidget { const SliverSemanticsList({super.key, required Widget sliver}) : super(child: sliver); @override RenderSliverSemanticsList createRenderObject(BuildContext context) => RenderSliverSemanticsList(); } class RenderSliverSemanticsList extends RenderProxySliver { @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); config.role = SemanticsRole.list; } } ``` </details> Fixes: #160217 ## 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. --------- Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
parent
6353a00fb7
commit
3fa9b38705
@ -1,6 +1,7 @@
|
||||
// Copyright 2013 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:ui/src/engine.dart';
|
||||
@ -9,20 +10,10 @@ import 'package:ui/ui.dart' as ui;
|
||||
/// Implements vertical and horizontal scrolling functionality for semantics
|
||||
/// objects.
|
||||
///
|
||||
/// Scrolling is implemented using a "joystick" method. The absolute value of
|
||||
/// "scrollTop" in HTML is not important. We only need to know in whether the
|
||||
/// value changed in the positive or negative direction. If it changes in the
|
||||
/// positive direction we send a [ui.SemanticsAction.scrollUp]. Otherwise, we
|
||||
/// send [ui.SemanticsAction.scrollDown]. The actual scrolling is then handled
|
||||
/// by the framework and we receive a [ui.SemanticsUpdate] containing the new
|
||||
/// [scrollPosition] and child positions.
|
||||
///
|
||||
/// "scrollTop" or "scrollLeft" is always reset to an arbitrarily chosen non-
|
||||
/// zero "neutral" scroll position value. This is done so we have a
|
||||
/// predictable range of DOM scroll position values. When the amount of
|
||||
/// contents is less than the size of the viewport the browser snaps
|
||||
/// "scrollTop" back to zero. If there is more content than available in the
|
||||
/// viewport "scrollTop" may take positive values.
|
||||
/// Scrolling is controlled by sending the current DOM scroll position in a
|
||||
/// [ui.SemanticsAction.scrollToOffset] to the framework where it applies the
|
||||
/// value to its scrollable and the engine receives a [ui.SemanticsUpdate]
|
||||
/// containing the new [SemanticsObject.scrollPosition] and child positions.
|
||||
class SemanticScrollable extends SemanticRole {
|
||||
SemanticScrollable(SemanticsObject semanticsObject)
|
||||
: super.withBasics(
|
||||
@ -39,81 +30,61 @@ class SemanticScrollable extends SemanticRole {
|
||||
/// Disables browser-driven scrolling in the presence of pointer events.
|
||||
GestureModeCallback? _gestureModeListener;
|
||||
|
||||
/// DOM element used as a workaround for: https://github.com/flutter/flutter/issues/104036
|
||||
///
|
||||
/// When the assistive technology gets to the last element of the scrollable
|
||||
/// list, the browser thinks the scrollable area doesn't have any more content,
|
||||
/// so it overrides the value of "scrollTop"/"scrollLeft" with zero. As a result,
|
||||
/// the user can't scroll back up/left.
|
||||
///
|
||||
/// As a workaround, we add this DOM element and set its size to
|
||||
/// [canonicalNeutralScrollPosition] so the browser believes
|
||||
/// that the scrollable area still has some more content, and doesn't override
|
||||
/// scrollTop/scrollLetf with zero.
|
||||
/// DOM element used to indicate to the browser the total quantity of available
|
||||
/// content under this scrollable area. This element is sized based on the
|
||||
/// total scroll extent calculated by scrollExtentMax - scrollExtentMin + rect.height
|
||||
/// of the [SemanticsObject] managed by this scrollable.
|
||||
final DomElement _scrollOverflowElement = createDomElement('flt-semantics-scroll-overflow');
|
||||
|
||||
/// Listens to HTML "scroll" gestures detected by the browser.
|
||||
///
|
||||
/// This gesture is converted to [ui.SemanticsAction.scrollUp] or
|
||||
/// [ui.SemanticsAction.scrollDown], depending on the direction.
|
||||
/// When the browser detects a "scroll" gesture we send the updated DOM scroll position
|
||||
/// to the framework in a [ui.SemanticsAction.scrollToOffset].
|
||||
@visibleForTesting
|
||||
DomEventListener? scrollListener;
|
||||
|
||||
/// The value of the "scrollTop" or "scrollLeft" property of this object's
|
||||
/// [element] that has zero offset relative to the [scrollPosition].
|
||||
int _effectiveNeutralScrollPosition = 0;
|
||||
|
||||
/// Whether this scrollable can scroll vertically or horizontally.
|
||||
bool get _canScroll =>
|
||||
semanticsObject.isVerticalScrollContainer || semanticsObject.isHorizontalScrollContainer;
|
||||
|
||||
/// The previous value of the "scrollTop" or "scrollLeft" property of this object's
|
||||
/// [element], used to determine if the content was scrolled.
|
||||
int _previousDomScrollPosition = 0;
|
||||
|
||||
/// Responds to browser-detected "scroll" gestures.
|
||||
void _recomputeScrollPosition() {
|
||||
if (_domScrollPosition != _effectiveNeutralScrollPosition) {
|
||||
if (_domScrollPosition != _previousDomScrollPosition) {
|
||||
if (!EngineSemantics.instance.shouldAcceptBrowserGesture('scroll')) {
|
||||
return;
|
||||
}
|
||||
final bool doScrollForward = _domScrollPosition > _effectiveNeutralScrollPosition;
|
||||
_neutralizeDomScrollPosition();
|
||||
|
||||
_previousDomScrollPosition = _domScrollPosition;
|
||||
_updateScrollableState();
|
||||
semanticsObject.recomputePositionAndSize();
|
||||
semanticsObject.updateChildrenPositionAndSize();
|
||||
|
||||
final int semanticsId = semanticsObject.id;
|
||||
if (doScrollForward) {
|
||||
if (semanticsObject.isVerticalScrollContainer) {
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
viewId,
|
||||
semanticsId,
|
||||
ui.SemanticsAction.scrollUp,
|
||||
null,
|
||||
);
|
||||
} else {
|
||||
assert(semanticsObject.isHorizontalScrollContainer);
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
viewId,
|
||||
semanticsId,
|
||||
ui.SemanticsAction.scrollLeft,
|
||||
null,
|
||||
);
|
||||
}
|
||||
final Float64List offsets = Float64List(2);
|
||||
|
||||
// Either SemanticsObject.isVerticalScrollContainer or
|
||||
// SemanticsObject.isHorizontalScrollContainer should be
|
||||
// true otherwise scrollToOffset cannot be called.
|
||||
if (semanticsObject.isVerticalScrollContainer) {
|
||||
offsets[0] = 0.0;
|
||||
offsets[1] = element.scrollTop;
|
||||
} else {
|
||||
if (semanticsObject.isVerticalScrollContainer) {
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
viewId,
|
||||
semanticsId,
|
||||
ui.SemanticsAction.scrollDown,
|
||||
null,
|
||||
);
|
||||
} else {
|
||||
assert(semanticsObject.isHorizontalScrollContainer);
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
viewId,
|
||||
semanticsId,
|
||||
ui.SemanticsAction.scrollRight,
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert(semanticsObject.isHorizontalScrollContainer);
|
||||
offsets[0] = element.scrollLeft;
|
||||
offsets[1] = 0.0;
|
||||
}
|
||||
|
||||
final ByteData? message = const StandardMessageCodec().encodeMessage(offsets);
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
viewId,
|
||||
semanticsId,
|
||||
ui.SemanticsAction.scrollToOffset,
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,6 +93,22 @@ class SemanticScrollable extends SemanticRole {
|
||||
// Scrolling is controlled by setting overflow-y/overflow-x to 'scroll`. The
|
||||
// default overflow = "visible" needs to be unset.
|
||||
semanticsObject.element.style.overflow = '';
|
||||
// On macOS the scrollbar behavior which can be set in the settings application
|
||||
// may sometimes insert scrollbars into an application when a peripheral like a
|
||||
// mouse or keyboard is plugged in. This causes the clientHeight or clientWidth
|
||||
// of the scrollable DOM element to be offset by the width of the scrollbar.
|
||||
// This causes issues in the vertical scrolling context because the max scroll
|
||||
// extent is calculated by the element's scrollHeight - clientHeight, so when
|
||||
// the clientHeight is offset by scrollbar width the browser may there is
|
||||
// a greater scroll extent then what is actually available.
|
||||
//
|
||||
// The scrollbar is already made transparent in SemanticsRole._initElement so here
|
||||
// set scrollbar-width to "none" to prevent it from affecting the max scroll extent.
|
||||
//
|
||||
// Support for scrollbar-width was only added to Safari v18.2+, so versions before
|
||||
// that may still experience overscroll issues when macOS inserts scrollbars
|
||||
// into the application.
|
||||
semanticsObject.element.style.scrollbarWidth = 'none';
|
||||
|
||||
_scrollOverflowElement.style
|
||||
..position = 'absolute'
|
||||
@ -136,7 +123,15 @@ class SemanticScrollable extends SemanticRole {
|
||||
super.update();
|
||||
|
||||
semanticsObject.owner.addOneTimePostUpdateCallback(() {
|
||||
_neutralizeDomScrollPosition();
|
||||
if (_canScroll) {
|
||||
final double? scrollPosition = semanticsObject.scrollPosition;
|
||||
assert(scrollPosition != null);
|
||||
if (scrollPosition != _domScrollPosition) {
|
||||
element.scrollTop = scrollPosition!;
|
||||
_previousDomScrollPosition = _domScrollPosition;
|
||||
}
|
||||
}
|
||||
_updateScrollableState();
|
||||
semanticsObject.recomputePositionAndSize();
|
||||
semanticsObject.updateChildrenPositionAndSize();
|
||||
});
|
||||
@ -183,56 +178,38 @@ class SemanticScrollable extends SemanticRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the scroll position (top or left) to the neutral value.
|
||||
///
|
||||
/// The scroll position of the scrollable HTML node that's considered to
|
||||
/// have zero offset relative to Flutter's notion of scroll position is
|
||||
/// referred to as "neutral scroll position".
|
||||
///
|
||||
/// We always set the scroll position to a non-zero value in order to
|
||||
/// be able to scroll in the negative direction. When scrollTop/scrollLeft is
|
||||
/// zero the browser will refuse to scroll back even when there is more
|
||||
/// content available.
|
||||
void _neutralizeDomScrollPosition() {
|
||||
void _updateScrollableState() {
|
||||
// This value is arbitrary.
|
||||
const int canonicalNeutralScrollPosition = 10;
|
||||
final ui.Rect? rect = semanticsObject.rect;
|
||||
if (rect == null) {
|
||||
printWarning('Warning! the rect attribute of semanticsObject is null');
|
||||
return;
|
||||
}
|
||||
final double? scrollExtentMax = semanticsObject.scrollExtentMax;
|
||||
final double? scrollExtentMin = semanticsObject.scrollExtentMin;
|
||||
assert(scrollExtentMax != null);
|
||||
assert(scrollExtentMin != null);
|
||||
final double scrollExtentTotal =
|
||||
scrollExtentMax! -
|
||||
scrollExtentMin! +
|
||||
(semanticsObject.isVerticalScrollContainer ? rect.height : rect.width);
|
||||
// Place the _scrollOverflowElement at the beginning of the content
|
||||
// and size it based on the total scroll extent so the browser
|
||||
// knows how much scrollable content there is.
|
||||
if (semanticsObject.isVerticalScrollContainer) {
|
||||
// Place the _scrollOverflowElement at the end of the content and
|
||||
// make sure that when we neutralize the scrolling position,
|
||||
// it doesn't scroll into the visible area.
|
||||
final int verticalOffset = rect.height.ceil() + canonicalNeutralScrollPosition;
|
||||
_scrollOverflowElement.style
|
||||
..transform = 'translate(0px,${verticalOffset}px)'
|
||||
..width = '${rect.width.round()}px'
|
||||
..height = '${canonicalNeutralScrollPosition}px';
|
||||
|
||||
element.scrollTop = canonicalNeutralScrollPosition.toDouble();
|
||||
// Read back because the effective value depends on the amount of content.
|
||||
_effectiveNeutralScrollPosition = element.scrollTop.toInt();
|
||||
..width = '0px'
|
||||
..height = '${scrollExtentTotal.toStringAsFixed(1)}px';
|
||||
semanticsObject
|
||||
..verticalScrollAdjustment = _effectiveNeutralScrollPosition.toDouble()
|
||||
..verticalScrollAdjustment = element.scrollTop
|
||||
..horizontalScrollAdjustment = 0.0;
|
||||
} else if (semanticsObject.isHorizontalScrollContainer) {
|
||||
// Place the _scrollOverflowElement at the end of the content and
|
||||
// make sure that when we neutralize the scrolling position,
|
||||
// it doesn't scroll into the visible area.
|
||||
final int horizontalOffset = rect.width.ceil() + canonicalNeutralScrollPosition;
|
||||
_scrollOverflowElement.style
|
||||
..transform = 'translate(${horizontalOffset}px,0px)'
|
||||
..width = '${canonicalNeutralScrollPosition}px'
|
||||
..height = '${rect.height.round()}px';
|
||||
|
||||
element.scrollLeft = canonicalNeutralScrollPosition.toDouble();
|
||||
// Read back because the effective value depends on the amount of content.
|
||||
_effectiveNeutralScrollPosition = element.scrollLeft.toInt();
|
||||
..width = '${scrollExtentTotal.toStringAsFixed(1)}px'
|
||||
..height = '0px';
|
||||
semanticsObject
|
||||
..verticalScrollAdjustment = 0.0
|
||||
..horizontalScrollAdjustment = _effectiveNeutralScrollPosition.toDouble();
|
||||
..horizontalScrollAdjustment = element.scrollLeft;
|
||||
} else {
|
||||
_scrollOverflowElement.style
|
||||
..transform = 'translate(0px,0px)'
|
||||
@ -240,7 +217,6 @@ class SemanticScrollable extends SemanticRole {
|
||||
..height = '0px';
|
||||
element.scrollLeft = 0.0;
|
||||
element.scrollTop = 0.0;
|
||||
_effectiveNeutralScrollPosition = 0;
|
||||
semanticsObject
|
||||
..verticalScrollAdjustment = 0.0
|
||||
..horizontalScrollAdjustment = 0.0;
|
||||
|
@ -1612,7 +1612,7 @@ void _testVerticalScrolling() {
|
||||
</sem>''');
|
||||
|
||||
final DomElement scrollable = findScrollable(owner());
|
||||
expect(scrollable.scrollTop, isPositive);
|
||||
expect(scrollable.scrollTop, 0);
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
@ -1649,8 +1649,8 @@ void _testVerticalScrolling() {
|
||||
expect(scrollable, isNotNull);
|
||||
|
||||
// When there's less content than the available size the neutral scrollTop
|
||||
// is still a positive number.
|
||||
expect(scrollable.scrollTop, isPositive);
|
||||
// is 0.
|
||||
expect(scrollable.scrollTop, 0);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@ -1703,18 +1703,7 @@ void _testVerticalScrolling() {
|
||||
|
||||
final DomElement scrollable = owner().debugSemanticsTree![0]!.element;
|
||||
expect(scrollable, isNotNull);
|
||||
|
||||
// When there's more content than the available size the neutral scrollTop
|
||||
// is greater than 0 with a maximum of 10 or 9.
|
||||
int browserMaxScrollDiff = 0;
|
||||
// The max scroll value varies between `9` and `10` for Safari desktop
|
||||
// browsers.
|
||||
if (ui_web.browser.browserEngine == ui_web.BrowserEngine.webkit &&
|
||||
ui_web.browser.operatingSystem == ui_web.OperatingSystem.macOs) {
|
||||
browserMaxScrollDiff = 1;
|
||||
}
|
||||
|
||||
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(scrollable.scrollTop, 0);
|
||||
|
||||
Future<ui.SemanticsActionEvent> capturedEventFuture = captureSemanticsEvent();
|
||||
scrollable.scrollTop = 20;
|
||||
@ -1722,21 +1711,44 @@ void _testVerticalScrolling() {
|
||||
ui.SemanticsActionEvent capturedEvent = await capturedEventFuture;
|
||||
|
||||
expect(capturedEvent.nodeId, 0);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollUp);
|
||||
expect(capturedEvent.arguments, isNull);
|
||||
// Engine semantics returns scroll top back to neutral.
|
||||
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollToOffset);
|
||||
expect(capturedEvent.arguments, isNotNull);
|
||||
final Float64List expectedOffset = Float64List(2);
|
||||
expectedOffset[0] = 0.0;
|
||||
expectedOffset[1] = 20.0;
|
||||
Float64List message =
|
||||
const StandardMessageCodec().decodeMessage(capturedEvent.arguments! as ByteData)
|
||||
as Float64List;
|
||||
expect(message, expectedOffset);
|
||||
|
||||
// Update scrollPosition to scrollTop value.
|
||||
final ui.SemanticsUpdateBuilder builder2 = ui.SemanticsUpdateBuilder();
|
||||
updateNode(
|
||||
builder2,
|
||||
scrollPosition: 20.0,
|
||||
flags: 0 | ui.SemanticsFlag.hasImplicitScrolling.index,
|
||||
actions: 0 | ui.SemanticsAction.scrollUp.index | ui.SemanticsAction.scrollDown.index,
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
|
||||
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
|
||||
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
|
||||
);
|
||||
owner().updateSemantics(builder2.build());
|
||||
|
||||
capturedEventFuture = captureSemanticsEvent();
|
||||
scrollable.scrollTop = 5;
|
||||
capturedEvent = await capturedEventFuture;
|
||||
|
||||
expect(scrollable.scrollTop >= (5 - browserMaxScrollDiff), isTrue);
|
||||
expect(scrollable.scrollTop, 5);
|
||||
expect(capturedEvent.nodeId, 0);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollDown);
|
||||
expect(capturedEvent.arguments, isNull);
|
||||
// Engine semantics returns scroll top back to neutral.
|
||||
expect(scrollable.scrollTop >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollToOffset);
|
||||
expect(capturedEvent.arguments, isNotNull);
|
||||
expectedOffset[0] = 0.0;
|
||||
expectedOffset[1] = 5.0;
|
||||
message =
|
||||
const StandardMessageCodec().decodeMessage(capturedEvent.arguments! as ByteData)
|
||||
as Float64List;
|
||||
expect(message, expectedOffset);
|
||||
});
|
||||
|
||||
test('scrollable switches to pointer event mode on a wheel event', () async {
|
||||
@ -1783,27 +1795,22 @@ void _testVerticalScrolling() {
|
||||
final DomElement scrollable = owner().debugSemanticsTree![0]!.element;
|
||||
expect(scrollable, isNotNull);
|
||||
|
||||
void expectNeutralPosition() {
|
||||
// Browsers disagree on the exact value, but it's always close to 10.
|
||||
expect((scrollable.scrollTop - 10).abs(), lessThan(2));
|
||||
}
|
||||
|
||||
// Initially, starting with a neutral scroll position, everything should be
|
||||
// Initially, starting at "scrollTop" 0, everything should be
|
||||
// in browser gesture mode, react to DOM scroll events, and generate
|
||||
// semantic actions.
|
||||
expectNeutralPosition();
|
||||
expect(scrollable.scrollTop, 0);
|
||||
expect(semantics().gestureMode, GestureMode.browserGestures);
|
||||
scrollable.scrollTop = 20;
|
||||
expect(scrollable.scrollTop, 20);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(actionLog, hasLength(1));
|
||||
final capturedEvent = actionLog.removeLast();
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollUp);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollToOffset);
|
||||
|
||||
// Now, starting with a neutral mode, observing a DOM "wheel" event should
|
||||
// Now, starting at the "scrollTop" 20 we set, observing a DOM "wheel" event should
|
||||
// swap into pointer event mode, and the scrollable becomes a plain clip,
|
||||
// i.e. `overflow: hidden`.
|
||||
expectNeutralPosition();
|
||||
expect(scrollable.scrollTop, 20);
|
||||
expect(semantics().gestureMode, GestureMode.browserGestures);
|
||||
expect(scrollable.style.overflowY, 'scroll');
|
||||
|
||||
@ -1870,8 +1877,8 @@ void _testHorizontalScrolling() {
|
||||
expect(scrollable, isNotNull);
|
||||
|
||||
// When there's less content than the available size the neutral
|
||||
// scrollLeft is still a positive number.
|
||||
expect(scrollable.scrollLeft, isPositive);
|
||||
// scrollLeft is still 0.
|
||||
expect(scrollable.scrollLeft, 0);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@ -1924,17 +1931,7 @@ void _testHorizontalScrolling() {
|
||||
|
||||
final DomElement scrollable = findScrollable(owner());
|
||||
expect(scrollable, isNotNull);
|
||||
|
||||
// When there's more content than the available size the neutral scrollTop
|
||||
// is greater than 0 with a maximum of 10.
|
||||
int browserMaxScrollDiff = 0;
|
||||
// The max scroll value varies between `9` and `10` for Safari desktop
|
||||
// browsers.
|
||||
if (ui_web.browser.browserEngine == ui_web.BrowserEngine.webkit &&
|
||||
ui_web.browser.operatingSystem == ui_web.OperatingSystem.macOs) {
|
||||
browserMaxScrollDiff = 1;
|
||||
}
|
||||
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(scrollable.scrollLeft, 0);
|
||||
|
||||
Future<ui.SemanticsActionEvent> capturedEventFuture = captureSemanticsEvent();
|
||||
scrollable.scrollLeft = 20;
|
||||
@ -1942,21 +1939,44 @@ void _testHorizontalScrolling() {
|
||||
ui.SemanticsActionEvent capturedEvent = await capturedEventFuture;
|
||||
|
||||
expect(capturedEvent.nodeId, 0);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollLeft);
|
||||
expect(capturedEvent.arguments, isNull);
|
||||
// Engine semantics returns scroll position back to neutral.
|
||||
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollToOffset);
|
||||
expect(capturedEvent.arguments, isNotNull);
|
||||
final Float64List expectedOffset = Float64List(2);
|
||||
expectedOffset[0] = 20.0;
|
||||
expectedOffset[1] = 0.0;
|
||||
Float64List message =
|
||||
const StandardMessageCodec().decodeMessage(capturedEvent.arguments! as ByteData)
|
||||
as Float64List;
|
||||
expect(message, expectedOffset);
|
||||
|
||||
// Update scrollPosition to scrollLeft value.
|
||||
final ui.SemanticsUpdateBuilder builder2 = ui.SemanticsUpdateBuilder();
|
||||
updateNode(
|
||||
builder2,
|
||||
scrollPosition: 20.0,
|
||||
flags: 0 | ui.SemanticsFlag.hasImplicitScrolling.index,
|
||||
actions: 0 | ui.SemanticsAction.scrollLeft.index | ui.SemanticsAction.scrollRight.index,
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 50, 100),
|
||||
childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
|
||||
childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
|
||||
);
|
||||
owner().updateSemantics(builder2.build());
|
||||
|
||||
capturedEventFuture = captureSemanticsEvent();
|
||||
scrollable.scrollLeft = 5;
|
||||
capturedEvent = await capturedEventFuture;
|
||||
|
||||
expect(scrollable.scrollLeft >= (5 - browserMaxScrollDiff), isTrue);
|
||||
expect(scrollable.scrollLeft, 5);
|
||||
expect(capturedEvent.nodeId, 0);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollRight);
|
||||
expect(capturedEvent.arguments, isNull);
|
||||
// Engine semantics returns scroll top back to neutral.
|
||||
expect(scrollable.scrollLeft >= (10 - browserMaxScrollDiff), isTrue);
|
||||
expect(capturedEvent.type, ui.SemanticsAction.scrollToOffset);
|
||||
expect(capturedEvent.arguments, isNotNull);
|
||||
expectedOffset[0] = 5.0;
|
||||
expectedOffset[1] = 0.0;
|
||||
message =
|
||||
const StandardMessageCodec().decodeMessage(capturedEvent.arguments! as ByteData)
|
||||
as Float64List;
|
||||
expect(message, expectedOffset);
|
||||
});
|
||||
}
|
||||
|
||||
|
207
examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart
Normal file
207
examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart
Normal file
@ -0,0 +1,207 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
/// Flutter code sample for [SliverEnsureSemantics].
|
||||
|
||||
void main() => runApp(const SliverEnsureSemanticsExampleApp());
|
||||
|
||||
class SliverEnsureSemanticsExampleApp extends StatelessWidget {
|
||||
const SliverEnsureSemanticsExampleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(home: SliverEnsureSemanticsExample());
|
||||
}
|
||||
}
|
||||
|
||||
class SliverEnsureSemanticsExample extends StatefulWidget {
|
||||
const SliverEnsureSemanticsExample({super.key});
|
||||
|
||||
@override
|
||||
State<SliverEnsureSemanticsExample> createState() => _SliverEnsureSemanticsExampleState();
|
||||
}
|
||||
|
||||
class _SliverEnsureSemanticsExampleState extends State<SliverEnsureSemanticsExample> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.colorScheme.inversePrimary,
|
||||
title: const Text('SliverEnsureSemantics Demo'),
|
||||
),
|
||||
body: Center(
|
||||
child: CustomScrollView(
|
||||
semanticChildCount: 106,
|
||||
slivers: <Widget>[
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 0,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Steps to reproduce', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
const Text('Issue description'),
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Expected Results', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Actual Results', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Code Sample', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Screenshots', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
Semantics(
|
||||
header: true,
|
||||
headingLevel: 3,
|
||||
child: Text('Logs', style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFixedExtentList(
|
||||
itemExtent: 44.0,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return Card(
|
||||
child: Padding(padding: const EdgeInsets.all(8.0), child: Text('Item $index')),
|
||||
);
|
||||
},
|
||||
childCount: 50,
|
||||
semanticIndexOffset: 1,
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 51,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Semantics(header: true, child: const Text('Footer 1')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 52,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Semantics(header: true, child: const Text('Footer 2')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 53,
|
||||
child: Semantics(link: true, child: const Text('Link #1')),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 54,
|
||||
child: OverflowBar(
|
||||
children: <Widget>[
|
||||
TextButton(onPressed: () {}, child: const Text('Button 1')),
|
||||
TextButton(onPressed: () {}, child: const Text('Button 2')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 55,
|
||||
child: Semantics(link: true, child: const Text('Link #2')),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverSemanticsList(
|
||||
sliver: SliverFixedExtentList(
|
||||
itemExtent: 44.0,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return Semantics(
|
||||
role: SemanticsRole.listItem,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text('Second List Item $index'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: 50,
|
||||
semanticIndexOffset: 56,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverEnsureSemantics(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: IndexedSemantics(
|
||||
index: 107,
|
||||
child: Semantics(link: true, child: const Text('Link #3')),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A sliver that assigns the role of SemanticsRole.list to its sliver child.
|
||||
class SliverSemanticsList extends SingleChildRenderObjectWidget {
|
||||
const SliverSemanticsList({super.key, required Widget sliver}) : super(child: sliver);
|
||||
|
||||
@override
|
||||
RenderSliverSemanticsList createRenderObject(BuildContext context) => RenderSliverSemanticsList();
|
||||
}
|
||||
|
||||
class RenderSliverSemanticsList extends RenderProxySliver {
|
||||
@override
|
||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||
super.describeSemanticsConfiguration(config);
|
||||
config.role = SemanticsRole.list;
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter_api_samples/widgets/sliver/sliver_ensure_semantics.0.dart' as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('SliverEnsureSemantics example', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.SliverEnsureSemanticsExampleApp());
|
||||
|
||||
expect(find.text('SliverEnsureSemantics Demo'), findsOneWidget);
|
||||
});
|
||||
}
|
@ -42,6 +42,14 @@ abstract class RenderProxySliver extends RenderSliver
|
||||
this.child = child;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect get semanticBounds {
|
||||
if (child != null) {
|
||||
return child!.semanticBounds;
|
||||
}
|
||||
return super.semanticBounds;
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderObject child) {
|
||||
if (child.parentData is! SliverPhysicalParentData) {
|
||||
|
@ -5,6 +5,7 @@
|
||||
/// @docImport 'package:flutter/material.dart';
|
||||
///
|
||||
/// @docImport 'proxy_box.dart';
|
||||
/// @docImport 'proxy_sliver.dart';
|
||||
/// @docImport 'sliver_fill.dart';
|
||||
/// @docImport 'sliver_grid.dart';
|
||||
/// @docImport 'sliver_list.dart';
|
||||
@ -1306,6 +1307,28 @@ List<DiagnosticsNode> _debugCompareFloats(
|
||||
/// than zero, then it should override [childCrossAxisPosition]. For example
|
||||
/// [RenderSliverGrid] overrides this method.
|
||||
abstract class RenderSliver extends RenderObject {
|
||||
/// Whether this sliver should be included in the semantics tree.
|
||||
///
|
||||
/// This value is used by [RenderViewportBase] to ensure a sliver is
|
||||
/// included in the semantics tree regardless of its geometry.
|
||||
///
|
||||
/// A [RenderSliver] should override this value to `true` to ensure
|
||||
/// its child is included in the semantics tree. For example if your
|
||||
/// sliver is under a [RenderViewport] you may want to wrap it with
|
||||
/// a [SliverEnsureSemantics] to ensure that:
|
||||
///
|
||||
/// 1. It is still visited by [RenderViewportBase.visitChildrenForSemantics]
|
||||
/// regardless of its geometry. This includes cases where your sliver is outside
|
||||
/// the current viewport and cache extent.
|
||||
/// 2. Its semantic information is not clipped out by the [RenderViewport] in
|
||||
/// [RenderViewportBase.describeSemanticsClip] or [RenderViewportBase.describeApproximatePaintClip].
|
||||
///
|
||||
/// If a given [RenderSliver] does not provide a valid [semanticBounds] it will still
|
||||
/// be dropped from the semantics tree.
|
||||
///
|
||||
/// Defaults to `false`.
|
||||
bool get ensureSemantics => false;
|
||||
|
||||
// layout input
|
||||
@override
|
||||
SliverConstraints get constraints => super.constraints as SliverConstraints;
|
||||
|
@ -425,6 +425,18 @@ abstract class RenderSliverMultiBoxAdaptor extends RenderSliver
|
||||
// Do not visit children in [_keepAliveBucket].
|
||||
}
|
||||
|
||||
@override
|
||||
Rect get semanticBounds {
|
||||
// If we laid out the first child but this sliver is not visible, we report the
|
||||
// semantic bounds of this sliver as the bounds of the first child. This is necessary
|
||||
// for accessibility technologies to reach this sliver even when it is outside
|
||||
// the current viewport and cache extent.
|
||||
if (geometry != null && !geometry!.visible && firstChild != null && firstChild!.hasSize) {
|
||||
return firstChild!.paintBounds;
|
||||
}
|
||||
return super.semanticBounds;
|
||||
}
|
||||
|
||||
/// Called during layout to create and add the child with the given index and
|
||||
/// scroll offset.
|
||||
///
|
||||
|
@ -314,7 +314,10 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
|
||||
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
|
||||
childrenInPaintOrder
|
||||
.where(
|
||||
(RenderSliver sliver) => sliver.geometry!.visible || sliver.geometry!.cacheExtent > 0.0,
|
||||
(RenderSliver sliver) =>
|
||||
sliver.geometry!.visible ||
|
||||
sliver.geometry!.cacheExtent > 0.0 ||
|
||||
sliver.ensureSemantics,
|
||||
)
|
||||
.forEach(visitor);
|
||||
}
|
||||
@ -671,6 +674,12 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
|
||||
|
||||
@override
|
||||
Rect? describeApproximatePaintClip(RenderSliver child) {
|
||||
if (child.ensureSemantics && !(child.geometry!.visible || child.geometry!.cacheExtent > 0.0)) {
|
||||
// Return null here so we don't end up clipping out a semantics node rect
|
||||
// for a sliver child when we explicitly want it to be included in the semantics tree.
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (clipBehavior) {
|
||||
case Clip.none:
|
||||
return null;
|
||||
@ -716,7 +725,14 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
|
||||
}
|
||||
|
||||
@override
|
||||
Rect describeSemanticsClip(RenderSliver? child) {
|
||||
Rect? describeSemanticsClip(RenderSliver? child) {
|
||||
if (child != null &&
|
||||
child.ensureSemantics &&
|
||||
!(child.geometry!.visible || child.geometry!.cacheExtent > 0.0)) {
|
||||
// Return null here so we don't end up clipping out a semantics node rect
|
||||
// for a sliver child when we explicitly want it to be included in the semantics tree.
|
||||
return null;
|
||||
}
|
||||
if (_calculatedCacheExtent == null) {
|
||||
return semanticBounds;
|
||||
}
|
||||
|
@ -1771,3 +1771,48 @@ class _SliverMainAxisGroupElement extends MultiChildRenderObjectElement {
|
||||
.forEach(visitor);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sliver that ensures its sliver child is included in the semantics tree.
|
||||
///
|
||||
/// This sliver ensures that its child sliver is still visited by the [RenderViewport]
|
||||
/// when constructing the semantics tree, and is not clipped out of the semantics tree by
|
||||
/// the [RenderViewport] when it is outside the current viewport and outside the cache extent.
|
||||
///
|
||||
/// The child sliver may still be excluded from the semantics tree if its [RenderSliver] does
|
||||
/// not provide a valid [RenderSliver.semanticBounds]. This sliver does not guarantee its
|
||||
/// child sliver is laid out.
|
||||
///
|
||||
/// Be mindful when positioning [SliverEnsureSemantics] in a [CustomScrollView] after slivers that build
|
||||
/// their children lazily, like [SliverList]. Lazy slivers might underestimate the total scrollable size (scroll
|
||||
/// extent) before the [SliverEnsureSemantics] widget. This inaccuracy can cause problems for assistive
|
||||
/// technologies (e.g., screen readers), which rely on a correct scroll extent to navigate properly; they
|
||||
/// might fail to scroll accurately to the content wrapped by [SliverEnsureSemantics].
|
||||
///
|
||||
/// To avoid this potential issue and ensure the scroll extent is calculated accurately up to this sliver,
|
||||
/// it's recommended to use slivers that can determine their extent precisely beforehand. Instead of
|
||||
/// [SliverList], consider using [SliverFixedExtentList], [SliverVariedExtentList], or
|
||||
/// [SliverPrototypeExtentList]. If using [SliverGrid], ensure it employs a delegate such as
|
||||
/// [SliverGridDelegateWithFixedCrossAxisCount] or [SliverGridDelegateWithMaxCrossAxisExtent].
|
||||
/// Using these alternatives guarantees that the scrollable area's size is known accurately, allowing
|
||||
/// assistive technologies to function correctly with [SliverEnsureSemantics].
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This example shows how to use [SliverEnsureSemantics] to keep certain headers and lists
|
||||
/// available to assistive technologies while they are outside the current viewport and cache extent.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart **
|
||||
/// {@end-tool}
|
||||
// TODO(Renzo-Olivares): Investigate potential solutions for revealing off screen items, https://github.com/flutter/flutter/issues/166703.
|
||||
class SliverEnsureSemantics extends SingleChildRenderObjectWidget {
|
||||
/// Creates a sliver that ensures its sliver child is included in the semantics tree.
|
||||
const SliverEnsureSemantics({super.key, required Widget sliver}) : super(child: sliver);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _RenderSliverEnsureSemantics();
|
||||
}
|
||||
|
||||
/// Ensures its sliver child is included in the semantics tree.
|
||||
class _RenderSliverEnsureSemantics extends RenderProxySliver {
|
||||
@override
|
||||
bool get ensureSemantics => true;
|
||||
}
|
||||
|
@ -726,7 +726,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
Widget boilerPlate(Widget sliver) {
|
||||
Widget boilerPlate(List<Widget> slivers) {
|
||||
return Localizations(
|
||||
locale: const Locale('en', 'us'),
|
||||
delegates: const <LocalizationsDelegate<dynamic>>[
|
||||
@ -735,10 +735,7 @@ void main() {
|
||||
],
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: CustomScrollView(slivers: <Widget>[sliver]),
|
||||
),
|
||||
child: MediaQuery(data: const MediaQueryData(), child: CustomScrollView(slivers: slivers)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -747,7 +744,7 @@ void main() {
|
||||
testWidgets('offstage true', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(const SliverOffstage(sliver: SliverToBoxAdapter(child: Text('a')))),
|
||||
boilerPlate(<Widget>[const SliverOffstage(sliver: SliverToBoxAdapter(child: Text('a')))]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(0));
|
||||
@ -762,9 +759,9 @@ void main() {
|
||||
testWidgets('offstage false', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOffstage(offstage: false, sliver: SliverToBoxAdapter(child: Text('a'))),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -783,12 +780,12 @@ void main() {
|
||||
|
||||
// Opacity 1.0: Semantics and painting
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 1.0,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -796,12 +793,12 @@ void main() {
|
||||
|
||||
// Opacity 0.0: Nothing
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.0,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(0));
|
||||
@ -809,13 +806,13 @@ void main() {
|
||||
|
||||
// Opacity 0.0 with semantics: Just semantics
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.0,
|
||||
alwaysIncludeSemantics: true,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -823,12 +820,12 @@ void main() {
|
||||
|
||||
// Opacity 0.0 without semantics: Nothing
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.0,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(0));
|
||||
@ -836,12 +833,12 @@ void main() {
|
||||
|
||||
// Opacity 0.1: Semantics and painting
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.1,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -849,12 +846,12 @@ void main() {
|
||||
|
||||
// Opacity 0.1 without semantics: Still has semantics and painting
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.1,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -862,13 +859,13 @@ void main() {
|
||||
|
||||
// Opacity 0.1 with semantics: Semantics and painting
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverOpacity(
|
||||
sliver: SliverToBoxAdapter(child: Text('a', textDirection: TextDirection.rtl)),
|
||||
opacity: 0.1,
|
||||
alwaysIncludeSemantics: true,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
@ -883,7 +880,7 @@ void main() {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
final List<String> events = <String>[];
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
SliverIgnorePointer(
|
||||
ignoringSemantics: false,
|
||||
sliver: SliverToBoxAdapter(
|
||||
@ -895,7 +892,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
await tester.tap(find.byType(GestureDetector), warnIfMissed: false);
|
||||
@ -907,7 +904,7 @@ void main() {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
final List<String> events = <String>[];
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
SliverIgnorePointer(
|
||||
ignoring: false,
|
||||
ignoringSemantics: true,
|
||||
@ -920,7 +917,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(0));
|
||||
await tester.tap(find.byType(GestureDetector));
|
||||
@ -931,13 +928,13 @@ void main() {
|
||||
testWidgets('ignoring only block semantics actions', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
SliverIgnorePointer(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: GestureDetector(child: const Text('a'), onTap: () {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(semantics, includesNodeWith(label: 'a', actions: <SemanticsAction>[]));
|
||||
semantics.dispose();
|
||||
@ -947,7 +944,7 @@ void main() {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
final List<String> events = <String>[];
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
SliverIgnorePointer(
|
||||
ignoringSemantics: true,
|
||||
sliver: SliverToBoxAdapter(
|
||||
@ -959,7 +956,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(0));
|
||||
await tester.tap(find.byType(GestureDetector), warnIfMissed: false);
|
||||
@ -971,7 +968,7 @@ void main() {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
final List<String> events = <String>[];
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(
|
||||
boilerPlate(<Widget>[
|
||||
SliverIgnorePointer(
|
||||
ignoring: false,
|
||||
ignoringSemantics: false,
|
||||
@ -984,7 +981,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(semantics.nodesWith(label: 'a'), hasLength(1));
|
||||
await tester.tap(find.byType(GestureDetector));
|
||||
@ -993,6 +990,40 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SliverEnsureSemantics - ', () {
|
||||
testWidgets('ensure semantics', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
await tester.pumpWidget(
|
||||
boilerPlate(<Widget>[
|
||||
const SliverEnsureSemantics(sliver: SliverToBoxAdapter(child: Text('a'))),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text('Lorem Ipsum $index'),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: 50,
|
||||
semanticIndexOffset: 1,
|
||||
),
|
||||
),
|
||||
const SliverEnsureSemantics(sliver: SliverToBoxAdapter(child: Text('b'))),
|
||||
]),
|
||||
);
|
||||
|
||||
// Even though 'b' is outside of the Viewport and cacheExtent, since it is
|
||||
// wrapped with a `SliverEnsureSemantics` it will still be included in the
|
||||
// semantics tree.
|
||||
expect(semantics.nodesWith(label: 'b'), hasLength(1));
|
||||
expect(find.text('b'), findsNothing);
|
||||
expect(find.byType(SliverEnsureSemantics, skipOffstage: false), findsNWidgets(2));
|
||||
semantics.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('SliverList handles 0 scrollOffsetCorrection', (WidgetTester tester) async {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/62198
|
||||
await tester.pumpWidget(
|
||||
|
Loading…
Reference in New Issue
Block a user