fix(tv): correct long hub row focus scrolling
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Scroll the nearest scrollable ancestor so [context] is centered.
|
||||
@@ -70,8 +73,59 @@ void scrollListToIndex(
|
||||
final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent);
|
||||
|
||||
if (animate) {
|
||||
controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
unawaited(controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut));
|
||||
} else {
|
||||
controller.jumpTo(desiredOffset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll a horizontal list so the keyed child is centered using its real layout
|
||||
/// bounds. This corrects small per-item extent drift in long carousels.
|
||||
void scrollKeyedChildToHorizontalCenter(
|
||||
ScrollController controller,
|
||||
GlobalKey key, {
|
||||
bool animate = true,
|
||||
int maxAttempts = 2,
|
||||
}) {
|
||||
void schedule(int attempt) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final context = key.currentContext;
|
||||
if (context == null) {
|
||||
if (attempt < maxAttempts) schedule(attempt + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
final didResolve = _scrollContextToHorizontalCenterNow(controller, context, animate: animate);
|
||||
if (!didResolve && attempt < maxAttempts) schedule(attempt + 1);
|
||||
});
|
||||
}
|
||||
|
||||
schedule(0);
|
||||
}
|
||||
|
||||
bool _scrollContextToHorizontalCenterNow(ScrollController controller, BuildContext context, {required bool animate}) {
|
||||
if (!context.mounted || controller.positions.length != 1) return true;
|
||||
|
||||
final position = controller.position;
|
||||
if (position.axis != Axis.horizontal) return true;
|
||||
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject == null || !renderObject.attached) return false;
|
||||
|
||||
final viewport = RenderAbstractViewport.maybeOf(renderObject);
|
||||
if (viewport == null) return false;
|
||||
|
||||
final target = viewport
|
||||
.getOffsetToReveal(renderObject, 0.5)
|
||||
.offset
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if ((target - position.pixels).abs() < 0.5) return true;
|
||||
|
||||
if (animate) {
|
||||
unawaited(controller.animateTo(target, duration: const Duration(milliseconds: 150), curve: Curves.easeOut));
|
||||
} else {
|
||||
controller.jumpTo(target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -125,12 +125,14 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
void didUpdateWidget(HubSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.hub.id != oldWidget.hub.id) {
|
||||
_itemKeys.clear();
|
||||
_mediaCardKeys.clear();
|
||||
} else if (widget.hub.items.length != oldWidget.hub.items.length) {
|
||||
} else if (widget.hub.items.length != oldWidget.hub.items.length || widget.hub.more != oldWidget.hub.more) {
|
||||
_itemKeys.removeWhere((index, _) => index >= _totalItemCount);
|
||||
_mediaCardKeys.removeWhere((index, _) => index >= widget.hub.items.length);
|
||||
}
|
||||
|
||||
if (widget.hub.items.length != oldWidget.hub.items.length) {
|
||||
if (widget.hub.items.length != oldWidget.hub.items.length || widget.hub.more != oldWidget.hub.more) {
|
||||
final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1;
|
||||
if (_focusedIndex > maxIndex) {
|
||||
_focusedIndex = maxIndex;
|
||||
@@ -211,6 +213,9 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
leadingPadding: _leadingPadding,
|
||||
animate: animate,
|
||||
);
|
||||
if (index >= 0 && index < _totalItemCount) {
|
||||
scrollKeyedChildToHorizontalCenter(_scrollController, _itemKeyFor(index), animate: animate);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ALL key events at the hub level
|
||||
@@ -316,8 +321,13 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
/// GlobalKeys for MediaCards to access their state (for context menu)
|
||||
final Map<int, GlobalKey> _itemKeys = {};
|
||||
final Map<int, GlobalKey<MediaCardState>> _mediaCardKeys = {};
|
||||
|
||||
GlobalKey _itemKeyFor(int index) {
|
||||
return _itemKeys.putIfAbsent(index, () => GlobalKey());
|
||||
}
|
||||
|
||||
GlobalKey<MediaCardState> _getMediaCardKey(int index) {
|
||||
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
|
||||
}
|
||||
@@ -492,6 +502,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
|
||||
if (index == widget.hub.items.length) {
|
||||
return Padding(
|
||||
key: _itemKeyFor(index),
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.only(right: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 2),
|
||||
@@ -533,6 +544,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
final item = widget.hub.items[index];
|
||||
|
||||
return Padding(
|
||||
key: _itemKeyFor(index),
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.only(right: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 2),
|
||||
|
||||
@@ -295,6 +295,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final Map<String, ScrollController> _scrollControllers = {};
|
||||
final ScrollController _verticalController = ScrollController();
|
||||
final Map<int, GlobalKey> _hubSectionKeys = {};
|
||||
final Map<String, GlobalKey> _itemKeys = {};
|
||||
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
|
||||
|
||||
int _hubIndex = 0;
|
||||
@@ -656,6 +657,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
leadingPadding: _railLeadingPadding,
|
||||
animate: animate,
|
||||
);
|
||||
if (_itemIndex >= 0 && _itemIndex < _totalItemCount(hub)) {
|
||||
scrollKeyedChildToHorizontalCenter(controller, _itemKeyFor(hub, _itemIndex), animate: animate);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToItemAfterLayout({bool animate = true}) {
|
||||
@@ -693,6 +697,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey<MediaCardState>());
|
||||
}
|
||||
|
||||
GlobalKey _itemKeyFor(MediaHub hub, int itemIndex) {
|
||||
return _itemKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey());
|
||||
}
|
||||
|
||||
void _showContextMenuForCurrentItem() {
|
||||
final hub = _activeHub;
|
||||
if (hub == null || _itemIndex >= hub.items.length) return;
|
||||
@@ -1000,6 +1008,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex;
|
||||
if (itemIndex == hub.items.length) {
|
||||
return Padding(
|
||||
key: _itemKeyFor(hub, itemIndex),
|
||||
padding: EdgeInsets.only(right: metrics.itemGap),
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
@@ -1029,6 +1038,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
|
||||
final item = hub.items[itemIndex];
|
||||
return Padding(
|
||||
key: _itemKeyFor(hub, itemIndex),
|
||||
padding: EdgeInsets.only(right: metrics.itemGap),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _setHoveredItem(hub, itemIndex),
|
||||
|
||||
@@ -270,4 +270,42 @@ void main() {
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('scrollKeyedChildToHorizontalCenter', () {
|
||||
testWidgets('centers a keyed child using measured layout bounds', (tester) async {
|
||||
final controller = ScrollController();
|
||||
final itemKey = GlobalKey();
|
||||
const viewportWidth = 300.0;
|
||||
const widths = [90.0, 120.0, 70.0, 180.0, 110.0, 160.0, 100.0];
|
||||
const targetIndex = 4;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SizedBox(
|
||||
width: viewportWidth,
|
||||
height: 80,
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < widths.length; i++)
|
||||
SizedBox(key: i == targetIndex ? itemKey : null, width: widths[i], height: 80, child: Text('$i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
scrollKeyedChildToHorizontalCenter(controller, itemKey, animate: false);
|
||||
await tester.pump();
|
||||
|
||||
final leadingWidth = widths.take(targetIndex).fold<double>(0, (sum, width) => sum + width);
|
||||
final targetCenter = leadingWidth + (widths[targetIndex] / 2);
|
||||
final expected = (targetCenter - (viewportWidth / 2)).clamp(0.0, controller.position.maxScrollExtent);
|
||||
expect(controller.offset, closeTo(expected, 0.001));
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,6 +517,73 @@ void main() {
|
||||
expect(focused.last, 'movies:movie_5');
|
||||
});
|
||||
|
||||
testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async {
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.view.resetPhysicalSize();
|
||||
});
|
||||
|
||||
Future<void> pressRight() async {
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
|
||||
final episodes = List.generate(
|
||||
153,
|
||||
(index) => MediaItem(
|
||||
id: 'episode_${index + 1}',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode ${index + 1}',
|
||||
parentIndex: 11,
|
||||
index: index + 1,
|
||||
thumbPath: '/episode_${index + 1}',
|
||||
),
|
||||
);
|
||||
final hub = MediaHub(id: 'detail_season_11', title: 'Season 11', type: 'episode', items: episodes, size: 153);
|
||||
final serverManager = MultiServerManager();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
hubs: [hub],
|
||||
autofocus: true,
|
||||
iconForHub: (_, _) => Icons.tv_rounded,
|
||||
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < 117; i++) {
|
||||
await pressRight();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final targetTitle = find.text('Episode 118');
|
||||
expect(targetTitle, findsOneWidget);
|
||||
final railRect = tester.getRect(find.byType(TvBrowseRail));
|
||||
final targetRect = tester.getRect(targetTitle);
|
||||
expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5));
|
||||
expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5));
|
||||
});
|
||||
|
||||
testWidgets('resets long-press state when context menu focus receives select key up', (tester) async {
|
||||
final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe');
|
||||
addTearDown(menuFocusNode.dispose);
|
||||
|
||||
Reference in New Issue
Block a user