Files
plezy/lib/focus/focus_glow_overlay.dart
edde746 aa9118a799 fix(tv): hold the hub focus glow while the rail scrolls vertically
Moving UP between hubs showed the focused card's glow immediately: the
glow paints in the root overlay, unclipped by the rail viewport, so it
flashed over the spotlight while the target row was still offscreen.
The rail now suppresses only the glow for the 250 ms vertical scroll
and lets it fade in once the row settles. FocusGlowOverlay defers
attached-portal shows out of build, which OverlayPortalController
asserts against.
2026-08-23 10:12:26 +02:00

200 lines
7.2 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../services/device_performance.dart';
import '../services/settings_service.dart';
import 'focus_theme.dart';
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
/// ABOVE sibling cards on all four sides.
///
/// The glow is an outward blur ([FocusTheme.focusGlowShadows]). When drawn
/// in-tree behind a packed rail/grid it is occluded by later-painted neighbours
/// on the trailing edges and only escapes on the leading (left) edge — producing
/// a one-sided halo (issue #1231). Lifting it into an [OverlayPortal] that
/// follows the card via [LayerLink] makes it render above every sibling, so the
/// glow is symmetric on all sides. The crisp focus border stays in-card; only
/// the glow moves to the overlay.
///
/// Only mounts the overlay/leader while the card is focused (or fading out), so
/// there is at most one [LeaderLayer] on screen regardless of how many cards a
/// grid builds.
class FocusGlowOverlay extends StatefulWidget {
const FocusGlowOverlay({
super.key,
required this.isFocused,
required this.borderRadius,
required this.color,
required this.child,
this.glowSize,
});
/// Whether the wrapped card currently shows focus. Drives the glow.
final bool isFocused;
/// Border radius of the card, used for the glow's rounded rect.
final double borderRadius;
/// Glow colour, resolved from the card's theme at the call site (the overlay
/// builds in the root Overlay's context, which may not carry a nested theme).
final Color color;
/// Explicit card size. When null, falls back to [LayerLink.leaderSize] (one
/// frame late on first show, hidden under the opacity-0 fade-in).
final Size? glowSize;
final Widget child;
@override
State<FocusGlowOverlay> createState() => _FocusGlowOverlayState();
}
class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
final OverlayPortalController _controller = OverlayPortalController();
final LayerLink _link = LayerLink();
/// Drives the [AnimatedOpacity] target. Set false on focus loss so the glow
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
/// Whether the previous [build] mounted the [OverlayPortal] — i.e. whether
/// [_controller] is attached. An attached controller's show()/hide() assert
/// when called during build, so [didUpdateWidget] must defer; a detached
/// controller's show() merely records pending state and is build-safe.
bool _portalInTree = false;
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
/// saveLayer are too expensive on weak GPUs) and when the user turned the
/// Focus Glow setting off (#1278). The crisp focus border remains.
static bool get _disabled => DevicePerformance.isReduced || !SettingsService.instance.read(SettingsService.focusGlow);
@override
void initState() {
super.initState();
if (widget.isFocused && !_disabled) {
_visible = true;
_controller.show();
}
}
@override
void didUpdateWidget(FocusGlowOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (_disabled) return;
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_visible = false;
if (_portalInTree) {
// The portal from a previous glow session is still attached (mid
// fade-out, or the rail held the glow back during a vertical scroll):
// show() would assert during build, so defer it out of the frame and
// fade back in from wherever the fade-out got to.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isFocused) return;
if (!_controller.isShowing) _controller.show();
setState(() => _visible = true);
});
} else {
// Detached: a build-time show() only records pending state, applied
// when this build mounts the portal. Start hidden, fade in next frame.
_controller.show();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isFocused) setState(() => _visible = true);
});
}
} else {
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
setState(() => _visible = false);
}
}
void _handleFadeEnd() {
if (!_visible && mounted && _controller.isShowing) {
_controller.hide();
setState(() {}); // drop the OverlayPortal/leader from the tree
}
}
@override
Widget build(BuildContext context) {
if (_disabled) {
_portalInTree = false;
return widget.child;
}
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
if (!widget.isFocused && !_controller.isShowing) {
_portalInTree = false;
return widget.child;
}
_portalInTree = true;
final duration = FocusTheme.getAnimationDuration(context);
return OverlayPortal(
controller: _controller,
overlayChildBuilder: (overlayContext) {
final size = widget.glowSize ?? _link.leaderSize;
final extent = FocusTheme.focusGlowExtent;
return CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.topLeft,
followerAnchor: Alignment.topLeft,
offset: Offset(-extent, -extent),
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: duration,
curve: Curves.easeOutCubic,
onEnd: _handleFadeEnd,
child: size == null
? const SizedBox.shrink()
: CustomPaint(
size: Size(size.width + extent * 2, size.height + extent * 2),
painter: _FocusGlowPainter(
rect: Offset(extent, extent) & size,
borderRadius: widget.borderRadius,
shadows: FocusTheme.focusGlowShadows(widget.color),
),
),
),
),
);
},
child: CompositedTransformTarget(link: _link, child: widget.child),
);
}
}
/// Paints [shadows] around [rect] with the rect interior clipped out, so only
/// the outer glow shows — matching the original look where the opaque card hid
/// the inner part of the shadow.
class _FocusGlowPainter extends CustomPainter {
const _FocusGlowPainter({required this.rect, required this.borderRadius, required this.shadows});
final Rect rect;
final double borderRadius;
final List<BoxShadow> shadows;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius));
final clip = Path.combine(PathOperation.difference, Path()..addRect(Offset.zero & size), Path()..addRRect(rrect));
canvas.save();
canvas.clipPath(clip);
for (final shadow in shadows) {
canvas.drawRRect(rrect.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint());
}
canvas.restore();
}
@override
bool shouldRepaint(_FocusGlowPainter oldDelegate) {
return oldDelegate.rect != rect ||
oldDelegate.borderRadius != borderRadius ||
!listEquals(oldDelegate.shadows, shadows);
}
}