diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart index c5fb4cf2025..90814fc0042 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart @@ -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; diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 10420cc21e2..f1b153b9f2a 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -1612,7 +1612,7 @@ void _testVerticalScrolling() { '''); 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 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([1, 2, 3]), + childrenInTraversalOrder: Int32List.fromList([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.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 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([1, 2, 3]), + childrenInTraversalOrder: Int32List.fromList([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); }); } diff --git a/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart b/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart new file mode 100644 index 00000000000..defdb691c23 --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart @@ -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 createState() => _SliverEnsureSemanticsExampleState(); +} + +class _SliverEnsureSemanticsExampleState extends State { + @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: [ + SliverEnsureSemantics( + sliver: SliverToBoxAdapter( + child: IndexedSemantics( + index: 0, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + 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; + } +} diff --git a/examples/api/test/widgets/sliver/sliver_ensure_semantics.0_test.dart b/examples/api/test/widgets/sliver/sliver_ensure_semantics.0_test.dart new file mode 100644 index 00000000000..651d98d6f8d --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_ensure_semantics.0_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/rendering/proxy_sliver.dart b/packages/flutter/lib/src/rendering/proxy_sliver.dart index 84be9b46ca1..a1d8f6353ba 100644 --- a/packages/flutter/lib/src/rendering/proxy_sliver.dart +++ b/packages/flutter/lib/src/rendering/proxy_sliver.dart @@ -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) { diff --git a/packages/flutter/lib/src/rendering/sliver.dart b/packages/flutter/lib/src/rendering/sliver.dart index 0a944edba31..c0f5d99ee9d 100644 --- a/packages/flutter/lib/src/rendering/sliver.dart +++ b/packages/flutter/lib/src/rendering/sliver.dart @@ -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 _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; diff --git a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart index 90885e4c110..4f6071eaef2 100644 --- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart +++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart @@ -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. /// diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 09af6c11f1c..2661717e451 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -314,7 +314,10 @@ abstract class RenderViewportBase 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 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 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; } diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index 4f96cb9f169..f6d14ebf789 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -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; +} diff --git a/packages/flutter/test/widgets/slivers_test.dart b/packages/flutter/test/widgets/slivers_test.dart index 66399e23bf4..de4b6d8bb5d 100644 --- a/packages/flutter/test/widgets/slivers_test.dart +++ b/packages/flutter/test/widgets/slivers_test.dart @@ -726,7 +726,7 @@ void main() { }, ); - Widget boilerPlate(Widget sliver) { + Widget boilerPlate(List slivers) { return Localizations( locale: const Locale('en', 'us'), delegates: const >[ @@ -735,10 +735,7 @@ void main() { ], child: Directionality( textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: CustomScrollView(slivers: [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([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([ 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([ 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([ 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([ 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([ 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([ 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([ 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([ 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 events = []; await tester.pumpWidget( - boilerPlate( + boilerPlate([ 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 events = []; await tester.pumpWidget( - boilerPlate( + boilerPlate([ 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([ SliverIgnorePointer( sliver: SliverToBoxAdapter( child: GestureDetector(child: const Text('a'), onTap: () {}), ), ), - ), + ]), ); expect(semantics, includesNodeWith(label: 'a', actions: [])); semantics.dispose(); @@ -947,7 +944,7 @@ void main() { final SemanticsTester semantics = SemanticsTester(tester); final List events = []; await tester.pumpWidget( - boilerPlate( + boilerPlate([ 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 events = []; await tester.pumpWidget( - boilerPlate( + boilerPlate([ 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([ + 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(