跑马灯、时区、主题修复开发笔记

December 28, 2025
4 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

涉及功能: 视频卡片跑马灯、时区处理、深色模式主题适配


一、问题背景

1.1 视频卡片文本滚动问题

  • 现象: 视频消息卡片下方的文件名过长时不滚动
  • 期望: 文件名过长时自动滚动显示

1.2 视频时长不显示

  • 现象: 视频卡片左上角不显示时长
  • 原因: 发送视频时没有获取并保存 duration 字段

1.3 跑马灯滚动闪烁

  • 现象: 文本滚动完后突然闪一下从头播放
  • 期望: 无缝循环或平滑过渡

1.4 深色模式按钮颜色问题

  • 现象: 空会话列表的”新建会话”按钮在深色模式下都变成白色看不清
  • 期望: 深色模式使用浅色背景+深色文字

1.5 聊天按天分隔时区问题

  • 现象: 使用 UTC 时间导致日期分隔不正确
  • 期望: 使用本地时间进行日期判断

二、方案演进与取舍

2.1 跑马灯实现方案对比

方案 A:自定义往返滚动

实现思路:

  • 使用 AnimationController + CurvedAnimation
  • forward() 滚动到末尾后 reverse() 回滚
  • 通过 addStatusListener 监听动画状态实现往返

代码片段:

void _onAnimationStatus(AnimationStatus status) {
  if (status == AnimationStatus.completed) {
    Future.delayed(Duration(milliseconds: 1500), () {
      if (mounted) _controller.reverse();
    });
  } else if (status == AnimationStatus.dismissed) {
    Future.delayed(Duration(seconds: 2), () {
      if (mounted) _controller.forward();
    });
  }
}

问题: 往返滚动虽然没有跳变,但使用体验不如传统跑马灯

方案 B:自定义双文本首尾相接

实现思路:

  • 渲染两份相同的文本,中间加间隔
  • 使用 AnimationController.repeat() 实现无限循环
  • 通过 Transform.translate 移动两份文本

代码片段:

return Row(
  children: [
    Transform.translate(
      offset: Offset(-offset, 0),
      child: Text(widget.text, ...),
    ),
    SizedBox(width: _gap),
    Transform.translate(
      offset: Offset(-offset, 0),
      child: Text(widget.text, ...),
    ),
  ],
);

问题: 仍然有视觉闪烁,因为 Row 布局在 ClipRect 内的处理不够完美

方案 C:使用 marquee 包(最终采用)

实现思路:

  • 使用成熟的 marquee Flutter 包
  • 包内部已处理好无缝循环逻辑

优点:

  • 成熟稳定,无闪烁
  • 可配置参数丰富(velocity, blankSpace, pauseAfterRound 等)
  • 无需维护复杂的动画逻辑

安装:

flutter pub add marquee

最终代码:

import 'package:marquee/marquee.dart' as marquee_pkg;
 
// 需要固定高度容器,否则会报 RenderBox was not laid out 错误
return SizedBox(
  height: _textHeight,
  child: marquee_pkg.Marquee(
    text: widget.text,
    style: widget.style,
    velocity: 30.0,           // 每秒 30 像素
    blankSpace: 50.0,         // 两份文本间距
    startPadding: 0.0,
    pauseAfterRound: Duration(seconds: 2),
  ),
);

关键坑点:

  • Marquee 组件必须有固定高度,否则报布局错误
  • 需要通过 TextPainter 测量文本高度后用 SizedBox 包裹

2.2 深色模式按钮颜色方案对比

方案 A:硬编码颜色(错误做法)

// 深色模式
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
// 浅色模式
backgroundColor: ChatColors.primary(context),
foregroundColor: Colors.white,

问题: 硬编码导致定色混乱,主题色失效

方案 B:使用主题系统颜色(正确做法)

FilledButton.icon(
  style: FilledButton.styleFrom(
    backgroundColor: isDark
        ? theme.colorScheme.primaryContainer
        : theme.colorScheme.primary,
    foregroundColor: isDark
        ? theme.colorScheme.onPrimaryContainer
        : theme.colorScheme.onPrimary,
  ),
)

优点:

  • 颜色随主题自动适配
  • 保持设计系统一致性
  • 便于后续主题定制

2.3 时区处理方案

问题分析

后端返回的时间字符串(如 2024-12-28T10:30:00Z)是 UTC 时间,DateTime.tryParse() 会正确解析为 UTC DateTime,但在比较日期时如果不转换为本地时间,会导致日期分隔不正确。

