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.
135 lines
4.7 KiB
Dart
135 lines
4.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../focus/focus_chrome.dart';
|
|
import '../focus/focus_theme.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import 'clickable_cursor.dart';
|
|
|
|
/// Shared builders for focusable widgets to reduce code duplication.
|
|
///
|
|
/// These builders provide consistent focus decoration patterns across
|
|
/// different focusable widgets (chips, cards, etc.).
|
|
class FocusBuilders {
|
|
/// Builds a chip-style focusable widget with background color changes.
|
|
///
|
|
/// Used by FocusableTabChip and FocusableFilterChip.
|
|
///
|
|
/// Parameters:
|
|
/// - [context]: Build context for theming
|
|
/// - [focusNode]: The focus node for this widget
|
|
/// - [isFocused]: Whether this widget currently has focus
|
|
/// - [onKeyEvent]: Callback for handling key events
|
|
/// - [onTap]: Callback for tap/click events
|
|
/// - [padding]: Padding inside the chip
|
|
/// - [backgroundColor]: Background color for the chip
|
|
/// - [borderRadius]: Border radius for the chip (defaults to 20)
|
|
/// - [child]: The content to display inside the chip
|
|
static Widget buildFocusableChip({
|
|
required BuildContext context,
|
|
required FocusNode focusNode,
|
|
required KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent,
|
|
required VoidCallback onTap,
|
|
required String semanticLabel,
|
|
bool? selected,
|
|
required EdgeInsetsGeometry padding,
|
|
required Color backgroundColor,
|
|
double borderRadius = 20,
|
|
required Widget child,
|
|
}) {
|
|
final duration = FocusTheme.getAnimationDuration(context);
|
|
|
|
return Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: onKeyEvent,
|
|
child: Semantics(
|
|
label: semanticLabel,
|
|
button: true,
|
|
selected: selected,
|
|
onTap: onTap,
|
|
excludeSemantics: true,
|
|
child: ClickableCursor(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: duration,
|
|
curve: Curves.easeOutCubic,
|
|
padding: padding,
|
|
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(borderRadius)),
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Builds a card-style wrapper with scale and border decoration but no [Focus]
|
|
/// node — focus lives on an enclosing rail or screen that passes [isFocused]
|
|
/// down.
|
|
///
|
|
/// Used by the hub row, the TV browse rail, the cast strip and the extras row.
|
|
/// Cards that own their focus node use [FocusableWrapper] instead; both share
|
|
/// the same chrome through [buildFocusChrome].
|
|
///
|
|
/// Parameters:
|
|
/// - [context]: Build context for theming
|
|
/// - [isFocused]: Whether this widget should appear focused
|
|
/// - [onTap]: Callback for tap/click events
|
|
/// - [onLongPress]: Callback for long press events
|
|
/// - [borderRadius]: Border radius for the focus decoration
|
|
/// - [child]: The content to display inside the wrapper
|
|
static Widget buildLockedFocusWrapper({
|
|
required BuildContext context,
|
|
required bool isFocused,
|
|
VoidCallback? onTap,
|
|
VoidCallback? onLongPress,
|
|
double borderRadius = FocusTheme.defaultBorderRadius,
|
|
double focusScale = FocusTheme.focusScale,
|
|
bool useFocusGlow = false,
|
|
bool delegateFocusBorder = false,
|
|
|
|
/// When set, overrides [isFocused] for the glow only — border and scale
|
|
/// keep following [isFocused]. Used by the TV rail to hold the glow back
|
|
/// while its vertical viewport is animating.
|
|
bool? showGlow,
|
|
Size? glowSize,
|
|
required Widget child,
|
|
}) {
|
|
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
|
|
|
// In touch mode, no item ever shows focus effects — skip animated wrappers
|
|
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
|
|
if (!isKeyboardMode) {
|
|
return (onTap != null || onLongPress != null)
|
|
? ClickableCursor(
|
|
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
|
|
)
|
|
: child;
|
|
}
|
|
|
|
final duration = FocusTheme.getAnimationDuration(context);
|
|
final focusedWidget = AnimatedScale(
|
|
scale: isFocused ? focusScale : 1.0,
|
|
duration: duration,
|
|
curve: Curves.easeOutCubic,
|
|
child: buildFocusChrome(
|
|
context,
|
|
showFocus: isFocused,
|
|
duration: duration,
|
|
borderRadius: borderRadius,
|
|
useFocusGlow: useFocusGlow,
|
|
delegateFocusBorder: delegateFocusBorder,
|
|
showGlow: showGlow,
|
|
glowSize: glowSize,
|
|
child: child,
|
|
),
|
|
);
|
|
|
|
// Wrap in GestureDetector if tap/long press handlers provided
|
|
return (onTap != null || onLongPress != null)
|
|
? ClickableCursor(
|
|
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
|
|
)
|
|
: focusedWidget;
|
|
}
|
|
}
|