fix(tv): fade dim-scrim edges instead of hard rect boundaries
Unlike the content opacity it replaced, the dim quad also darkens the backdrop showing through around the rows, so its rectangle edge read as a hard line across the spotlight artwork. Ramp the dim in over narrow top/bottom gradient bands (flat quad interior, non-AA to avoid seams); band area keeps the gradient-shader cost negligible on weak TV GPUs. Browse pass on the Mali-G31 box unchanged: draw p50 7.2ms, 2% janky.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1135,6 +1135,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user