perf(tv): cut semantics work during card navigation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import '../widgets/clickable_cursor.dart';
|
||||
import '../utils/text_input_diagnostics.dart';
|
||||
@@ -21,6 +22,62 @@ void _logFocusableWrapper(String message) {
|
||||
TextInputDiagnostics.log('FocusableWrapper', message);
|
||||
}
|
||||
|
||||
/// Applies a visual scale without changing hit-test or semantics geometry.
|
||||
///
|
||||
/// Focus scale is paint-only: animating a [Transform] marks the transformed
|
||||
/// subtree's semantics dirty on every frame, which is costly for dense TV
|
||||
/// grids. Keeping layout and semantics static preserves the same visible
|
||||
/// motion without rebuilding the accessibility tree.
|
||||
class _PaintScale extends SingleChildRenderObjectWidget {
|
||||
const _PaintScale({required this.scale, required super.child});
|
||||
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _RenderPaintScale(scale);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderPaintScale renderObject) {
|
||||
renderObject.scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderPaintScale extends RenderProxyBox {
|
||||
_RenderPaintScale(double scale) : _scale = scale;
|
||||
|
||||
final Matrix4 _transform = Matrix4.identity();
|
||||
double _scale;
|
||||
|
||||
set scale(double value) {
|
||||
if (_scale == value) return;
|
||||
_scale = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (child == null) return;
|
||||
if (_scale == 1) {
|
||||
layer = null;
|
||||
super.paint(context, offset);
|
||||
return;
|
||||
}
|
||||
|
||||
_transform
|
||||
..setIdentity()
|
||||
..setEntry(0, 0, _scale)
|
||||
..setEntry(1, 1, _scale)
|
||||
..setTranslationRaw((1 - _scale) * size.width / 2, (1 - _scale) * size.height / 2, 0);
|
||||
layer = context.pushTransform(
|
||||
needsCompositing,
|
||||
offset,
|
||||
_transform,
|
||||
super.paint,
|
||||
oldLayer: layer is TransformLayer ? layer as TransformLayer? : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
||||
///
|
||||
/// Provides:
|
||||
@@ -475,46 +532,46 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
controller.duration = duration;
|
||||
}
|
||||
|
||||
final shouldScale = showFocus && !widget.disableScale;
|
||||
// Keep the card subtree outside the scale builder. Rebuilding media-card
|
||||
// semantics on every animation tick is substantially more expensive than
|
||||
// changing the paint transform alone on dense TV grids.
|
||||
Widget card;
|
||||
if (widget.delegateFocusBorder) {
|
||||
card = CardFocusScope(showFocus: showFocus, child: widget.child);
|
||||
} else {
|
||||
final focusDecoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
color: widget.focusColor,
|
||||
);
|
||||
card = AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: focusDecoration,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
if (widget.useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
inner = AnimatedBuilder(
|
||||
animation: _scaleAnimation!,
|
||||
builder: (context, child) {
|
||||
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;
|
||||
if (widget.delegateFocusBorder) {
|
||||
card = CardFocusScope(showFocus: showFocus, child: widget.child);
|
||||
} else {
|
||||
final focusDecoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
color: widget.focusColor,
|
||||
);
|
||||
card = AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: focusDecoration,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
if (widget.useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
return Transform.scale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: card);
|
||||
},
|
||||
child: card,
|
||||
builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,9 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
|
||||
final semanticLabel = mediaCardSemanticLabel(item);
|
||||
final enableDetailLinks = widget.onTap == null;
|
||||
final preserveDetailSemantics = enableDetailLinks && item is MediaItem && _hasPointerDetailLinks(item);
|
||||
final preservePointerDetailSemantics = !PlatformDetector.isTV() || MediaQuery.accessibleNavigationOf(context);
|
||||
final preserveDetailSemantics =
|
||||
preservePointerDetailSemantics && enableDetailLinks && item is MediaItem && _hasPointerDetailLinks(item);
|
||||
final localPosterPath = _getLocalPosterPath(context, item);
|
||||
|
||||
Widget cardWidget = viewMode == ViewMode.grid
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../navigation/main_screen_scope.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../screens/hub_detail_screen.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
@@ -407,6 +408,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
|
||||
final Map<String, ScrollController> _scrollControllers = {};
|
||||
final ScrollController _verticalController = ScrollController();
|
||||
final SnapshotController _verticalScrollSnapshotController = SnapshotController();
|
||||
final Map<int, GlobalKey> _hubSectionKeys = {};
|
||||
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
|
||||
final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {};
|
||||
@@ -429,6 +431,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
VoidCallback? _gestureSignalListener;
|
||||
bool _suppressSelectUntilKeyUp = false;
|
||||
bool _hasUserChangedHub = false;
|
||||
int _verticalScrollGeneration = 0;
|
||||
bool _hasUserChangedItem = false;
|
||||
|
||||
MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)];
|
||||
@@ -610,6 +613,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
controller.dispose();
|
||||
}
|
||||
_verticalController.dispose();
|
||||
_verticalScrollGeneration++;
|
||||
_verticalScrollSnapshotController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -797,14 +802,16 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
if (_verticalController.hasClients && _hubIndex >= 0 && _hubIndex < _sectionOffsets.length) {
|
||||
final target = _sectionOffsets[_hubIndex].clamp(0.0, _sectionMaxScrollExtent).toDouble();
|
||||
if (animate) {
|
||||
unawaited(
|
||||
_verticalController.animateTo(
|
||||
_startVerticalScrollAnimation(
|
||||
() => _verticalController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_verticalScrollGeneration++;
|
||||
_verticalScrollSnapshotController.allowSnapshotting = false;
|
||||
_verticalController.jumpTo(target);
|
||||
}
|
||||
return;
|
||||
@@ -813,18 +820,39 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final key = _hubSectionKeys[_hubIndex];
|
||||
final context = key?.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0,
|
||||
duration: animate ? const Duration(milliseconds: 250) : Duration.zero,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
if (animate) {
|
||||
_startVerticalScrollAnimation(
|
||||
() => Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_verticalScrollGeneration++;
|
||||
_verticalScrollSnapshotController.allowSnapshotting = false;
|
||||
unawaited(Scrollable.ensureVisible(context, alignment: 0, duration: Duration.zero));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startVerticalScrollAnimation(Future<void> Function() animate) {
|
||||
final generation = ++_verticalScrollGeneration;
|
||||
// Full-tier row effects must stay live. The reduced tier has already
|
||||
// resolved those short effects to their final state before this snapshot.
|
||||
final useSnapshots = DevicePerformance.isReduced;
|
||||
if (useSnapshots) {
|
||||
_verticalScrollSnapshotController.allowSnapshotting = true;
|
||||
}
|
||||
unawaited(
|
||||
animate().whenComplete(() {
|
||||
if (!mounted || generation != _verticalScrollGeneration) return;
|
||||
_verticalScrollSnapshotController.allowSnapshotting = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _setHoveredItem(MediaHub hub, int index) {
|
||||
final active = _activeHub;
|
||||
if (active == null || _hubKey(active) != _hubKey(hub) || index >= hub.items.length || _itemIndex == index) {
|
||||
@@ -1195,31 +1223,36 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
// below is passed through as a stable child.
|
||||
bool isActiveHub() => _focusModel.hubIndex == hubIndex;
|
||||
|
||||
return SizedBox(
|
||||
key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()),
|
||||
height: sectionHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
ListenableSelector<bool>(
|
||||
listenable: _focusModel,
|
||||
selector: isActiveHub,
|
||||
builder: (context, isActive, _) =>
|
||||
_buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale),
|
||||
),
|
||||
SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)),
|
||||
_buildHubRail(
|
||||
hub: hub,
|
||||
hubIndex: hubIndex,
|
||||
episodePosterMode: modes[hubIndex],
|
||||
metrics: metrics,
|
||||
scale: scale,
|
||||
fullCardLayout: fullCardLayout,
|
||||
leftOverflow: leftOverflow,
|
||||
interactionExpansion: interactionExpansion,
|
||||
railViewportWidth: railViewportWidth,
|
||||
),
|
||||
],
|
||||
return SnapshotWidget(
|
||||
controller: _verticalScrollSnapshotController,
|
||||
mode: SnapshotMode.permissive,
|
||||
autoresize: true,
|
||||
child: SizedBox(
|
||||
key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()),
|
||||
height: sectionHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
ListenableSelector<bool>(
|
||||
listenable: _focusModel,
|
||||
selector: isActiveHub,
|
||||
builder: (context, isActive, _) =>
|
||||
_buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale),
|
||||
),
|
||||
SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)),
|
||||
_buildHubRail(
|
||||
hub: hub,
|
||||
hubIndex: hubIndex,
|
||||
episodePosterMode: modes[hubIndex],
|
||||
metrics: metrics,
|
||||
scale: scale,
|
||||
fullCardLayout: fullCardLayout,
|
||||
leftOverflow: leftOverflow,
|
||||
interactionExpansion: interactionExpansion,
|
||||
railViewportWidth: railViewportWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user