From 825e901e0096d5e0f6fbbc008f5a6780cbe39d5d Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Wed, 13 Mar 2024 11:34:25 -0500 Subject: [PATCH] Set cacheExtent for SliverFillRemaining widget (#143612) When a Sliver with items is outside of the Viewport, but within the Viewport's `cacheExtent`, the framework should create SemanticNodes for the items even though they are out of view. However, for this to work, the Sliver's geometry must have a `cacheExtent` (how much space the sliver took up of the Viewport's `cacheExtent`) greater than 0, otherwise it is [excluded](https://github.com/flutter/flutter/blob/f01ce9f4cb41beff7b85122b5fcf1228bb655a87/packages/flutter/lib/src/rendering/viewport.dart#L311-L315). `SliverFillRemaining` widgets that fall outside the viewport did not have this set and therefore were being excluded when SemanticNodes were created, even if they were within the Viewport's `cacheExtent`. This PR sets the `cacheExtent` for `SliverFillRemaining` widgets. In addition, `RenderSliverFillRemainingWithScrollable` would get dropped from the semantic tree because it's child had a size of 0 when outside the remaining paint extent. To fix, we give the child a `maxExtent` of the sliver's `cacheExtent` if it's outside the remaining paint extent but within the viewport's cacheExtent. Fixes https://github.com/flutter/flutter/issues/142065. Definitions: * `RenderViewport.cacheExtent`: ```dart /// The viewport has an area before and after the visible area to cache items /// that are about to become visible when the user scrolls. /// /// Items that fall in this cache area are laid out even though they are not /// (yet) visible on screen. The [cacheExtent] describes how many pixels /// the cache area extends before the leading edge and after the trailing edge /// of the viewport. /// /// The total extent, which the viewport will try to cover with children, is /// [cacheExtent] before the leading edge + extent of the main axis + /// [cacheExtent] after the trailing edge. /// /// The cache area is also used to implement implicit accessibility scrolling /// on iOS: When the accessibility focus moves from an item in the visible /// viewport to an invisible item in the cache area, the framework will bring /// that item into view with an (implicit) scroll action. ``` * `SliverGeometry.cacheExtent`: ```dart /// How many pixels the sliver has consumed in the /// [SliverConstraints.remainingCacheExtent]. ``` * `SliverContraints.remainingCacheExtent`: ```dart /// Describes how much content the sliver should provide starting from the /// [cacheOrigin]. /// /// Not all content in the [remainingCacheExtent] will be visible as some /// of it might fall into the cache area of the viewport. /// /// Each sliver should start laying out content at the [cacheOrigin] and /// try to provide as much content as the [remainingCacheExtent] allows. ``` --- .../lib/src/rendering/sliver_fill.dart | 23 +- .../test/rendering/sliver_cache_test.dart | 921 ++++++++++++++++++ .../widgets/sliver_fill_remaining_test.dart | 386 ++++++++ 3 files changed, 1329 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/rendering/sliver_fill.dart b/packages/flutter/lib/src/rendering/sliver_fill.dart index ef5f89b9b2f..69c55a153ce 100644 --- a/packages/flutter/lib/src/rendering/sliver_fill.dart +++ b/packages/flutter/lib/src/rendering/sliver_fill.dart @@ -83,21 +83,36 @@ class RenderSliverFillRemainingWithScrollable extends RenderSliverSingleBoxAdapt final SliverConstraints constraints = this.constraints; final double extent = constraints.remainingPaintExtent - math.min(constraints.overlap, 0.0); + final double cacheExtent = calculateCacheOffset( + constraints, + from: 0.0, + to: constraints.viewportMainAxisExtent, + ); if (child != null) { + double maxExtent = extent; + + // If sliver has no extent, but is within viewport's cacheExtent, use the + // sliver's cacheExtent as the maxExtent so that it does not get dropped + // from the semantic tree. + if (extent == 0 && cacheExtent > 0) { + maxExtent = cacheExtent; + } child!.layout(constraints.asBoxConstraints( minExtent: extent, - maxExtent: extent, + maxExtent: maxExtent, )); } final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent); assert(paintedChildSize.isFinite); assert(paintedChildSize >= 0.0); + geometry = SliverGeometry( scrollExtent: constraints.viewportMainAxisExtent, paintExtent: paintedChildSize, maxPaintExtent: paintedChildSize, hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, ); if (child != null) { setChildParentData(child!, constraints, geometry!); @@ -162,11 +177,14 @@ class RenderSliverFillRemaining extends RenderSliverSingleBoxAdapter { final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent); assert(paintedChildSize.isFinite); assert(paintedChildSize >= 0.0); + + final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: extent); geometry = SliverGeometry( scrollExtent: extent, paintExtent: paintedChildSize, maxPaintExtent: paintedChildSize, hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, ); if (child != null) { setChildParentData(child!, constraints, geometry!); @@ -235,11 +253,14 @@ class RenderSliverFillRemainingAndOverscroll extends RenderSliverSingleBoxAdapte final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent); assert(paintedChildSize.isFinite); assert(paintedChildSize >= 0.0); + + final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: extent); geometry = SliverGeometry( scrollExtent: extent, paintExtent: math.min(maxExtent, constraints.remainingPaintExtent), maxPaintExtent: maxExtent, hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, ); if (child != null) { setChildParentData(child!, constraints, geometry!); diff --git a/packages/flutter/test/rendering/sliver_cache_test.dart b/packages/flutter/test/rendering/sliver_cache_test.dart index 3f287961d46..d46da5f13a6 100644 --- a/packages/flutter/test/rendering/sliver_cache_test.dart +++ b/packages/flutter/test/rendering/sliver_cache_test.dart @@ -872,6 +872,914 @@ void main() { visible: true, ); }); + + group('RenderSliverFillRemaining calculates correct geometry', () { + test('when initially in view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = 400; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemaining( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: firstSliverHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemaining: + // * The child has a minExtent and maxExtent of the remaining space of the + // viewportMainAxisExtent or the height of the child - whichever is larger. + // * The sliver has a paintExtent of the child's minExtent/maxExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of the child's minExtent/maxExtent or the + // remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + const double extentOfChild = viewportHeight - firstSliverHeight; + double remainingPaintExtent = viewportHeight - firstSliverHeight; + double remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: extentOfChild, + cacheExtent: extentOfChild, + visible: true, + ); + + // Overscroll + const double scrollOffset = 50; + root.offset = ViewportOffset.fixed(scrollOffset); + pumpFrame(); + remainingPaintExtent = viewportHeight - firstSliverHeight + scrollOffset; + remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight + scrollOffset; + + // With RenderSliverFillRemaining, when you overscroll, the extent of the + // child does not change and therefore neither does paintExtent or cacheExtent. + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: extentOfChild, + cacheExtent: extentOfChild, + visible: true, + ); + }); + + test('when scrolled into view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = beginningViewportCacheExtent; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemaining( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: viewportHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemaining: + // * The child has a minExtent and maxExtent of the remaining space of the + // viewportMainAxisExtent or the height of the child - whichever is larger. + // * The sliver has a paintExtent of the child's minExtent/maxExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of the child's minExtent/maxExtent or the + // remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + const double extentOfChild = sliverFillRemainingChildHeight; + double remainingPaintExtent = 0; + double remainingCacheExtent = 0; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: remainingCacheExtent, + visible: false, + ); + + // Scroll so RenderSliverFillRemaining is not within viewport, but is + // within remainingCacheExtent. + root.offset = ViewportOffset.fixed(cacheExtent); + pumpFrame(); + remainingPaintExtent = 0; + remainingCacheExtent = cacheExtent; + + // When within the remainingCacheExtent, the sliver will have a cacheExtent + // of the child's extent. + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: extentOfChild, + visible: false, + ); + + // Scroll so RenderSliverFillRemaining is partially within viewport. + root.offset = ViewportOffset.fixed(cacheExtent + 50); + pumpFrame(); + remainingPaintExtent = 50; + remainingCacheExtent = cacheExtent + 50; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: extentOfChild, + visible: true, + ); + + // Scroll so RenderSliverFillRemaining is completely within viewport. + root.offset = ViewportOffset.fixed(cacheExtent + sliverFillRemainingChildHeight); + pumpFrame(); + remainingPaintExtent = sliverFillRemainingChildHeight; + remainingCacheExtent = cacheExtent + sliverFillRemainingChildHeight; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: extentOfChild, + cacheExtent: extentOfChild, + visible: true, + ); + + // Overscroll + root.offset = ViewportOffset.fixed(cacheExtent + sliverFillRemainingChildHeight + 50); + pumpFrame(); + remainingPaintExtent = sliverFillRemainingChildHeight + 50; + remainingCacheExtent = cacheExtent + sliverFillRemainingChildHeight + 50; + + // With RenderSliverFillRemaining, when you overscroll, the extent of the + // child does not change and therefore neither does paintExtent or cacheExtent. + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: extentOfChild, + minHeight: extentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: extentOfChild, + cacheExtent: extentOfChild, + visible: true, + ); + }); + }); + + group('RenderSliverFillRemainingAndOverscroll calculates correct geometry', () { + test('when initially in view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = 400; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemainingAndOverscroll( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: firstSliverHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemainingAndOverscroll: + // * The child has a minExtent of the remaining space of the viewportMainAxisExtent + // or the height of the child - whichever is larger. + // * The child has a maxExtent of the remaining space of the viewportMainAxisExtent, + // the height of the child, or the remainingPaintExtent - whichever is larger. + // * The sliver has a paintExtent of the child's maxExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of the child's minExtent or the + // remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + const double minExtentOfChild = viewportHeight - firstSliverHeight; + double maxExtentOfChild = viewportHeight - firstSliverHeight; + double remainingPaintExtent = viewportHeight - firstSliverHeight; + double remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: maxExtentOfChild, + cacheExtent: minExtentOfChild, + visible: true, + ); + + // Overscroll + const double scrollOffset = 50; + root.offset = ViewportOffset.fixed(scrollOffset); + pumpFrame(); + remainingPaintExtent = viewportHeight - firstSliverHeight + scrollOffset; + remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight + scrollOffset; + + // When you overscroll, the child's maxExtent is the + // remainingPaintExtent, since it's the higher value. + maxExtentOfChild = remainingPaintExtent; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: maxExtentOfChild, + cacheExtent: minExtentOfChild, + visible: true, + ); + }); + + test('when scrolled into view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = beginningViewportCacheExtent; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemainingAndOverscroll( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: viewportHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemainingAndOverscroll: + // * The child has a minExtent of the remaining space of the viewportMainAxisExtent + // or the height of the child - whichever is larger. + // * The child has a maxExtent of the remaining space of the viewportMainAxisExtent, + // the height of the child, or the remainingPaintExtent - whichever is larger. + // * The sliver has a paintExtent of the child's maxExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of the child's minExtent or the + // remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + const double minExtentOfChild = sliverFillRemainingChildHeight; + double maxExtentOfChild = sliverFillRemainingChildHeight; + double remainingPaintExtent = 0; + double remainingCacheExtent = 0; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: remainingCacheExtent, + visible: false, + ); + + // Scroll so RenderSliverFillRemainingAndOverscroll is not within viewport, + // but is within remainingCacheExtent. + root.offset = ViewportOffset.fixed(cacheExtent); + pumpFrame(); + remainingPaintExtent = 0; + remainingCacheExtent = cacheExtent; + + // When within the remainingCacheExtent, the sliver will have a cacheExtent + // of the child's minExtent. + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: minExtentOfChild, + visible: false, + ); + + // Scroll so RenderSliverFillRemainingAndOverscroll is partially within + // the viewport. + root.offset = ViewportOffset.fixed(cacheExtent + 50); + pumpFrame(); + remainingPaintExtent = 50; + remainingCacheExtent = cacheExtent + 50; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: remainingPaintExtent, + cacheExtent: minExtentOfChild, + visible: true, + ); + + // Scroll so RenderSliverFillRemainingAndOverscroll is completely within + // the viewport. + root.offset = ViewportOffset.fixed(cacheExtent + sliverFillRemainingChildHeight); + pumpFrame(); + remainingPaintExtent = sliverFillRemainingChildHeight; + remainingCacheExtent = cacheExtent + sliverFillRemainingChildHeight; + + // When completely in view, the slivers's paintExtent is the child's + // maxExtentOfChild, since it's the smaller value. + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: maxExtentOfChild, + cacheExtent: minExtentOfChild, + visible: true, + ); + + // Overscroll + root.offset = ViewportOffset.fixed(cacheExtent + sliverFillRemainingChildHeight + 50); + pumpFrame(); + remainingPaintExtent = sliverFillRemainingChildHeight + 50; + remainingCacheExtent = cacheExtent + sliverFillRemainingChildHeight + 50; + + // When you overscroll, the child's maxExtent is the remainingPaintExtent, + // since it's the higher value. + maxExtentOfChild = remainingPaintExtent; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: maxExtentOfChild, + cacheExtent: minExtentOfChild, + visible: true, + ); + }); + }); + + group('RenderSliverFillRemainingWithScrollable calculates correct geometry', () { + test('when initially in view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = 400; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemainingWithScrollable( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: firstSliverHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemainingWithScrollable: + // * The child has a minExtent of the remainingPaintExtent. + // * If not within the viewport but within the cacheExtent, the child has + // a maxExtent of the sliver's cacheExtent. Otherwise, the child has a + // maxExtent of the remainingPaintExtent + // * The sliver has a paintExtent of the child's minExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of either the viewportMainAxisExtent or + // the remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + double remainingPaintExtent = viewportHeight - firstSliverHeight; + double remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight; + double minExtentOfChild = remainingPaintExtent; + double maxExtentOfChild = remainingPaintExtent; + + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: remainingCacheExtent, + visible: true, + ); + + // Overscroll so first sliver is partially out of view. + root.offset = ViewportOffset.fixed(50); + pumpFrame(); + remainingPaintExtent = viewportHeight - firstSliverHeight + 50; + remainingCacheExtent = beginningViewportCacheExtent - firstSliverHeight + 50; + minExtentOfChild = remainingPaintExtent; + maxExtentOfChild = remainingPaintExtent; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: remainingCacheExtent, + visible: true, + ); + + // Overscroll so only RenderSliverFillRemainingWithScrollable is visible. + root.offset = ViewportOffset.fixed(firstSliverHeight); + pumpFrame(); + remainingPaintExtent = viewportHeight; + remainingCacheExtent = beginningViewportCacheExtent; + minExtentOfChild = remainingPaintExtent; + maxExtentOfChild = remainingPaintExtent; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: viewportHeight, + visible: true, + ); + }); + + test('when scrolled into view', () { + // Viewport is 800x600 + const double viewportHeight = 600; + const double viewportWidth = 800; + const double cacheExtent = 250.0; + const double beginningViewportCacheExtent = viewportHeight + cacheExtent; + const double firstSliverHeight = beginningViewportCacheExtent; + const double sliverFillRemainingChildHeight = 100.0; + + final List slivers = [ + RenderSliverToBoxAdapter( + child: RenderSizedBox(const Size(400.0, firstSliverHeight)), + ), + RenderSliverFillRemainingWithScrollable( + child: RenderSizedBox(const Size(100.0, sliverFillRemainingChildHeight)), + ) + ]; + + final RenderViewport root = RenderViewport( + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: cacheExtent, + children: slivers, + ); + layout(root); + + final RenderSliver firstVisibleSliver = slivers[0]; + expectSliverConstraints( + sliver: firstVisibleSliver, + cacheOrigin: 0.0, + remainingPaintExtent: viewportHeight, + remainingCacheExtent: beginningViewportCacheExtent, + scrollOffset: 0.0, + ); + expectSliverGeometry( + sliver: firstVisibleSliver, + paintExtent: viewportHeight, + cacheExtent: firstSliverHeight, + visible: true, + ); + + // With RenderSliverFillRemainingWithScrollable: + // * The child has a minExtent of the remainingPaintExtent. + // * If not within the viewport but within the cacheExtent, the child has + // a maxExtent of the sliver's cacheExtent. Otherwise, the child has a + // maxExtent of the remainingPaintExtent + // * The sliver has a paintExtent of the child's minExtent or the + // remainingPaintExtent - whichever is smaller. + // * The sliver has a cacheExtent of either the viewportMainAxisExtent or + // the remainingCacheExtent - whichever is smaller. + final RenderSliverSingleBoxAdapter sliverFillRemaining = slivers[1] as RenderSliverSingleBoxAdapter; + double remainingPaintExtent = 0; + double remainingCacheExtent = 0; + double minExtentOfChild = remainingPaintExtent; + double maxExtentOfChild = remainingPaintExtent; + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: remainingCacheExtent, + visible: false, + ); + + // Scroll so RenderSliverFillRemainingWithScrollable is not within + // viewport, but is within remainingCacheExtent. + root.offset = ViewportOffset.fixed(cacheExtent); + pumpFrame(); + remainingPaintExtent = 0; + remainingCacheExtent = cacheExtent; + minExtentOfChild = remainingPaintExtent; + // When RenderSliverFillRemainingWithScrollable is completely outside the + // viewport, but is within the remainingCacheExtent, the child has a + // maxExtent of the slivers's cacheExtent. + maxExtentOfChild = remainingCacheExtent; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: remainingCacheExtent, + visible: false, + ); + + // Scroll so RenderSliverFillRemainingWithScrollable is partially within + // viewport. + root.offset = ViewportOffset.fixed(cacheExtent + 50); + pumpFrame(); + remainingPaintExtent = 50; + remainingCacheExtent = cacheExtent + 50; + minExtentOfChild = remainingPaintExtent; + maxExtentOfChild = remainingPaintExtent; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: remainingCacheExtent, + visible: true, + ); + + // Scroll so RenderSliverFillRemainingWithScrollable takes the entire + // viewport. + root.offset = ViewportOffset.fixed(firstSliverHeight); + pumpFrame(); + remainingPaintExtent = viewportHeight; + remainingCacheExtent = beginningViewportCacheExtent; + minExtentOfChild = remainingPaintExtent; + maxExtentOfChild = remainingPaintExtent; + + expectSliverConstraints( + sliver: sliverFillRemaining, + cacheOrigin: 0.0, + remainingPaintExtent: remainingPaintExtent, + remainingCacheExtent: remainingCacheExtent, + scrollOffset: 0.0, + ); + expectSliverChildConstraints( + sliver: sliverFillRemaining, + maxHeight: maxExtentOfChild, + minHeight: minExtentOfChild, + maxWidth: viewportWidth, + minWidth: viewportWidth, + ); + expectSliverGeometry( + sliver: sliverFillRemaining, + paintExtent: minExtentOfChild, + cacheExtent: viewportHeight, + visible: true, + ); + }); + }); + } void expectSliverConstraints({ @@ -898,6 +1806,19 @@ void expectSliverGeometry({ expect(sliver.geometry!.visible, visible, reason: 'visible'); } +void expectSliverChildConstraints({ + required RenderSliverSingleBoxAdapter sliver, + required double maxWidth, + required double maxHeight, + required double minWidth, + required double minHeight, +}) { + expect(sliver.child!.constraints.maxWidth, maxWidth, reason: 'maxWidth'); + expect(sliver.child!.constraints.maxHeight, maxHeight, reason: 'maxHeight'); + expect(sliver.child!.constraints.minWidth, minWidth, reason: 'minWidth'); + expect(sliver.child!.constraints.minHeight, minHeight, reason: 'minHeight'); +} + class TestRenderSliverBoxChildManager extends RenderSliverBoxChildManager { TestRenderSliverBoxChildManager({ required this.children, diff --git a/packages/flutter/test/widgets/sliver_fill_remaining_test.dart b/packages/flutter/test/widgets/sliver_fill_remaining_test.dart index 90692a69394..9cef39c9163 100644 --- a/packages/flutter/test/widgets/sliver_fill_remaining_test.dart +++ b/packages/flutter/test/widgets/sliver_fill_remaining_test.dart @@ -139,6 +139,132 @@ void main() { expect(controller.offset, 150.0); expect(find.byType(Container), findsOneWidget); }); + + group('has correct semantics when', () { + testWidgets('within viewport', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight - 100, + width: 150, + ), + ), + // This sliver is within viewport + const SliverFillRemaining( + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is within the viewport, semantic nodes for + // it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: false)); + handle.dispose(); + }); + + testWidgets('outside of viewport but within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 250 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight, + width: 150, + ), + ), + // This sliver is not within viewport but is within remaining cacheExtent + const SliverFillRemaining( + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport, but is within + // cacheExtent, hidden semantic nodes for it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: true)); + handle.dispose(); + }); + + testWidgets('outside of viewport and not within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 0 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight + cacheExtent, + width: 150, + ), + ), + // This sliver is not within viewport and not within remaining cacheExtent + const SliverFillRemaining( + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport and not within + // cacheExtent, semantic nodes are not created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsNothing); + handle.dispose(); + }); + }); + }); group('hasScrollBody: false', () { @@ -357,6 +483,135 @@ void main() { expect(tester.getCenter(button).dx, equals(400.0)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + group('has correct semantics when', () { + testWidgets('within viewport', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight - 100, + width: 150, + ), + ), + // This sliver is within viewport + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is within the viewport, semantic nodes for + // it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: false)); + handle.dispose(); + }); + + testWidgets('outside of viewport but within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 250 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight, + width: 150, + ), + ), + // This sliver is not within viewport but is within remaining cacheExtent + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport, but is within + // cacheExtent, hidden semantic nodes for it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: true)); + handle.dispose(); + }); + + testWidgets('outside of viewport and not within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 0 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight + cacheExtent, + width: 150, + ), + ), + // This sliver is not within viewport and not within remaining cacheExtent + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport and not within + // cacheExtent, semantic nodes are not created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsNothing); + handle.dispose(); + }); + }); + + group('fillOverscroll: true, relevant platforms', () { testWidgets('child without size is sized by extent and overscroll', (WidgetTester tester) async { final List slivers = [ @@ -650,6 +905,137 @@ void main() { expect(tester.getBottomLeft(button).dy, equals(600.0)); expect(tester.getCenter(button).dx, equals(400.0)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + group('has correct semantics when', () { + testWidgets('within viewport', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight - 100, + width: 150, + ), + ), + // This sliver is within viewport + const SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is within the viewport, semantic nodes for + // it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: false)); + handle.dispose(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('outside of viewport but within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 250 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight, + width: 150, + ), + ), + // This sliver is not within viewport but is within remaining cacheExtent + const SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport, but is within + // cacheExtent, hidden semantic nodes for it are created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsOne); + expect(textInSliverFillRemaining, containsSemantics(isHidden: true)); + handle.dispose(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('outside of viewport and not within cache extent', (WidgetTester tester) async { + // Viewport is 800x600 + const double viewportHeight = 600; + const double cacheExtent = 250; + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + cacheExtent: cacheExtent, + slivers: [ + // This sliver takes up entire viewport and leaves 0 remaining cacheExtent + SliverToBoxAdapter( + child: Container( + color: Colors.amber, + height: viewportHeight + cacheExtent, + width: 150, + ), + ), + // This sliver is not within viewport and not within remaining cacheExtent + const SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: SizedBox( + height: 100, + child: Text('Text in SliverFillRemaining'), + ), + ), + ], + ), + ), + ); + + // When SliverFillRemaining is not in viewport and not within + // cacheExtent, semantic nodes are not created. + final SemanticsFinder textInSliverFillRemaining = find.semantics.byLabel( + 'Text in SliverFillRemaining', + ); + expect(textInSliverFillRemaining, findsNothing); + handle.dispose(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + }); }); group('fillOverscroll: true, is ignored on irrelevant platforms', () {