fix(ux): make the scroll cue visible — edge scrim + chevron

The fade-to-transparent was invisible: the row sits on the same pale
canvas, so masking the edge to transparent just revealed more of the
same colour (and often landed on an inter-chip gap). Replace it with a
surface-coloured scrim the chips slide under, topped by an outward
chevron, shown only on an edge with more content. RTL-mirrored.
This commit is contained in:
vjrj 2026-07-11 07:25:14 +02:00
parent 94cf2f5448
commit 05227706fd
2 changed files with 84 additions and 31 deletions

View file

@ -1,21 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Fades the edges of a horizontal scrollable to transparency wherever more import 'theme.dart';
/// content lies beyond that edge, so it is obvious the row scrolls.
/// Marks the edges of a horizontal scrollable that have more content beyond
/// them, so it is obvious the row scrolls.
/// ///
/// Wrap directly around a horizontal [SingleChildScrollView] or [ListView]. /// Wrap directly around a horizontal [SingleChildScrollView] or [ListView].
/// The fade is a [ShaderMask] with [BlendMode.dstIn], so it melts into /// Each overflowing edge gets a soft scrim in the surface colour the chips
/// whatever background sits behind no theme colour involved. Edge state is /// appear to slide *under* it topped by a chevron pointing outward. The
/// scrim only shows while content remains beyond that edge; it disappears once
/// the row reaches that end (or does not overflow at all). Edge state is
/// tracked per logical side (start/end) and mapped to physical left/right via /// tracked per logical side (start/end) and mapped to physical left/right via
/// [Directionality], so RTL mirrors correctly. /// [Directionality], so RTL mirrors correctly.
class EdgeFade extends StatefulWidget { class EdgeFade extends StatefulWidget {
const EdgeFade({super.key, required this.child, this.fadeWidth = 28}); const EdgeFade({super.key, required this.child, this.scrimWidth = 40});
/// The horizontal scrollable to mask. /// The horizontal scrollable to mark.
final Widget child; final Widget child;
/// Width of each faded edge in logical pixels. /// Width of each edge scrim in logical pixels.
final double fadeWidth; final double scrimWidth;
@override @override
State<EdgeFade> createState() => EdgeFadeState(); State<EdgeFade> createState() => EdgeFadeState();
@ -25,11 +29,11 @@ class EdgeFadeState extends State<EdgeFade> {
bool _fadeStart = false; bool _fadeStart = false;
bool _fadeEnd = false; bool _fadeEnd = false;
/// Whether the physical left edge is currently faded. /// Whether the physical left edge currently shows a scroll cue.
@visibleForTesting @visibleForTesting
bool get fadeLeft => _isRtl ? _fadeEnd : _fadeStart; bool get fadeLeft => _isRtl ? _fadeEnd : _fadeStart;
/// Whether the physical right edge is currently faded. /// Whether the physical right edge currently shows a scroll cue.
@visibleForTesting @visibleForTesting
bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd; bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd;
@ -50,31 +54,70 @@ class EdgeFadeState extends State<EdgeFade> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fadeLeft = this.fadeLeft; final surface = Theme.of(context).scaffoldBackgroundColor;
final fadeRight = this.fadeRight;
// The mask stays in the tree even when both edges are opaque: swapping it
// in and out would re-inflate the child and reset its scroll position.
return NotificationListener<ScrollMetricsNotification>( return NotificationListener<ScrollMetricsNotification>(
onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false,
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false,
child: ShaderMask( child: Stack(
blendMode: BlendMode.dstIn, children: [
shaderCallback: (bounds) { widget.child,
final w = bounds.width > 0 if (fadeLeft)
? (widget.fadeWidth / bounds.width).clamp(0.0, 0.5) _EdgeCue(
: 0.0; left: true,
return LinearGradient( width: widget.scrimWidth,
colors: [ surface: surface,
fadeLeft ? Colors.transparent : Colors.white, ),
Colors.white, if (fadeRight)
Colors.white, _EdgeCue(
fadeRight ? Colors.transparent : Colors.white, left: false,
width: widget.scrimWidth,
surface: surface,
),
], ],
stops: [0, w, 1 - w, 1], ),
).createShader(bounds); ),
}, );
child: widget.child, }
}
/// A gradient scrim over one physical edge of the row, opaque at the very edge
/// and fading inward, with a chevron pointing outward toward the hidden chips.
/// Ignores pointers so taps still reach the chip underneath.
class _EdgeCue extends StatelessWidget {
const _EdgeCue({
required this.left,
required this.width,
required this.surface,
});
final bool left;
final double width;
final Color surface;
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
bottom: 0,
left: left ? 0 : null,
right: left ? null : 0,
width: width,
child: IgnorePointer(
child: Container(
alignment: left ? Alignment.centerLeft : Alignment.centerRight,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: left ? Alignment.centerLeft : Alignment.centerRight,
end: left ? Alignment.centerRight : Alignment.centerLeft,
colors: [surface, surface.withValues(alpha: 0)],
),
),
child: Icon(
left ? Icons.chevron_left : Icons.chevron_right,
size: 22,
color: seedMuted,
),
), ),
), ),
); );

View file

@ -35,6 +35,16 @@ void main() {
await tester.pump(); // let the post-layout metrics notification land await tester.pump(); // let the post-layout metrics notification land
expect(state(tester).fadeLeft, isFalse); expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isFalse); expect(state(tester).fadeRight, isFalse);
expect(find.byIcon(Icons.chevron_left), findsNothing);
expect(find.byIcon(Icons.chevron_right), findsNothing);
});
testWidgets('a trailing chevron cue appears when the row overflows',
(tester) async {
await tester.pumpWidget(harness(count: 20));
await tester.pump();
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
expect(find.byIcon(Icons.chevron_left), findsNothing);
}); });
testWidgets('LTR: at start only the trailing (right) edge fades', testWidgets('LTR: at start only the trailing (right) edge fades',