例如:UTC 时间 12月28日 23:30 在东八区实际是 12月29日 07:30

修复方案

在所有日期比较和显示处添加 .toLocal() 转换:

DateSeparator 组件:

String _formatDate(DateTime date) {
  // 确保使用本地时间
  final localDate = date.isUtc ? date.toLocal() : date;
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final dateOnly = DateTime(localDate.year, localDate.month, localDate.day);
  
  if (dateOnly == today) return '今天';
  // ...
}

chat_page.dart 日期分隔判断:

// 确保使用本地时间进行比较
final currentLocal = message.createdAt.toLocal();
final prevLocal = prevMessage.createdAt.toLocal();
final currentDate = DateTime(currentLocal.year, currentLocal.month, currentLocal.day);
final prevDate = DateTime(prevLocal.year, prevLocal.month, prevLocal.day);
showDateSeparator = currentDate != prevDate;

三、文件修改清单

3.1 chat_widgets.dart

  • 添加 marquee 包导入
  • 重写 _MarqueeText 组件使用 marquee 包
  • 修复 DateSeparator._formatDate() 使用本地时间

3.2 chat_page.dart

  • 修改日期分隔判断逻辑使用本地时间

3.3 send_page.dart

  • 修复深色模式按钮颜色,使用主题系统颜色
  • 改用 FilledButton 替代 ElevatedButton

3.4 pubspec.yaml

  • 添加 marquee: ^2.3.0 依赖

四、视频时长获取(前次迭代内容)

4.1 添加工具方法

file_utils.dart:

Future<int?> getVideoDurationMs(String filePath) async {
  final player = Player();
  try {
    await player.open(Media(filePath), play: false);
    Duration? duration;
    await for (final state in player.stream.duration.timeout(
      Duration(seconds: 5),
      onTimeout: (sink) => sink.close(),
    )) {
      if (state.inMilliseconds > 0) {
        duration = state;
        break;
      }
    }
    return duration?.inMilliseconds;
  } finally {
    await player.dispose();
  }
}

4.2 修改发送流程

chat_page.dart:

// 发送视频前获取时长
int? durationMs;
if (isVideo) {
  durationMs = await getVideoDurationMs(filePath);
}
 
appState.enqueueSendUpload(
  // ...
  durationMs: durationMs,
);

五、文件夹选择器新建文件夹(前次迭代内容)

5.1 UI 设计

  • 在面包屑导航右侧添加圆形 folder-plus 按钮
  • 仅在 folderOnly 模式下显示

5.2 实现代码

file_picker_dialog.dart:

if (widget.mode == FilePickerMode.folderOnly)
  Container(
    width: 32,
    height: 32,
    margin: EdgeInsets.only(left: 8),
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      color: theme.colorScheme.primaryContainer,
    ),
    child: IconButton(
      icon: Icon(TablerIcons.folder_plus, size: 18),
      onPressed: _showCreateFolderDialog,
      tooltip: '新建文件夹',
    ),
  ),

六、遇到的问题与解决

6.1 Marquee 布局错误

错误信息:

RenderBox was not laid out: RenderClipRect#3e561
Null check operator used on a null value

原因: Marquee 组件需要固定高度容器

解决: 使用 TextPainter 测量文本高度,用 SizedBox 包裹

_textHeight = textPainter.height;
// ...
return SizedBox(
  height: _textHeight,
  child: marquee_pkg.Marquee(...),
);

6.2 createFolder API 参数错误

错误: Too many positional arguments: 1 expected, but 2 found

解决: 使用正确的 API createFolderAt(folderName, parentPath)


七、经验总结

  1. 优先使用成熟库: 跑马灯这种常见需求,使用成熟的 marquee 包比自己实现更可靠

  2. 主题色使用规范: 永远使用 theme.colorScheme 而非硬编码颜色,保持设计系统一致性

  3. 时区处理要彻底: 所有涉及日期比较和显示的地方都要考虑时区转换

  4. 布局约束要明确: 某些组件(如 Marquee)需要明确的尺寸约束才能正常工作

  5. Flutter 包的注意事项: 使用第三方包时要仔细阅读文档,了解其布局要求


八、后续优化建议

  1. 可以考虑在 SendMessage 模型的 fromJson 中统一做 .toLocal() 转换
  2. 跑马灯可以添加开始前的延迟,先看到完整文本
  3. 深色模式的按钮样式可以抽取为通用组件复用