fix: Improved logic horizontal list navigation

This commit is contained in:
PartyDonut 2025-10-16 22:22:50 +02:00
parent b4e68c9e15
commit 47128eb3b6
4 changed files with 40 additions and 15 deletions

View file

@ -10,6 +10,7 @@ import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sticky_header_text.dart'; import 'package:fladder/util/sticky_header_text.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class NextUpEpisode extends ConsumerWidget { class NextUpEpisode extends ConsumerWidget {
final EpisodeModel nextEpisode; final EpisodeModel nextEpisode;
@ -49,6 +50,11 @@ class NextUpEpisode extends ConsumerWidget {
showLabel: false, showLabel: false,
onTap: () => nextEpisode.navigateTo(context), onTap: () => nextEpisode.navigateTo(context),
actions: const [], actions: const [],
onFocusChanged: (value) {
if (value) {
context.ensureVisible();
}
},
isCurrentEpisode: false, isCurrentEpisode: false,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -71,6 +77,11 @@ class NextUpEpisode extends ConsumerWidget {
showLabel: false, showLabel: false,
onTap: () => nextEpisode.navigateTo(context), onTap: () => nextEpisode.navigateTo(context),
actions: const [], actions: const [],
onFocusChanged: (value) {
if (value) {
context.ensureVisible();
}
},
isCurrentEpisode: false, isCurrentEpisode: false,
), ),
), ),

View file

@ -134,6 +134,7 @@ class EpisodePoster extends ConsumerWidget {
final Function()? onLongPress; final Function()? onLongPress;
final bool blur; final bool blur;
final List<ItemAction> actions; final List<ItemAction> actions;
final Function(bool value)? onFocusChanged;
final bool isCurrentEpisode; final bool isCurrentEpisode;
final Object? heroTag; final Object? heroTag;
@ -145,6 +146,7 @@ class EpisodePoster extends ConsumerWidget {
this.onLongPress, this.onLongPress,
this.blur = false, this.blur = false,
required this.actions, required this.actions,
this.onFocusChanged,
required this.isCurrentEpisode, required this.isCurrentEpisode,
this.heroTag, this.heroTag,
}); });
@ -167,6 +169,7 @@ class EpisodePoster extends ConsumerWidget {
child: FocusButton( child: FocusButton(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
onFocusChanged: onFocusChanged,
onSecondaryTapDown: (details) async { onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition; Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect position =

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
extension EnsureVisibleHelper on BuildContext { extension EnsureVisibleHelper on BuildContext {
Future<void> ensureVisible({ Future<void> ensureVisible({
Duration duration = const Duration(milliseconds: 225), Duration duration = const Duration(milliseconds: 275),
double? alignment, double? alignment,
Curve curve = Curves.fastOutSlowIn, Curve curve = Curves.fastOutSlowIn,
}) { }) {

View file

@ -11,7 +11,6 @@ import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/sticky_header_text.dart'; import 'package:fladder/util/sticky_header_text.dart';
import 'package:fladder/util/throttler.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart';
@ -100,12 +99,11 @@ class _HorizontalListState extends ConsumerState<HorizontalList> with TickerProv
final target = (index * (_firstItemWidth! + contentPadding)).clamp(0, _scrollController.position.maxScrollExtent); final target = (index * (_firstItemWidth! + contentPadding)).clamp(0, _scrollController.position.maxScrollExtent);
// Cancel any ongoing animation
_scrollAnimation?.stop(); _scrollAnimation?.stop();
final controller = AnimationController( final controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 125), duration: const Duration(milliseconds: 275),
); );
_scrollAnimation = controller; _scrollAnimation = controller;
@ -115,15 +113,21 @@ class _HorizontalListState extends ConsumerState<HorizontalList> with TickerProv
end: target.toDouble(), end: target.toDouble(),
); );
final animation = CurvedAnimation(
parent: controller,
curve: Curves.fastOutSlowIn,
);
controller.addListener(() { controller.addListener(() {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.jumpTo(tween.evaluate(controller)); _scrollController.jumpTo(tween.evaluate(animation));
} }
}); });
controller.forward().whenComplete(() { await controller.forward();
if (_scrollAnimation == controller) _scrollAnimation = null;
}); if (_scrollAnimation == controller) _scrollAnimation = null;
controller.dispose();
} }
void _scrollToStart() { void _scrollToStart() {
@ -272,7 +276,8 @@ class _HorizontalListState extends ConsumerState<HorizontalList> with TickerProv
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: HorizontalRailFocus( policy: HorizontalRailFocus(
parentNode: parentNode, parentNode: parentNode,
throttle: Throttler(duration: const Duration(milliseconds: 100)), scrollController: _scrollController,
firstItemWidth: _firstItemWidth ?? 250,
onFocused: (node) { onFocused: (node) {
lastFocused = node; lastFocused = node;
final correctIndex = _getCorrectIndexForNode(node); final correctIndex = _getCorrectIndexForNode(node);
@ -288,6 +293,7 @@ class _HorizontalListState extends ConsumerState<HorizontalList> with TickerProv
clipBehavior: Clip.none, clipBehavior: Clip.none,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: widget.contentPadding, padding: widget.contentPadding,
cacheExtent: _firstItemWidth ?? 250 * 3,
itemBuilder: (context, index) => index == widget.items.length itemBuilder: (context, index) => index == widget.items.length
? PosterPlaceHolder( ? PosterPlaceHolder(
onTap: widget.onLabelClick ?? () {}, onTap: widget.onLabelClick ?? () {},
@ -371,28 +377,33 @@ List<FocusNode> _nodesInRow(FocusNode parentNode) {
class HorizontalRailFocus extends WidgetOrderTraversalPolicy { class HorizontalRailFocus extends WidgetOrderTraversalPolicy {
final FocusNode parentNode; final FocusNode parentNode;
final void Function(FocusNode node) onFocused; final void Function(FocusNode node) onFocused;
final Throttler? throttle; final ScrollController scrollController;
final double firstItemWidth;
HorizontalRailFocus({ HorizontalRailFocus({
required this.parentNode, required this.parentNode,
required this.onFocused, required this.onFocused,
this.throttle, required this.scrollController,
required this.firstItemWidth,
}); });
@override @override
bool inDirection(FocusNode currentNode, TraversalDirection direction) { bool inDirection(FocusNode currentNode, TraversalDirection direction) {
final rowNodes = _nodesInRow(parentNode); final rowNodes = _nodesInRow(parentNode);
final index = rowNodes.indexOf(currentNode); final index = rowNodes.indexOf(currentNode);
if (index == -1) return false;
if (direction == TraversalDirection.left) { if (direction == TraversalDirection.left) {
if (throttle?.canRun() == false) return true; if (scrollController.hasClients && scrollController.offset <= firstItemWidth * 0.5) {
lastMainFocus = currentNode;
navBarNode.requestFocus();
return true;
}
if (index > 0) { if (index > 0) {
final target = rowNodes[index - 1]; final target = rowNodes[index - 1];
target.requestFocus(); target.requestFocus();
onFocused(target); onFocused(target);
} else {
lastMainFocus = currentNode;
navBarNode.requestFocus();
} }
return true; return true;
} }