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.
200 lines
7.2 KiB
Dart
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);
|
|
}
|
|
}
|