正在播放的自滑动刹车动画动作优化完成,同时调整播放页进入动作

This commit is contained in:
2026-08-28 22:45:14 +08:00
parent 2e55f94df2
commit 86b1d0df42
2 changed files with 116 additions and 63 deletions
+83 -40
View File
@@ -14,12 +14,17 @@ class _PlaylistPageState extends State<PlaylistPage> {
final ScrollController _scrollController = ScrollController();
final GlobalKey _currentTagKey = GlobalKey();
// ⭐ 定位事务 ID(防止旧任务污染)
int _scrollGeneration = 0;
// ⭐ 是否已完成定位
bool _hasScrolledToCurrent = false;
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
// 实际约 72-76px,取 74px 作为稳定值
static const double _itemExtent = 74.0;
// 舒适区范围
static const double _comfortZoneTop = 0.15;
static const double _comfortZoneBottom = 0.85;
@override
void initState() {
super.initState();
@@ -34,7 +39,18 @@ class _PlaylistPageState extends State<PlaylistPage> {
super.dispose();
}
/// ⭐ 核心:一次计算 + 一次动画
bool _isItemInComfortZone(
RenderBox renderBox, double viewportHeight, double listTop) {
final itemGlobal = renderBox.localToGlobal(Offset.zero);
final itemTop = itemGlobal.dy - listTop;
final itemBottom = itemTop + renderBox.size.height;
final topBoundary = viewportHeight * _comfortZoneTop;
final bottomBoundary = viewportHeight * _comfortZoneBottom;
return itemTop >= topBoundary && itemBottom <= bottomBoundary;
}
Future<void> _scrollToCurrentSong() async {
if (_hasScrolledToCurrent) return;
@@ -46,42 +62,59 @@ class _PlaylistPageState extends State<PlaylistPage> {
return;
}
// 生成新的事务 ID,使旧任务失效
final generation = ++_scrollGeneration;
// 等待页面稳定
await Future.delayed(const Duration(milliseconds: 300));
// ⭐ 等待两帧,确保 ListView 完全稳定
await WidgetsBinding.instance.endOfFrame;
await WidgetsBinding.instance.endOfFrame;
if (!mounted || generation != _scrollGeneration) return;
// 获取目标 item 的当前布局状态
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第一阶段:检测当前歌曲是否已经在舒适区内
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
var tagContext = _currentTagKey.currentContext;
// ⭐ 如果 Tag 还没被构建,用不可见的 jumpTo 触发布局
if (tagContext != null) {
final renderBox = tagContext.findRenderObject() as RenderBox?;
if (renderBox != null) {
final viewportHeight = _scrollController.position.viewportDimension;
final listRenderBox = context.findRenderObject() as RenderBox?;
if (listRenderBox != null) {
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
if (_isItemInComfortZone(renderBox, viewportHeight, listTop)) {
debugPrint('🎯 [定位] 当前歌曲已在舒适区内,不滚动');
_hasScrolledToCurrent = true;
return;
}
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第二阶段:粗定位(使用固定 itemExtent,不再依赖 maxExtent
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (tagContext == null) {
// 用 maxExtent 计算平均高度,做一次瞬时定位
final maxExtent = _scrollController.position.maxScrollExtent;
final avgHeight = maxExtent / queue.length;
final viewportHeight = _scrollController.position.viewportDimension;
final roughOffset =
(currentIndex * avgHeight) - (viewportHeight / 2) + (avgHeight / 2);
// ⭐ 固定高度计算,无需读取 maxExtent,更稳定更快
final roughOffset = (currentIndex * _itemExtent) - (viewportHeight * 0.3);
final maxExtent = _scrollController.position.maxScrollExtent;
final clamped = roughOffset.clamp(0.0, maxExtent);
// ⭐ 瞬时跳转(用户不可见)
// ⭐ 不可见瞬时跳转
_scrollController.jumpTo(clamped);
// 等待一帧,让目标 item 被构建
// 等待目标被构建(只需要一帧,因为高度固定,布局更快)
await WidgetsBinding.instance.endOfFrame;
if (!mounted || generation != _scrollGeneration) return;
tagContext = _currentTagKey.currentContext;
// 如果还是 null说明估算偏差太大,用迭代方式推进
// 如果还是 null,用迭代方式推进(但固定高度下很少需要)
if (tagContext == null) {
// 最多尝试 2 次,每次推进半屏
for (int i = 0; i < 2; i++) {
final viewportHeight2 = _scrollController.position.viewportDimension;
final direction = (currentIndex > queue.length / 2) ? -1 : 1;
final step = viewportHeight2 * 0.7 * direction;
final step = viewportHeight2 * 0.5 * direction;
final newOffset = (_scrollController.offset + step)
.clamp(0.0, _scrollController.position.maxScrollExtent);
_scrollController.jumpTo(newOffset);
@@ -99,43 +132,51 @@ class _PlaylistPageState extends State<PlaylistPage> {
if (!mounted || generation != _scrollGeneration) return;
// ⭐ 获取 Tag 的真实位置
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 第三阶段:精确定位到舒适区
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
final renderBox = tagContext!.findRenderObject() as RenderBox?;
if (renderBox == null) {
_hasScrolledToCurrent = true;
return;
}
final tagPosition = renderBox.localToGlobal(Offset.zero);
final tagSize = renderBox.size;
final tagCenter = tagPosition.dy + tagSize.height / 2;
final screenHeight = MediaQuery.of(context).size.height;
final screenCenter = screenHeight / 2;
final delta = tagCenter - screenCenter;
final currentOffset = _scrollController.offset;
final targetOffset = (currentOffset + delta)
.clamp(0.0, _scrollController.position.maxScrollExtent);
// 如果偏差太小,直接标记完成
if ((targetOffset - currentOffset).abs() < 5) {
final listRenderBox = context.findRenderObject() as RenderBox?;
if (listRenderBox == null) {
_hasScrolledToCurrent = true;
return;
}
// ⭐ 根据距离动态计算时长
final itemGlobal = renderBox.localToGlobal(Offset.zero);
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
final viewportHeight = _scrollController.position.viewportDimension;
final itemHeight = renderBox.size.height;
final targetTop = viewportHeight * _comfortZoneTop;
final relativeOffset = itemGlobal.dy - listTop;
final currentOffset = _scrollController.offset;
final targetOffset = (currentOffset + relativeOffset - targetTop)
.clamp(0.0, _scrollController.position.maxScrollExtent);
final distance = (targetOffset - currentOffset).abs();
final durationMs = (250 + distance * 0.3).clamp(250, 700).round();
final duration = Duration(milliseconds: durationMs);
if (distance < 5) {
_hasScrolledToCurrent = true;
return;
}
final isFirstEntry = _scrollController.offset == 0 && currentIndex > 5;
final durationMs = isFirstEntry
? (150 + distance * 0.12).clamp(150, 350).round()
: (200 + distance * 0.15).clamp(200, 500).round();
debugPrint(
'🎯 [定位] 距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms');
'🎯 [定位] 偏移: ${currentOffset.toStringAsFixed(0)}${targetOffset.toStringAsFixed(0)}, '
'距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms');
// ⭐ 唯一的一次可见动画
await _scrollController.animateTo(
targetOffset,
duration: duration,
duration: Duration(milliseconds: durationMs),
curve: Curves.easeOutCubic,
);
@@ -218,6 +259,8 @@ class _PlaylistPageState extends State<PlaylistPage> {
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// ⭐ 关键优化:固定 item 高度
itemExtent: _itemExtent,
itemCount: queue.length,
itemBuilder: (context, index) {
final song = queue[index];