iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Mobile Development

Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!系列 第 27

[ Day 27 ] Flutter UI 深入應用篇 — 生活在地球的勇者啊,怎麼見極樂世界呀?(3)#動態歌詞

  • 分享至 

  • xImage
  •  

2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 27
「 Flutter UI 深入應用篇 生活在地球的勇者啊,怎麼見極樂世界呀 ? (3)


《佛說阿彌陀經》:

又舍利弗。彼佛國土,常作天樂。黃金為地。晝夜六時,雨天曼陀羅華。
其土眾生,常以清旦,各以衣祴盛眾妙華,供養他方十萬億佛,即以食時,還到本國,飯食經行。

舍利弗啊,阿彌陀佛的極樂世界,時常作響美妙音樂。黃金的地面,每天從早到晚六個時段,都會均勻飄灑著曼陀羅花。極樂世界的人民,經常在清晨的時候,各自用他們的衣裓,盛起眾多美妙的花朵,用來供養他方十萬億佛;隨即在用早齋的時候,他們已經回到了極樂世界;用過早齋,經行念佛。

前言

昨天我們已經認識了「 Human Interface Guidelines 」、「 Material Design 」,
知道平台官方對於 UI 相關的設計建議與規範。

但是了解設計規範沒辦法讓我們憑空打造 App 的使用者介面,
正如俗話:「 巧婦難為無米之炊 」,今天我們要從「 UI 素材取得 」出發,
探索「 Flutter 逐字填滿動畫 」讓文字動起來 !

Day 27 文章目錄:
一、UI 素材
二、逐字填滿動畫
三、核心程式


一、UI 素材

1. Material Symbols
(1) 簡介

Material Symbols 是 Google 推出的跨平台圖示系統
以可變字型與 SVG 提供同一個圖示在不同語境下的彈性外觀。

採 Apache License 2.0 開源授權,
因此可商用、可修改、可再散佈(依條款保留版權與授權聲明)。

(2) 可變字型 Variable Icons

變數軸 範圍 作用 / 影響 常見用法
填色(Fill) 0 / 1 0=線框(Outlined);1=實心(Filled) 清楚區分「一般」vs「選取/強調」
筆畫粗細(Weight) 100–700 改變線條/骨架粗細,數值越大越粗 依尺寸與文字粗細匹配
強弱微調(Grade) -25 – 200 細微加粗/變細 做層級(弱化/高強調)、深/淺底補償
光學尺寸(Optical Size) 20–48 不同尺寸下維持相同的觀感,圖示在縮放時會調整筆畫粗細。 光學尺寸能在放大或縮小符號時,自動調整其筆畫厚度,讓各尺寸下的外觀保持一致。

(3) 字體 Google Fonts

Google Fonts 是 Google 提供的開放字型平台,收錄上千款免費字體與圖示字型
授權方面多數採 SIL Open Font License 1.1,少數為 Apache 2.0/UFL,
可商用、可隨網站或 App 內嵌發佈。

2. Phosphor Icons

Phosphor Icons 是一套走「多字重、多風格」路線的跨平台圖示家族,
提供 Thin/Light/Regular/Bold/Fill/Duotone 六種風格,
設計在小尺寸也能清晰辨識。


二、逐字填滿動畫

Flutter實作「逐字填滿」字幕效果:
文字會像 KTV 字幕從左到右被填色且每列等分寬度。

GIF: 迴向偈

1. 實作要點

顯式動畫
AnimationController + AnimatedBuilder 以時間軸驅動。
文字動態排版
逐字顯示的文字揭示效果(KTV 字幕)。
遮罩裁剪
ClipRect + Align(widthFactor) 控制前景文字可視寬度,形成從左到右被填滿的動畫。

2. 相關實作方式

實作方式 原理 / 關鍵 API 優點 侷限 / 注意
兩層 Text 疊加 + ClipRect ClipRect + Align(widthFactor) 無套件、易懂、可逐字精準控制;跨平台穩定 每字一個子元素,長文需注意 widget 數;換行與標點需自行處理
CustomPainter + Canvas.clipRect + Paragraph 先畫底層再裁切畫前景 效能佳、可長文、精準控制字距與定位 實作量中高
ShaderMask 對整行套線性漸層並移動或調整 stops 作為進度 程式短、易做光效與金光 適合整行一起填滿;不適合逐字格格填滿(除非額外切格)
第三方套件(flutter_lyric / ym_lyric / AMLV) 內建歌詞解析與高亮/滾動/漸變,提供現成 widget 上手快,省去 LRC/KRC/SRT 解析與滾動邏輯 客製度受限,逐字填滿能力因套件而異

三、實作核心

  • widgets/karaoke_text.dart

把全域 progress 轉成每行已過毫秒,逐行交給 KaraokeLine 繪製。

