diff --git a/apps/app_seeds/lib/ui/edge_fade.dart b/apps/app_seeds/lib/ui/edge_fade.dart index e498021..eea3f81 100644 --- a/apps/app_seeds/lib/ui/edge_fade.dart +++ b/apps/app_seeds/lib/ui/edge_fade.dart @@ -1,21 +1,25 @@ import 'package:flutter/material.dart'; -/// Fades the edges of a horizontal scrollable to transparency wherever more -/// content lies beyond that edge, so it is obvious the row scrolls. +import 'theme.dart'; + +/// 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]. -/// The fade is a [ShaderMask] with [BlendMode.dstIn], so it melts into -/// whatever background sits behind — no theme colour involved. Edge state is +/// Each overflowing edge gets a soft scrim in the surface colour — the chips +/// 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 /// [Directionality], so RTL mirrors correctly. 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; - /// Width of each faded edge in logical pixels. - final double fadeWidth; + /// Width of each edge scrim in logical pixels. + final double scrimWidth; @override State createState() => EdgeFadeState(); @@ -25,11 +29,11 @@ class EdgeFadeState extends State { bool _fadeStart = false; bool _fadeEnd = false; - /// Whether the physical left edge is currently faded. + /// Whether the physical left edge currently shows a scroll cue. @visibleForTesting 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 bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd; @@ -50,31 +54,70 @@ class EdgeFadeState extends State { @override Widget build(BuildContext context) { - final fadeLeft = this.fadeLeft; - 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. + final surface = Theme.of(context).scaffoldBackgroundColor; return NotificationListener( onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, child: NotificationListener( onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) { - final w = bounds.width > 0 - ? (widget.fadeWidth / bounds.width).clamp(0.0, 0.5) - : 0.0; - return LinearGradient( - colors: [ - fadeLeft ? Colors.transparent : Colors.white, - Colors.white, - Colors.white, - fadeRight ? Colors.transparent : Colors.white, - ], - stops: [0, w, 1 - w, 1], - ).createShader(bounds); - }, - child: widget.child, + child: Stack( + children: [ + widget.child, + if (fadeLeft) + _EdgeCue( + left: true, + width: widget.scrimWidth, + surface: surface, + ), + if (fadeRight) + _EdgeCue( + left: false, + width: widget.scrimWidth, + surface: surface, + ), + ], + ), + ), + ); + } +} + +/// 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, + ), ), ), ); diff --git a/apps/app_seeds/test/ui/edge_fade_test.dart b/apps/app_seeds/test/ui/edge_fade_test.dart index e60d7c6..a468cf1 100644 --- a/apps/app_seeds/test/ui/edge_fade_test.dart +++ b/apps/app_seeds/test/ui/edge_fade_test.dart @@ -35,6 +35,16 @@ void main() { await tester.pump(); // let the post-layout metrics notification land expect(state(tester).fadeLeft, 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',