diff --git a/lib/widgets/animated_dim_scrim.dart b/lib/widgets/animated_dim_scrim.dart index 5ffc8c6a..69cad73e 100644 --- a/lib/widgets/animated_dim_scrim.dart +++ b/lib/widgets/animated_dim_scrim.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/widgets.dart'; import '../focus/focus_theme.dart'; @@ -11,12 +13,26 @@ import '../focus/focus_theme.dart'; /// `Opacity(opacity: 1 - alpha)`; over artwork it darkens toward [color] /// instead of turning translucent. /// +/// Unlike content opacity, the quad also darkens whatever shows through +/// gaps in the content, so its rectangle boundary can read as a hard line +/// across backdrop artwork. [fadeTop]/[fadeBottom] soften that by ramping +/// the dim in over a band of pixels. The bands are gradient-shaded, which +/// costs GPU per covered pixel on low-end TVs — keep them narrow; the +/// interior stays a flat quad. +/// /// Stack this above the content (with `IgnorePointer` built in so taps pass /// through). Animates with [FocusTheme.getAnimationDuration], so the full /// tier fades and the reduced tier snaps, matching the [AnimatedOpacity] /// behavior it replaces. class AnimatedDimScrim extends StatelessWidget { - const AnimatedDimScrim({super.key, required this.dimmed, required this.color, required this.alpha}); + const AnimatedDimScrim({ + super.key, + required this.dimmed, + required this.color, + required this.alpha, + this.fadeTop = 0, + this.fadeBottom = 0, + }); final bool dimmed; final Color color; @@ -25,6 +41,13 @@ class AnimatedDimScrim extends StatelessWidget { /// `Opacity(opacity: 1 - alpha)` over a [color] underlay. final double alpha; + /// Height of the ramp from transparent to full strength at the top edge. + final double fadeTop; + + /// Height of the ramp from full strength back to transparent at the + /// bottom edge. + final double fadeBottom; + @override Widget build(BuildContext context) { return IgnorePointer( @@ -32,9 +55,68 @@ class AnimatedDimScrim extends StatelessWidget { tween: Tween(end: dimmed ? alpha : 0.0), duration: FocusTheme.getAnimationDuration(context), curve: Curves.easeOutCubic, - builder: (context, value, _) => - value == 0 ? const SizedBox.shrink() : ColoredBox(color: color.withValues(alpha: value)), + builder: (context, value, _) { + if (value == 0) return const SizedBox.shrink(); + if (fadeTop == 0 && fadeBottom == 0) { + return ColoredBox(color: color.withValues(alpha: value)); + } + return CustomPaint( + painter: _DimScrimPainter(color: color, alpha: value, fadeTop: fadeTop, fadeBottom: fadeBottom), + ); + }, ), ); } } + +class _DimScrimPainter extends CustomPainter { + const _DimScrimPainter({required this.color, required this.alpha, required this.fadeTop, required this.fadeBottom}); + + final Color color; + final double alpha; + final double fadeTop; + final double fadeBottom; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final full = color.withValues(alpha: alpha); + final none = color.withValues(alpha: 0.0); + final top = fadeTop.clamp(0.0, size.height); + final bottom = fadeBottom.clamp(0.0, size.height - top); + + // Anti-aliasing off: adjacent translucent rects sharing an edge would + // otherwise double-blend along the seam. + canvas.drawRect( + Rect.fromLTRB(0, top, size.width, size.height - bottom), + Paint() + ..color = full + ..isAntiAlias = false, + ); + if (top > 0) { + final band = Rect.fromLTRB(0, 0, size.width, top); + canvas.drawRect( + band, + Paint() + ..shader = ui.Gradient.linear(band.topLeft, band.bottomLeft, [none, full]) + ..isAntiAlias = false, + ); + } + if (bottom > 0) { + final band = Rect.fromLTRB(0, size.height - bottom, size.width, size.height); + canvas.drawRect( + band, + Paint() + ..shader = ui.Gradient.linear(band.topLeft, band.bottomLeft, [full, none]) + ..isAntiAlias = false, + ); + } + } + + @override + bool shouldRepaint(_DimScrimPainter oldDelegate) => + color != oldDelegate.color || + alpha != oldDelegate.alpha || + fadeTop != oldDelegate.fadeTop || + fadeBottom != oldDelegate.fadeBottom; +} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index fe80dc73..abbeccbd 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1135,6 +1135,9 @@ class TvBrowseRailState extends State { dimmed: !railHasFocus, color: theme.scaffoldBackgroundColor, alpha: _unfocusedRailDimAlpha, + // The viewport's top edge cuts across the + // spotlight artwork; ramp the dim in instead. + fadeTop: 36 * scale, ), ), ), @@ -1348,6 +1351,10 @@ class TvBrowseRailState extends State { dimmed: !isActive, color: Theme.of(context).scaffoldBackgroundColor, alpha: _inactiveHubDimAlpha, + // Soften the quad's boundary so it doesn't draw a hard + // line across the artwork showing through around the row. + fadeTop: 20 * scale, + fadeBottom: 20 * scale, ), ), ),