blob: 562452afb1efbb465296cdc90c7552ee9f413926 [file]
// 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/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// Builds a [CustomScrollView] with a pinned 100px header followed by a
/// [SliverClipRRect] and filler content. Suitable for all overlap-clipping tests.
Widget _buildOverlapScenario({
required ScrollController controller,
ClipOverlapBehavior clipOverlap = .followEdge,
double borderRadius = 20.0,
Axis scrollDirection = .vertical,
bool reverse = false,
double childExtent = 100.0,
Clip clipBehavior = .antiAlias,
}) {
final isHorizontal = scrollDirection == .horizontal;
return TestWidgetsApp(
home: CustomScrollView(
controller: controller,
scrollDirection: scrollDirection,
reverse: reverse,
slivers: <Widget>[
const SliverPersistentHeader(delegate: _SliverPersistentHeaderDelegate(), pinned: true),
SliverClipRRect(
clipOverlap: clipOverlap,
borderRadius: .all(Radius.circular(borderRadius)),
clipBehavior: clipBehavior,
sliver: SliverToBoxAdapter(
child: RepaintBoundary(
child: Container(
height: isHorizontal ? 100.0 : childExtent,
width: isHorizontal ? childExtent : null,
color: const Color(0xFF2196F3),
),
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: isHorizontal ? null : 1000.0,
width: isHorizontal ? 1000.0 : null,
),
),
],
),
);
}
void main() {
group('SliverClipRRect', () {
testWidgets('renders its child', (WidgetTester tester) async {
await tester.pumpWidget(
const TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRRect(
borderRadius: .all(Radius.circular(10.0)),
sliver: SliverToBoxAdapter(child: Text('Hello World')),
),
],
),
),
);
expect(find.text('Hello World'), findsOneWidget);
});
testWidgets('hit test respects border radius', (WidgetTester tester) async {
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRRect(
borderRadius: const .all(Radius.circular(20.0)),
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFFF44336)),
),
),
],
),
),
);
final RenderSliver renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 50.0,
crossAxisPosition: 400.0,
),
isTrue,
reason: 'Should hit center',
);
expect(
renderSliver.hitTest(SliverHitTestResult(), mainAxisPosition: 0.0, crossAxisPosition: 0.0),
isFalse,
reason: 'Should NOT hit rounded corner',
);
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: 25.0,
),
isTrue,
reason: 'Should hit inside rounded corner',
);
});
testWidgets('updates properties', (WidgetTester tester) async {
await tester.pumpWidget(
const TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRRect(
borderRadius: .all(Radius.circular(10.0)),
sliver: SliverToBoxAdapter(child: SizedBox(height: 100.0)),
),
],
),
),
);
final RenderSliverClipRRect renderObject = tester.renderObject(find.byType(SliverClipRRect));
expect(renderObject.clipOverlap, ClipOverlapBehavior.followEdge);
expect(renderObject.clipBehavior, Clip.antiAlias);
expect(renderObject.borderRadius, const BorderRadius.all(Radius.circular(10.0)));
await tester.pumpWidget(
const TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRRect(
clipOverlap: .none,
clipBehavior: .hardEdge,
borderRadius: .all(Radius.circular(20.0)),
sliver: SliverToBoxAdapter(child: SizedBox(height: 100.0)),
),
],
),
),
);
expect(renderObject.clipOverlap, ClipOverlapBehavior.none);
expect(renderObject.clipBehavior, Clip.hardEdge);
expect(renderObject.borderRadius, const BorderRadius.all(Radius.circular(20.0)));
});
testWidgets('changing borderRadius or textDirection invalidates the cached clip', (
WidgetTester tester,
) async {
await tester.pumpWidget(
const TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRRect(
borderRadius: BorderRadiusDirectional.only(topStart: .circular(10.0)),
sliver: SliverToBoxAdapter(child: SizedBox(height: 100.0)),
),
],
),
),
);
final RenderSliverClipRRect renderObject = tester.renderObject(find.byType(SliverClipRRect));
// LTR: topStart is topLeft.
expect(
renderObject.paint,
paints..clipRRect(
rrect: RRect.fromLTRBAndCorners(
0.0,
0.0,
800.0,
100.0,
topLeft: const Radius.circular(10.0),
),
),
);
// Change borderRadius and check that the painted clip geometry is updated.
renderObject.borderRadius = const BorderRadius.only(topRight: .circular(20.0));
expect(
renderObject.paint,
paints..clipRRect(
rrect: RRect.fromLTRBAndCorners(
0.0,
0.0,
800.0,
100.0,
topRight: const Radius.circular(20.0),
),
),
);
// Revert to directional border radius to test textDirection.
renderObject.borderRadius = const BorderRadiusDirectional.only(topStart: .circular(10.0));
expect(
renderObject.paint,
paints..clipRRect(
rrect: RRect.fromLTRBAndCorners(
0.0,
0.0,
800.0,
100.0,
topLeft: const Radius.circular(10.0),
),
),
);
// Change textDirection and check that the painted clip geometry is updated.
// RTL: topStart is topRight.
renderObject.textDirection = TextDirection.rtl;
expect(
renderObject.paint,
paints..clipRRect(
rrect: RRect.fromLTRBAndCorners(
0.0,
0.0,
800.0,
100.0,
topRight: const Radius.circular(10.0),
),
),
);
});
testWidgets('updates clip when overlap changes even if geometry is same', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
Directionality(
textDirection: .ltr,
child: CustomScrollView(
controller: controller,
slivers: <Widget>[
const SliverPersistentHeader(
delegate: _SliverPersistentHeaderDelegate(),
pinned: true,
),
const SliverToBoxAdapter(child: SizedBox(height: 100.0)), // Spacer
SliverClipRRect(
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
// Initial state: no overlap
// Header 0..100, Spacer 100..200, ClipRect 200..300.
expect(renderSliver.constraints.overlap, 0.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), Radius.zero),
),
);
// Scroll by 150.
// Spacer is scrolled off. ClipRect starts at y=50.
// Since Header is pinned at 0..100, it overlaps ClipRect by 50px.
controller.jumpTo(150.0);
await tester.pump();
expect(renderSliver.constraints.overlap, 50.0);
// This should fail if the bug exists because the ClipRect is still fully visible (paintExtent=100.0),
// so its geometry didn't change, and _clip was not nulled.
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 50.0, 800.0, 100.0), Radius.zero),
),
);
});
testWidgets('changing clipBehavior or clipOverlap invalidates the cached clip', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
Directionality(
textDirection: .ltr,
child: CustomScrollView(
controller: controller,
slivers: <Widget>[
const SliverPersistentHeader(
delegate: _SliverPersistentHeaderDelegate(),
pinned: true,
),
const SliverToBoxAdapter(child: SizedBox(height: 100.0)), // Spacer
SliverClipRRect(
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
// Scroll to create overlap so clipOverlap takes effect.
controller.jumpTo(150.0);
await tester.pump();
expect(renderSliver.constraints.overlap, 50.0);
expect(renderSliver.clipOverlap, ClipOverlapBehavior.followEdge);
expect(renderSliver.clipBehavior, Clip.antiAlias);
// The clip is cached and truncated by the overlap (top = 50.0).
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 50.0, 800.0, 100.0), Radius.zero),
),
);
// Mutate clipOverlap -> should call _markNeedsClip() and invalidate cache.
renderSliver.clipOverlap = .none;
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), Radius.zero),
),
reason: 'The clip should not be truncated by the overlap anymore',
);
// Mutate clipBehavior to Clip.none -> no clip should be pushed at all.
renderSliver.clipBehavior = .none;
expect(renderSliver.paint, isNot(paints..clipRRect()));
// Mutate clipBehavior back to non-none and check that clip is rebuilt.
renderSliver.clipBehavior = .antiAlias;
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), Radius.zero),
),
);
});
testWidgets('custom clipper boundaries are not incorrectly expanded during overlap', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
Directionality(
textDirection: .ltr,
child: CustomScrollView(
controller: controller,
slivers: <Widget>[
const SliverPersistentHeader(
delegate: _SliverPersistentHeaderDelegate(),
pinned: true,
),
const SliverToBoxAdapter(child: SizedBox(height: 50.0)), // Spacer
SliverClipRRect(
clipper: const _CustomClipperRRect30(),
sliver: SliverToBoxAdapter(
child: Container(height: 200.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
// Spacer is 50px. ClipRect starts at y=50.
// Scroll by 60.0 -> ClipRect layoutOffset is 150 - 60 = 90.0.
// Header covers 0..100. So overlap is 10.0.
controller.jumpTo(60.0);
await tester.pump();
expect(renderSliver.constraints.overlap, 10.0);
// Top inset from custom clipper is 30.0. Since overlap (10.0) is less than 30.0,
// the clip top should still be 30.0 (and not 10.0 which would expand the clip boundary).
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 30.0, 800.0, 200.0), Radius.zero),
),
);
// Now scroll more to create an overlap of 50px (jumpTo 100.0):
// ClipRect layoutOffset is 150 - 100 = 50.0.
// Header covers 0..100. So overlap is 50.0.
// Since 50.0 > 30.0, the top of the clip should be 50.0.
controller.jumpTo(100.0);
await tester.pump();
expect(renderSliver.constraints.overlap, 50.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 50.0, 800.0, 200.0), Radius.zero),
),
);
});
// ---- Overlap hit testing: (clipOverlap × axis × reverse) matrix ----
group('overlap hit testing', () {
final overlapTestCases =
<
({
String name,
ClipOverlapBehavior clipOverlap,
Axis axis,
bool reverse,
bool expectHitInOverlap,
})
>[
(
name: 'followEdge blocks overlap hits (vertical)',
clipOverlap: .followEdge,
axis: .vertical,
reverse: false,
expectHitInOverlap: false,
),
(
name: 'none allows overlap hits (vertical)',
clipOverlap: .none,
axis: .vertical,
reverse: false,
expectHitInOverlap: true,
),
(
name: 'preserveShape blocks overlap hits (vertical)',
clipOverlap: .preserveShape,
axis: .vertical,
reverse: false,
expectHitInOverlap: false,
),
(
name: 'followEdge blocks overlap hits (horizontal)',
clipOverlap: .followEdge,
axis: .horizontal,
reverse: false,
expectHitInOverlap: false,
),
(
name: 'none allows overlap hits (horizontal)',
clipOverlap: .none,
axis: .horizontal,
reverse: false,
expectHitInOverlap: true,
),
(
name: 'none allows overlap hits (vertical reverse)',
clipOverlap: .none,
axis: .vertical,
reverse: true,
expectHitInOverlap: true,
),
(
name: 'followEdge blocks overlap hits (horizontal reverse)',
clipOverlap: .followEdge,
axis: .horizontal,
reverse: true,
expectHitInOverlap: false,
),
];
for (final testCase in overlapTestCases) {
testWidgets(testCase.name, (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(
controller: controller,
clipOverlap: testCase.clipOverlap,
scrollDirection: testCase.axis,
reverse: testCase.reverse,
),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(
find.byType(SliverClipRRect),
);
expect(renderSliver.constraints.overlap, 50.0);
final crossAxis = testCase.axis == .horizontal ? 50.0 : 100.0;
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: crossAxis,
),
testCase.expectHitInOverlap ? isTrue : isFalse,
);
if (!testCase.expectHitInOverlap) {
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 75.0,
crossAxisPosition: crossAxis,
),
isTrue,
reason: 'Should hit in visible area',
);
}
});
}
});
testWidgets('clipBehavior of Clip.none allows overlap hits', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_buildOverlapScenario(controller: controller, clipBehavior: .none));
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(renderSliver.constraints.overlap, 50.0);
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: 100.0,
),
isTrue,
reason: 'Should hit in overlap area because clipBehavior is Clip.none',
);
});
// ---- followEdge-specific edge cases ----
testWidgets('creates a straight cut at overlap', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(
controller: controller,
clipOverlap: .none,
borderRadius: 50.0,
childExtent: 200.0,
),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(renderSliver.constraints.overlap, 50.0);
expect(
renderSliver.hitTest(SliverHitTestResult(), mainAxisPosition: 51.0, crossAxisPosition: 1.0),
isTrue,
reason: 'Overlap cut should be straight, not rounded.',
);
});
testWidgets('uses complete extent for hit test clipping', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_buildOverlapScenario(controller: controller, borderRadius: 40.0));
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 15.0,
crossAxisPosition: 400.0,
),
isTrue,
);
controller.jumpTo(50.0);
await tester.pump();
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 15.0,
crossAxisPosition: 400.0,
),
isFalse,
reason: 'Should NOT hit at local 15 because it is below the header (clip starts at 50)',
);
});
testWidgets('calculates correct clip rectangle origin', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_buildOverlapScenario(controller: controller, borderRadius: 40.0));
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(0.0, 50.0, 800.0, 100.0),
const Radius.circular(40.0),
),
),
reason: 'clip.top should equal the overlap',
);
});
testWidgets('preserves visual content integrity at overlap cut', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_buildOverlapScenario(controller: controller, borderRadius: 40.0));
controller.jumpTo(20.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(
renderSliver.hitTest(SliverHitTestResult(), mainAxisPosition: 21.0, crossAxisPosition: 1.0),
isFalse,
reason: 'Content at (1, 21) is clipped by the rounded overlap cut.',
);
});
testWidgets('handles overlap calculation with reverse scrolling', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(controller: controller, borderRadius: 40.0, reverse: true),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(renderSliver.constraints.overlap, 50.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0),
const Radius.circular(40.0),
),
),
reason: 'Reverse scroll clip is incorrect.',
);
});
// ---- preserveShape clip geometry ----
group('preserveShape clip geometry', () {
testWidgets('shifts clip origin and preserves corners (vertical)', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(
controller: controller,
clipOverlap: .preserveShape,
borderRadius: 40.0,
),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(
find.byType(SliverClipRRect),
);
expect(renderSliver.constraints.overlap, 50.0);
// The clip top (20.0) is above the overlap (50.0) so the corner arc,
// which ends at 20.0 + 40.0 = 60.0, extends past the overlap into the
// visible area instead of being sheared off by it.
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(0.0, 20.0, 800.0, 100.0),
const Radius.circular(40.0),
),
),
);
});
testWidgets('shifts clip origin (horizontal)', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(
controller: controller,
clipOverlap: .preserveShape,
borderRadius: 40.0,
scrollDirection: .horizontal,
),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(
find.byType(SliverClipRRect),
);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(20.0, 0.0, 100.0, 600.0),
const Radius.circular(40.0),
),
),
reason: 'preserveShape clip.left should be less than the overlap',
);
});
testWidgets('shifts clip origin (vertical reverse)', (WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildOverlapScenario(
controller: controller,
clipOverlap: .preserveShape,
borderRadius: 40.0,
reverse: true,
),
);
controller.jumpTo(50.0);
await tester.pump();
final RenderSliverClipRRect renderSliver = tester.renderObject(
find.byType(SliverClipRRect),
);
// With followEdge in reverse, clip.bottom = 50.0.
// preserveShape should produce a different value.
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(0.0, 0.0, 800.0, 80.0),
const Radius.circular(40.0),
),
),
reason: 'preserveShape should produce a different bottom clip in reverse',
);
});
});
testWidgets('clears layer when clipBehavior is updated to Clip.none', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_buildOverlapScenario(controller: controller));
await tester.pumpWidget(_buildOverlapScenario(controller: controller, clipBehavior: .none));
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
expect(renderSliver.debugLayer, isNull);
});
testWidgets(
'preserveShape hit testing is received when pinned header is wrapped in SliverIgnorePointer',
(WidgetTester tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
var childTapped = false;
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
controller: controller,
slivers: <Widget>[
const SliverIgnorePointer(
sliver: SliverPersistentHeader(
delegate: _SliverPersistentHeaderDelegate(),
pinned: true,
),
),
SliverClipRRect(
clipOverlap: .preserveShape,
borderRadius: .circular(40.0),
sliver: SliverToBoxAdapter(
child: GestureDetector(
onTap: () {
childTapped = true;
},
child: Container(height: 100.0, color: const Color(0xFF2196F3)),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
controller.jumpTo(50.0);
await tester.pump();
// The header (0..100) covers the top of the SliverClipRRect (50..150),
// creating a 50px overlap. With preserveShape, the rounded clip shifts
// up so its corners remain visible inside the overlap band. Tapping at
// y=90 (mainAxisPosition 40 < overlap 50) lands inside that preserved
// shape. The hit is only received because the header is wrapped in a
// SliverIgnorePointer, so it lets the tap fall through to the child.
await tester.tapAt(const Offset(10.0, 90.0));
await tester.pump();
expect(childTapped, isTrue);
},
);
testWidgets('leading clip stays pinned until insideClipExtent is scrolled', (
WidgetTester tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
controller: controller,
slivers: <Widget>[
SliverClipRRect(
clipper: const _CustomClipperRRect30(),
sliver: SliverToBoxAdapter(
child: Container(height: 400.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRRect renderSliver = tester.renderObject(find.byType(SliverClipRRect));
// The clipper insets the top by 30px, so at scrollOffset 0 the clip starts
// at y=30. The extent that can slide under the leading edge before it
// moves is 400 - 30 = 370 (insideClipExtent).
expect(renderSliver.constraints.scrollOffset, 0.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 30.0, 800.0, 400.0), Radius.zero),
),
);
// While insideClipExtent (370px) has not been fully consumed by the
// scroll, the leading edge stays pinned at the viewport's leading edge (0)
// rather than drifting up with the content.
controller.jumpTo(100.0);
await tester.pump();
expect(renderSliver.constraints.scrollOffset, 100.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0), Radius.zero),
),
reason: 'Leading clip should stay pinned while insideClipExtent is not consumed',
);
// Once the scroll exceeds insideClipExtent (370px), the leading edge is
// released and follows the content upwards: top becomes 370 - 385 = -15.
controller.jumpTo(385.0);
await tester.pump();
expect(renderSliver.constraints.scrollOffset, 385.0);
expect(
renderSliver.paint,
paints..clipRRect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(0.0, -15.0, 800.0, 15.0), Radius.zero),
),
reason: 'Leading clip should follow the content once insideClipExtent is consumed',
);
});
});
}
class _SliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
const _SliverPersistentHeaderDelegate();
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) =>
SizedBox(height: maxExtent, width: maxExtent, child: const Text('Header'));
@override
double get maxExtent => 100.0;
@override
double get minExtent => 100.0;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => false;
}
class _CustomClipperRRect30 extends CustomClipper<RRect> {
const _CustomClipperRRect30();
@override
RRect getClip(Size size) => RRect.fromLTRBAndCorners(0.0, 30.0, size.width, size.height);
@override
bool shouldReclip(covariant CustomClipper<RRect> oldClipper) => false;
}