2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 27
「 Flutter UI 深入應用篇 — 生活在地球的勇者啊,怎麼見極樂世界呀 ? (3) 」
《佛說阿彌陀經》:
「 又舍利弗。彼佛國土,常作天樂。黃金為地。晝夜六時,雨天曼陀羅華。
其土眾生,常以清旦,各以衣祴盛眾妙華,供養他方十萬億佛,即以食時,還到本國,飯食經行。 」
舍利弗啊,阿彌陀佛的極樂世界,時常作響美妙音樂。黃金的地面,每天從早到晚六個時段,都會均勻飄灑著曼陀羅花。極樂世界的人民,經常在清晨的時候,各自用他們的衣裓,盛起眾多美妙的花朵,用來供養他方十萬億佛;隨即在用早齋的時候,他們已經回到了極樂世界;用過早齋,經行念佛。
昨天我們已經認識了「 Human Interface Guidelines 」、「 Material Design 」,
知道平台官方對於 UI 相關的設計建議與規範。
但是了解設計規範沒辦法讓我們憑空打造 App 的使用者介面,
正如俗話:「 巧婦難為無米之炊 」,今天我們要從「 UI 素材取得 」出發,
探索「 Flutter 逐字填滿動畫 」讓文字動起來 !
Day 27 文章目錄:
一、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 解析與滾動邏輯 | 客製度受限,逐字填滿能力因套件而異 |
把全域 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,
),
);
}),
);
},
);
}
}
用 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,
),
),
),
],
),
);
}
}
重點 | 內容 |
---|---|
UI 素材 | Material Symbols、Phosphor Icons |
逐字填滿動畫 | 實作逐字填滿動畫 |
實作核心 | ClipRect + Align(widthFactor = 0~1) |