Chips clipped at the viewport edge with no cue that more filters exist. New reusable EdgeFade widget (ShaderMask + dstIn) fades an edge only while content remains beyond it, driven by scroll metrics; RTL mirrors via Directionality. Applied to the inventory filter bar, market filter bar and calendar month strip.
82 lines
2.8 KiB
Dart
82 lines
2.8 KiB
Dart
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.
|
|
///
|
|
/// 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
|
|
/// 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});
|
|
|
|
/// The horizontal scrollable to mask.
|
|
final Widget child;
|
|
|
|
/// Width of each faded edge in logical pixels.
|
|
final double fadeWidth;
|
|
|
|
@override
|
|
State<EdgeFade> createState() => EdgeFadeState();
|
|
}
|
|
|
|
class EdgeFadeState extends State<EdgeFade> {
|
|
bool _fadeStart = false;
|
|
bool _fadeEnd = false;
|
|
|
|
/// Whether the physical left edge is currently faded.
|
|
@visibleForTesting
|
|
bool get fadeLeft => _isRtl ? _fadeEnd : _fadeStart;
|
|
|
|
/// Whether the physical right edge is currently faded.
|
|
@visibleForTesting
|
|
bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd;
|
|
|
|
bool get _isRtl => Directionality.of(context) == TextDirection.rtl;
|
|
|
|
bool _onMetrics(ScrollMetrics metrics) {
|
|
if (metrics.axis != Axis.horizontal) return false;
|
|
final start = metrics.extentBefore > 0.5;
|
|
final end = metrics.extentAfter > 0.5;
|
|
if (start != _fadeStart || end != _fadeEnd) {
|
|
setState(() {
|
|
_fadeStart = start;
|
|
_fadeEnd = end;
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@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.
|
|
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,
|
|
],
|
|
stops: [0, w, 1 - w, 1],
|
|
).createShader(bounds);
|
|
},
|
|
child: widget.child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|