266 lines
7.0 KiB
Dart
266 lines
7.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'focus_theme.dart';
|
|
import 'input_mode_tracker.dart';
|
|
|
|
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
|
///
|
|
/// Provides:
|
|
/// - Visual focus indicator (border + scale animation)
|
|
/// - Keyboard/D-pad event handling (Enter/Select to activate)
|
|
/// - Optional auto-scroll to keep focused item visible
|
|
class FocusableWrapper extends StatefulWidget {
|
|
/// The child widget to wrap.
|
|
final Widget child;
|
|
|
|
/// Called when the item is selected (Enter/Select/GamepadA).
|
|
final VoidCallback? onSelect;
|
|
|
|
/// Called when long press is triggered (context menu key).
|
|
final VoidCallback? onLongPress;
|
|
|
|
/// Called when focus changes.
|
|
final ValueChanged<bool>? onFocusChange;
|
|
|
|
/// Whether this widget should request focus when first built.
|
|
final bool autofocus;
|
|
|
|
/// Optional external FocusNode for programmatic focus control.
|
|
final FocusNode? focusNode;
|
|
|
|
/// Border radius for the focus indicator.
|
|
final double borderRadius;
|
|
|
|
/// Whether to scroll the widget into view when focused.
|
|
final bool autoScroll;
|
|
|
|
/// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end).
|
|
final double scrollAlignment;
|
|
|
|
/// Optional semantic label for accessibility.
|
|
final String? semanticLabel;
|
|
|
|
/// Whether the wrapper can receive focus.
|
|
final bool canRequestFocus;
|
|
|
|
/// Custom key event handler. Return KeyEventResult.handled to consume the event.
|
|
/// This is called before the default key handling.
|
|
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
|
|
|
|
const FocusableWrapper({
|
|
super.key,
|
|
required this.child,
|
|
this.onSelect,
|
|
this.onLongPress,
|
|
this.onFocusChange,
|
|
this.autofocus = false,
|
|
this.focusNode,
|
|
this.borderRadius = FocusTheme.defaultBorderRadius,
|
|
this.autoScroll = true,
|
|
this.scrollAlignment = 0.5,
|
|
this.semanticLabel,
|
|
this.canRequestFocus = true,
|
|
this.onKeyEvent,
|
|
});
|
|
|
|
@override
|
|
State<FocusableWrapper> createState() => _FocusableWrapperState();
|
|
}
|
|
|
|
class _FocusableWrapperState extends State<FocusableWrapper>
|
|
with SingleTickerProviderStateMixin {
|
|
late FocusNode _focusNode;
|
|
bool _ownsNode = false;
|
|
bool _isFocused = false;
|
|
|
|
late AnimationController _animationController;
|
|
late Animation<double> _scaleAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initFocusNode();
|
|
_initAnimations();
|
|
}
|
|
|
|
void _initFocusNode() {
|
|
if (widget.focusNode != null) {
|
|
_focusNode = widget.focusNode!;
|
|
_ownsNode = false;
|
|
} else {
|
|
_focusNode = FocusNode(
|
|
debugLabel: widget.semanticLabel ?? 'FocusableWrapper',
|
|
canRequestFocus: widget.canRequestFocus,
|
|
);
|
|
_ownsNode = true;
|
|
}
|
|
}
|
|
|
|
void _initAnimations() {
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 150),
|
|
);
|
|
|
|
_scaleAnimation = Tween<double>(
|
|
begin: 1.0,
|
|
end: FocusTheme.focusScale,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeOutCubic,
|
|
));
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(FocusableWrapper oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
// Handle focusNode changes
|
|
if (widget.focusNode != oldWidget.focusNode) {
|
|
if (_ownsNode) {
|
|
_focusNode.dispose();
|
|
}
|
|
_initFocusNode();
|
|
}
|
|
|
|
// Update canRequestFocus
|
|
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
|
|
_focusNode.canRequestFocus = widget.canRequestFocus;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
if (_ownsNode) {
|
|
_focusNode.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleFocusChange(bool hasFocus) {
|
|
if (_isFocused != hasFocus) {
|
|
setState(() {
|
|
_isFocused = hasFocus;
|
|
});
|
|
|
|
// Animate scale
|
|
if (hasFocus) {
|
|
_animationController.forward();
|
|
} else {
|
|
_animationController.reverse();
|
|
}
|
|
|
|
// Auto-scroll into view
|
|
if (hasFocus && widget.autoScroll) {
|
|
_scrollIntoView();
|
|
}
|
|
|
|
// Notify listener
|
|
widget.onFocusChange?.call(hasFocus);
|
|
}
|
|
}
|
|
|
|
void _scrollIntoView() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
|
|
final renderObject = context.findRenderObject();
|
|
if (renderObject == null) return;
|
|
|
|
Scrollable.ensureVisible(
|
|
context,
|
|
alignment: widget.scrollAlignment,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
});
|
|
}
|
|
|
|
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
|
|
// Call custom key handler first
|
|
if (widget.onKeyEvent != null) {
|
|
final result = widget.onKeyEvent!(node, event);
|
|
if (result == KeyEventResult.handled) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Handle Select/Enter for activation
|
|
if (_isSelectKey(event.logicalKey)) {
|
|
widget.onSelect?.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// Handle context menu key
|
|
if (_isContextMenuKey(event.logicalKey)) {
|
|
widget.onLongPress?.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
bool _isSelectKey(LogicalKeyboardKey key) {
|
|
return key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.numpadEnter ||
|
|
key == LogicalKeyboardKey.gameButtonA;
|
|
}
|
|
|
|
bool _isContextMenuKey(LogicalKeyboardKey key) {
|
|
return key == LogicalKeyboardKey.contextMenu ||
|
|
key == LogicalKeyboardKey.gameButtonX;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final duration = FocusTheme.getAnimationDuration(context);
|
|
// Only show focus effects during keyboard/d-pad navigation
|
|
final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context);
|
|
|
|
// Update animation duration if theme changes
|
|
if (_animationController.duration != duration) {
|
|
_animationController.duration = duration;
|
|
}
|
|
|
|
Widget result = Focus(
|
|
focusNode: _focusNode,
|
|
autofocus: widget.autofocus,
|
|
onFocusChange: _handleFocusChange,
|
|
onKeyEvent: _handleKeyEvent,
|
|
child: AnimatedBuilder(
|
|
animation: _scaleAnimation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: showFocus ? _scaleAnimation.value : 1.0,
|
|
child: AnimatedContainer(
|
|
duration: duration,
|
|
curve: Curves.easeOutCubic,
|
|
decoration: FocusTheme.focusDecoration(
|
|
context,
|
|
isFocused: showFocus,
|
|
borderRadius: widget.borderRadius,
|
|
),
|
|
child: widget.child,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
// Add semantics if label provided
|
|
if (widget.semanticLabel != null) {
|
|
result = Semantics(
|
|
label: widget.semanticLabel,
|
|
button: widget.onSelect != null,
|
|
child: result,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|