iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

從行程到應援:30 天做出追星實用工具箱系列 第 19 篇

Day 19:Flutter 滾動字幕 UI 繪製/自動置底與滾動動畫優化

  • 分享至 

  • xImage
  •  

一、前言

即時語音翻譯最關鍵的就是「字幕滑順滾動」。如果沒有處理好,使用者手動滾動看舊紀錄時畫面會一直被強行拉到底部,或者新字幕跳出來時畫面閃爍卡頓。

今天我們要實作 LiveSubtitleListView:

  1. 新字幕產生時自動滑順滾動到底部。
  2. 使用者手動向上滑動(看歷史紀錄)時,自動暫停強迫置底。
  3. 出現「跳至最新」懸浮按鈕,點擊一鍵回到最下方。

二、今日任務

Step 1: 智慧滾動字幕元件

在 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,
            ),
          ),
        ],
      ),
    );
  }
}

Step 2: 在 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),
        ),
        // 底部控制按鈕區塊...
      ],
    ),
  );
}

檢查清單

  • 自動置底測試:連個幾句話,字幕卡片會滑順地自動向上推送,最新一句永遠固定在螢幕最下方。
  • 智慧暫停測試:用手指把字幕向上滑動去看第一句,接著再講話,畫面不會強行把你拉到底部。
  • 懸浮按鈕測試:手動往上滑時,右下角出現 ⬇️ 按鈕,點擊一下立馬平滑動畫降回最底部!

上一篇
Day 18:直播結束一鍵生成精華摘要與逐字稿筆記功能實作
系列文
從行程到應援:30 天做出追星實用工具箱 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言