mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
This reverts commit 2fc716d, and updates the cross-axis size of the `_scrollOverflowElement` to be 1px (non-zero), so it is taken into account by the scrollable elements scrollHeight. 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
6acc8d332b
commit
fc12bec5ec
@ -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,42 @@ 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();
|
||||
// The cross axis size should be non-zero so it is taken into
|
||||
// account in the scrollable elements scrollHeight.
|
||||
..width = '1px'
|
||||
..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'
|
||||
// The cross axis size should be non-zero so it is taken into
|
||||
// account in the scrollable elements scrollHeight.
|
||||
..height = '1px';
|
||||
semanticsObject
|
||||
..verticalScrollAdjustment = 0.0
|
||||
..horizontalScrollAdjustment = _effectiveNeutralScrollPosition.toDouble();
|
||||
..horizontalScrollAdjustment = element.scrollLeft;
|
||||
} else {
|
||||
_scrollOverflowElement.style
|
||||
..transform = 'translate(0px,0px)'
|
||||
@ -240,7 +221,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…
x
Reference in New Issue
Block a user