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:
parent
94cf2f5448
commit
05227706fd
2 changed files with 84 additions and 31 deletions
|
|
@ -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<EdgeFade> createState() => EdgeFadeState();
|
||||
|
|
@ -25,11 +29,11 @@ class EdgeFadeState extends State<EdgeFade> {
|
|||
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<EdgeFade> {
|
|||
|
||||
@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<ScrollMetricsNotification>(
|
||||
onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue