即時語音翻譯最關鍵的就是「字幕滑順滾動」。如果沒有處理好,使用者手動滾動看舊紀錄時畫面會一直被強行拉到底部,或者新字幕跳出來時畫面閃爍卡頓。
今天我們要實作 LiveSubtitleListView:
在 lib/features/live_translate/presentation/widgets/ 下建立 live_subtitle_list_view.dart
import 'package:flutter/material.dart';
class SubtitleItem {
final String rawText; // 原始逐字稿
final String translatedText; // 翻譯後的文字
SubtitleItem({required this.rawText, required this.translatedText});
}
class LiveSubtitleListView extends StatefulWidget {
final List<SubtitleItem> subtitles;
const LiveSubtitleListView({
Key? key,
required this.subtitles,
}) : super(key: key);
@override
State<LiveSubtitleListView> createState() => _LiveSubtitleListViewState();
}
class _LiveSubtitleListViewState extends State<LiveSubtitleListView> {
final ScrollController _scrollController = ScrollController();
bool _isUserScrolling = false; // 是否手動向上看歷史
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
}
/// 監聽滾動狀態:若距離底部超過 100px,視為使用者正在向上瀏覽歷史
void _scrollListener() {
if (!_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
// 距離底部低於 100 像素,認定為處於底部
if (maxScroll - currentScroll > 100) {
if (!_isUserScrolling) setState(() => _isUserScrolling = true);
} else {
if (_isUserScrolling) setState(() => _isUserScrolling = false);
}
}
@override
void didUpdateWidget(covariant LiveSubtitleListView oldWidget) {
super.didUpdateWidget(oldWidget);
// 當字幕清單有新增,且使用者沒有手動往上滑時,自動滾動到底部
if (widget.subtitles.length > oldWidget.subtitles.length && !_isUserScrolling) {
_scrollToBottom();
}
}
/// 滾動到底部的動畫函式
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 1. 字幕列表
ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16.0),
itemCount: widget.subtitles.length,
itemBuilder: (context, index) {
final item = widget.subtitles[index];
return _buildSubtitleCard(item, index);
},
),
// 2. 當使用者手動往上滑時,顯示「回到底部」懸浮小按鈕
if (_isUserScrolling)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).primaryColor,
onPressed: () {
setState(() => _isUserScrolling = false);
_scrollToBottom();
},
child: const Icon(Icons.arrow_downward, color: Colors.white),
),
),
],
);
}
/// 字幕卡片 UI 設計
Widget _buildSubtitleCard(SubtitleItem item, int index) {
return Container(
margin: const EdgeInsets.only(bottom: 12.0),
padding: const EdgeInsets.all(14.0),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAlignment.start,
children: [
// 原始逐字稿 (STT) - 較小、灰色
Text(
item.rawText,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade400,
height: 1.3,
),
),
const SizedBox(height: 6),
// LLM 譯文 - 較大、醒目高亮
Text(
item.translatedText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.amberAccent,
height: 1.4,
),
),
],
),
);
}
}
multi_lang_page.dart 中套用字幕列表替換舊有的純文字展示,改用剛剛寫好的 LiveSubtitleListView
// 在 State 中建立 List<SubtitleItem>
List<SubtitleItem> _subtitleList = [];
// 當 LLM 翻譯完成後,將結果 append 進清單
void _onTranslationReceived(String raw, String translated) {
setState(() {
_subtitleList.add(SubtitleItem(
rawText: raw,
translatedText: translated,
));
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Live Translate')),
body: Column(
children: [
// 使用剛建立的智慧滾動字幕元件
Expanded(
child: _subtitleList.isEmpty
? const Center(child: Text('請開始說話,字幕將在此顯示...'))
: LiveSubtitleListView(subtitles: _subtitleList),
),
// 底部控制按鈕區塊...
],
),
);
}
⬇️ 按鈕,點擊一下立馬平滑動畫降回最底部!