blob: fc1f678d2ac5f7c527e918a5672831ba452da5c9 [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
/// [SliverClipRect] and filler content. Suitable for all overlap-clipping tests.
Widget _buildOverlapScenario({
required ScrollController controller,
ClipOverlapBehavior clipOverlap = .followEdge,
CustomClipper<Rect>? clipper,
Axis scrollDirection = .vertical,
bool reverse = false,
double childExtent = 100.0,
Clip clipBehavior = .hardEdge,
}) {
final isHorizontal = scrollDirection == .horizontal;
return TestWidgetsApp(
home: CustomScrollView(
controller: controller,
scrollDirection: scrollDirection,
reverse: reverse,
slivers: <Widget>[
const SliverPersistentHeader(delegate: _SliverPersistentHeaderDelegate(), pinned: true),
SliverClipRect(
clipOverlap: clipOverlap,
clipper: clipper,
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('SliverClipRect', () {
testWidgets('renders its child', (WidgetTester tester) async {
await tester.pumpWidget(
const TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRect(sliver: SliverToBoxAdapter(child: Text('Hello World'))),
],
),
),
);
expect(find.text('Hello World'), findsOneWidget);
});
testWidgets('hit test respects custom clipper', (WidgetTester tester) async {
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
slivers: <Widget>[
SliverClipRect(
clipper: const _HalfHeightClipper(),
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFFF44336)),
),
),
],
),
),
);
final RenderSliver renderSliver = tester.renderObject(find.byType(SliverClipRect));
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: 400.0,
),
isTrue,
reason: 'Should hit inside the clipped area',
);
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 75.0,
crossAxisPosition: 400.0,
),
isFalse,
reason: 'Should NOT hit outside the clipped area',
);
});
testWidgets('updates properties', (WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: .ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverClipRect(sliver: SliverToBoxAdapter(child: SizedBox(height: 100.0))),
],
),
),
);
final RenderSliverClipRect renderObject = tester.renderObject(find.byType(SliverClipRect));
expect(renderObject.clipOverlap, ClipOverlapBehavior.followEdge);
expect(renderObject.clipBehavior, Clip.hardEdge);
await tester.pumpWidget(
const Directionality(
textDirection: .ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverClipRect(
clipOverlap: .none,
clipBehavior: .antiAlias,
sliver: SliverToBoxAdapter(child: SizedBox(height: 100.0)),
),
],
),
),
);
expect(renderObject.clipOverlap, ClipOverlapBehavior.none);
expect(renderObject.clipBehavior, Clip.antiAlias);
});
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
SliverClipRect(
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
// Initial state: no overlap
// Header 0..100, Spacer 100..200, ClipRect 200..300.
expect(renderSliver.constraints.overlap, 0.0);
expect(
renderSliver.paint,
paints..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0)),
);
// 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),
// so its geometry didn't change, and _clip was not nulled.
expect(
renderSliver.paint,
paints..clipRect(rect: const Rect.fromLTRB(0.0, 50.0, 800.0, 100.0)),
);
});
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
SliverClipRect(
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
// 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.hardEdge);
// The clip is cached and truncated by the overlap (top = 50.0).
expect(
renderSliver.paint,
paints..clipRect(rect: const Rect.fromLTRB(0.0, 50.0, 800.0, 100.0)),
);
// Mutate clipOverlap -> should call _markNeedsClip() and invalidate cache.
renderSliver.clipOverlap = .none;
expect(
renderSliver.paint,
paints..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0)),
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..clipRect()));
// Mutate clipBehavior back to non-none and check that clip is rebuilt.
renderSliver.clipBehavior = .hardEdge;
expect(
renderSliver.paint,
paints..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0)),
);
});
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
SliverClipRect(
clipper: const _CustomClipper30(),
sliver: SliverToBoxAdapter(
child: Container(height: 200.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
// 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..clipRect(rect: const Rect.fromLTRB(0.0, 30.0, 800.0, 200.0)),
);
// 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..clipRect(rect: const Rect.fromLTRB(0.0, 50.0, 800.0, 200.0)),
);
});
// ---- 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 RenderSliverClipRect renderSliver = tester.renderObject(
find.byType(SliverClipRect),
);
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 RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
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',
);
});
// ---- Custom clipper with axis/reverse ----
group('custom clipper hit testing', () {
testWidgets('respects half-width clipper with horizontal axis', (WidgetTester tester) async {
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
scrollDirection: .horizontal,
slivers: <Widget>[
SliverClipRect(
clipper: const _HalfWidthClipper(),
sliver: SliverToBoxAdapter(
child: Container(width: 100.0, height: 100.0, color: const Color(0xFFF44336)),
),
),
],
),
),
);
final RenderSliver renderSliver = tester.renderObject(find.byType(SliverClipRect));
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: 50.0,
),
isTrue,
reason: 'Should hit inside the clipped area',
);
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 75.0,
crossAxisPosition: 50.0,
),
isFalse,
reason: 'Should NOT hit outside the clipped area',
);
});
testWidgets('respects half-height clipper with reverse axis', (WidgetTester tester) async {
await tester.pumpWidget(
TestWidgetsApp(
home: CustomScrollView(
reverse: true,
slivers: <Widget>[
SliverClipRect(
clipper: const _HalfHeightClipper(),
sliver: SliverToBoxAdapter(
child: Container(height: 100.0, color: const Color(0xFFF44336)),
),
),
],
),
),
);
final RenderSliver renderSliver = tester.renderObject(find.byType(SliverClipRect));
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 75.0,
crossAxisPosition: 400.0,
),
isTrue,
reason: 'Should hit inside the clipped area',
);
expect(
renderSliver.hitTest(
SliverHitTestResult(),
mainAxisPosition: 25.0,
crossAxisPosition: 400.0,
),
isFalse,
reason: 'Should NOT hit outside the clipped area',
);
});
});
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 RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
expect(renderSliver.debugLayer, isNull);
});
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>[
SliverClipRect(
clipper: const _CustomClipper30(),
sliver: SliverToBoxAdapter(
child: Container(height: 400.0, color: const Color(0xFF2196F3)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 1000.0)),
],
),
),
);
final RenderSliverClipRect renderSliver = tester.renderObject(find.byType(SliverClipRect));
// 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..clipRect(rect: const Rect.fromLTRB(0.0, 30.0, 800.0, 400.0)),
);
// 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..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0)),
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..clipRect(rect: const Rect.fromLTRB(0.0, -15.0, 800.0, 15.0)),
reason: 'Leading clip should follow the content once insideClipExtent is consumed',
);
});
});
}
class _HalfHeightClipper extends CustomClipper<Rect> {
const _HalfHeightClipper();
@override
Rect getClip(Size size) => Rect.fromLTWH(0.0, 0.0, size.width, size.height / 2.0);
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) => false;
}
class _HalfWidthClipper extends CustomClipper<Rect> {
const _HalfWidthClipper();
@override
Rect getClip(Size size) => Rect.fromLTWH(0.0, 0.0, size.width / 2.0, size.height);
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) => false;
}
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 _CustomClipper30 extends CustomClipper<Rect> {
const _CustomClipper30();
@override
Rect getClip(Size size) => Rect.fromLTRB(0.0, 30.0, size.width, size.height);
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) => false;
}