fix(tv): hug poster with focus border and add focus glow toggle
close #1278
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'focus_theme.dart';
|
||||
|
||||
/// Exposes the focus state of an enclosing focus wrapper to a descendant
|
||||
/// [CardFocusBorder] that draws the focus border itself.
|
||||
///
|
||||
/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this
|
||||
/// instead of painting a border when `delegateFocusBorder` is set, so cards can
|
||||
/// put the border on the exact rect the design highlights (the poster image,
|
||||
/// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder]
|
||||
/// element registers a dependency, so a focus flip rebuilds just that border
|
||||
/// box: a stable `child:` card subtree (e.g. the TV rail's MediaCard) is not
|
||||
/// rebuilt.
|
||||
class CardFocusScope extends InheritedWidget {
|
||||
const CardFocusScope({super.key, required this.showFocus, required super.child});
|
||||
|
||||
/// Whether the enclosing wrapper currently shows focus visuals
|
||||
/// (focused while in keyboard/d-pad input mode).
|
||||
final bool showFocus;
|
||||
|
||||
/// Null when no delegating wrapper is above (touch mode skips the focus
|
||||
/// wrappers entirely) — [CardFocusBorder] then renders its child bare.
|
||||
static bool? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType<CardFocusScope>()?.showFocus;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(CardFocusScope oldWidget) => showFocus != oldWidget.showFocus;
|
||||
}
|
||||
|
||||
/// Draws the focus border around its child based on the enclosing
|
||||
/// [CardFocusScope], letting the card decide which rect gets highlighted.
|
||||
///
|
||||
/// Defaults to an outside stroke so the border hugs the child exactly like the
|
||||
/// full-bleed card treatment (the child's own corner radius nests inside it).
|
||||
/// Decoration only — never affects layout. Renders the child unchanged when no
|
||||
/// scope is present.
|
||||
class CardFocusBorder extends StatelessWidget {
|
||||
const CardFocusBorder({
|
||||
super.key,
|
||||
required this.borderRadius,
|
||||
this.strokeAlign = BorderSide.strokeAlignOutside,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double borderRadius;
|
||||
final double strokeAlign;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showFocus = CardFocusScope.maybeOf(context);
|
||||
if (showFocus == null) return child;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: FocusTheme.getAnimationDuration(context),
|
||||
curve: Curves.easeOutCubic,
|
||||
foregroundDecoration: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: borderRadius,
|
||||
borderStrokeAlign: strokeAlign,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -56,10 +57,15 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
/// fades out before the portal is hidden in [_handleFadeEnd].
|
||||
bool _visible = 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 && !DevicePerformance.isReduced) {
|
||||
if (widget.isFocused && !_disabled) {
|
||||
_visible = true;
|
||||
_controller.show();
|
||||
}
|
||||
@@ -68,7 +74,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
@override
|
||||
void didUpdateWidget(FocusGlowOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (DevicePerformance.isReduced) return;
|
||||
if (_disabled) return;
|
||||
if (widget.isFocused == oldWidget.isFocused) return;
|
||||
if (widget.isFocused) {
|
||||
_controller.show();
|
||||
@@ -92,9 +98,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Reduced tier: no glow at all — the blurred shadows + fade saveLayer are
|
||||
// too expensive on weak GPUs. The crisp in-card focus border remains.
|
||||
if (DevicePerformance.isReduced) return widget.child;
|
||||
if (_disabled) 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).
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import '../widgets/clickable_cursor.dart';
|
||||
import '../utils/text_input_diagnostics.dart';
|
||||
import 'card_focus_scope.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'focus_glow_overlay.dart';
|
||||
import 'focus_theme.dart';
|
||||
@@ -110,14 +111,13 @@ class FocusableWrapper extends StatefulWidget {
|
||||
/// Scale used for the focus animation.
|
||||
final double focusScale;
|
||||
|
||||
/// Stroke alignment for the focus border.
|
||||
final double focusBorderStrokeAlign;
|
||||
|
||||
/// Whether to draw a glow around the focused widget.
|
||||
final bool useFocusGlow;
|
||||
|
||||
/// Whether to draw the focus border as a foreground decoration.
|
||||
final bool useForegroundFocusDecoration;
|
||||
/// Skip drawing the focus border here and expose the focus state through a
|
||||
/// [CardFocusScope] instead, so the child places the border on the exact
|
||||
/// rect it wants highlighted (e.g. MediaCard's poster image).
|
||||
final bool delegateFocusBorder;
|
||||
|
||||
/// Whether descendants can receive focus.
|
||||
/// Set to false when the child widget has its own Focus (e.g. buttons)
|
||||
@@ -150,9 +150,8 @@ class FocusableWrapper extends StatefulWidget {
|
||||
this.focusColor,
|
||||
this.disableScale = false,
|
||||
this.focusScale = FocusTheme.focusScale,
|
||||
this.focusBorderStrokeAlign = BorderSide.strokeAlignInside,
|
||||
this.useFocusGlow = false,
|
||||
this.useForegroundFocusDecoration = false,
|
||||
this.delegateFocusBorder = false,
|
||||
this.descendantsAreFocusable = true,
|
||||
});
|
||||
|
||||
@@ -471,16 +470,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
_animationController.duration = duration;
|
||||
}
|
||||
|
||||
// Choose decoration based on useBackgroundFocus
|
||||
final focusDecoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
color: widget.focusColor,
|
||||
borderStrokeAlign: widget.focusBorderStrokeAlign,
|
||||
);
|
||||
Widget result = Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
@@ -493,13 +482,25 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final shouldScale = showFocus && !widget.disableScale;
|
||||
// The glow (full-bleed cards) is drawn in an overlay above siblings so
|
||||
// it stays symmetric; the in-card decoration only carries the border.
|
||||
Widget card = AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: widget.useForegroundFocusDecoration ? null : focusDecoration,
|
||||
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
|
||||
child: widget.child,
|
||||
);
|
||||
Widget card;
|
||||
if (widget.delegateFocusBorder) {
|
||||
card = CardFocusScope(showFocus: showFocus, child: widget.child);
|
||||
} else {
|
||||
final focusDecoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
color: widget.focusColor,
|
||||
);
|
||||
card = AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: focusDecoration,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
if (widget.useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
|
||||
Reference in New Issue
Block a user