import 'package:flutter/material.dart';
import '../utils/karaoke_utils.dart';
import 'karaoke_line.dart';

class KaraokeText extends StatelessWidget {
  const KaraokeText({
    super.key,
    required this.lines,          
    required this.timings,        
    required this.progress,       
    required this.perCharMs,      
    this.rowSpacing = 8,         
    required this.baseStyle,      
    required this.fillStyle,    
  });

  final List<List<String>> lines;      
  final List<LineTiming> timings;      
  final Animation<double> progress;     
  final int perCharMs;                  
  final double rowSpacing;              
  final TextStyle baseStyle;            
  final TextStyle fillStyle;          

  @override
  Widget build(BuildContext context) {
    // 全文總時長(毫秒)
    final totalMs = totalDuration(timings);

    // AnimatedBuilder 監聽外部 progress(0..1),
    return AnimatedBuilder(
      animation: progress,
      builder: (_, __) {
        // 進度 0..1 → 換算成已過的毫秒數
        final elapsed = (progress.value * totalMs).round();

        // 逐行產生 UI
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: List.generate(lines.length, (i) {
            final t = timings[i];

           
            // raw < 0 代表還沒輪到這行;raw > duration 代表此行已播放完
            final raw = elapsed - t.startMs;
            final lineElapsed = raw < 0 ? 0 : (raw > t.durationMs ? t.durationMs : raw);

            // 每一行交給 KaraokeLine:
            //   - cells        :這行的字格
            //   - cellStarts   :這行每個字格(相對於行起點)的開始毫秒
            //   - elapsedInLineMs:此行已經走過多少毫秒
            //   - perCharMs    :每格耗時
            //   - base/fill    :兩層文字樣式
            return Padding(
              padding: EdgeInsets.only(bottom: i == lines.length - 1 ? 0 : rowSpacing),
              child: KaraokeLine(
                cells: lines[i],
                cellStarts: t.cellStarts,
                elapsedInLineMs: lineElapsed,
                perCharMs: perCharMs,
                baseStyle: baseStyle,
                fillStyle: fillStyle,
              ),
            );
          }),
        );
      },
    );
  }
}

  • widgets/karaoke_line.dart

用 ClipRect+Align.widthFactor 依進度從左至右覆蓋逐字填滿 。


import 'package:flutter/material.dart';

class KaraokeLine extends StatelessWidget {
  const KaraokeLine({
    super.key,
    required this.cells,            
    required this.cellStarts,       
    required this.elapsedInLineMs,  
    required this.perCharMs,        
    required this.baseStyle,        
    required this.fillStyle,        
  });

  final List<String> cells;
  final List<int> cellStarts;
  final int elapsedInLineMs;
  final int perCharMs;
  final TextStyle baseStyle;
  final TextStyle fillStyle;

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.baseline,
      textBaseline: TextBaseline.alphabetic,
      children: List.generate(cells.length, (i) {
        // 取得該格播放進度(0..1):
        final prog = ((elapsedInLineMs - cellStarts[i]) / perCharMs)
            .clamp(0.0, 1.0);

        return Expanded( 
          child: _KaraokeCell(
            text: cells[i],
            progress: prog,
            base: baseStyle,
            fill: fillStyle,
          ),
        );
      }),
    );
  }
}

class _KaraokeCell extends StatelessWidget {
  const _KaraokeCell({
    required this.text,
    required this.progress,
    required this.base,
    required this.fill,
  });

  final String text;
  final double progress;
  final TextStyle base;
  final TextStyle fill;

  @override
  Widget build(BuildContext context) {
    return Center( 
      child: Stack(
        children: [
          // 背景
          Text(
            text,
            style: base,
            textAlign: TextAlign.center,
            softWrap: false, 
          ),
          // 前景
          ClipRect(//裁切視窗以外,代表僅視窗內可見
            child: Align( //Align靠左,金色字會由左至右依序顯示
              alignment: Alignment.centerLeft, 
              widthFactor: progress, // 0..1 控制顯示寬度
              child: Text(
                text,
                style: fill,
                textAlign: TextAlign.center,
                softWrap: false,
              ),
            ),
          ),
        ],
      ),
    );
  }
}


Day 27 重點回顧

重點 內容
UI 素材 Material Symbols、Phosphor Icons
逐字填滿動畫 實作逐字填滿動畫
實作核心 ClipRect + Align(widthFactor = 0~1)

上一篇
[ Day 26 ] Flutter UI 實戰應用篇 — 生活在地球的勇者啊,怎麼見極樂世界呀?(2)
下一篇
[ Day 28 ] Flutter Android上架 實戰應用篇 — 生活在地球的勇者啊,哪邊可以找到阿彌陀佛呀?(1)
系列文
Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言