Flutter中如何实现和弦与歌词成对行自动换行并保持对齐?
这个需求我太熟了!之前帮朋友做吉他谱App的时候就遇到过一模一样的问题——用固定字符数拆分确实省事,但碰到和弦长度和歌词不匹配的情况,分分钟就错位,巨头疼。
要解决这个问题,核心是放弃按固定字符数拆分,转而基于「和弦-对应歌词」的绑定单元来计算换行。毕竟像Ultimate Guitar那样的效果,每个和弦都是和特定的歌词绑定的,换行时必须把绑定的一对内容一起移动,不能拆开。
下面是具体的实现步骤,都是我实际用过的可行方案:
1. 先把和弦与歌词转换成结构化的绑定单元
首先得把原始的和弦行、歌词行,转换成「每个和弦对应一段歌词」的结构化数据。比如你输入的是:
和弦行:
C G Am
歌词行:I'm singing in the rain just singing in the rain
我们需要把它拆成一个个ChordLyricPair单元,比如(C, "I'm ")、(G, "singing ")、(Am, "in the rain ")这样的结构。
先定义一个数据类来存这些单元:
class ChordLyricPair { final String chord; final String lyric; ChordLyricPair(this.chord, this.lyric); }
然后写一个解析函数,把原始的行转换成这些单元。这里要注意,解析逻辑得根据你实际的输入格式调整——如果你的和弦是用方括号标记的(比如[C]I'm [G]singing),就改解析逻辑识别方括号;如果是靠空格对齐的,就按空格拆分匹配:
List<ChordLyricPair> parseChordLyricLines(String chordLine, String lyricLine) { // 按空格拆分歌词和和弦,对应位置配对 final lyricWords = lyricLine.split(RegExp(r'\s+')).map((word) => '$word ').toList(); final chordParts = chordLine.split(RegExp(r'\s+')); final pairs = <ChordLyricPair>[]; for (int i = 0; i < lyricWords.length; i++) { // 没有对应和弦的位置填空字符串 final chord = i < chordParts.length ? chordParts[i] : ''; pairs.add(ChordLyricPair(chord, lyricWords[i])); } return pairs; }
2. 根据屏幕宽度,把单元分成合适的行
接下来用LayoutBuilder获取当前可用的屏幕宽度,再用TextPainter测量每个单元的宽度,逐个往当前行里加,直到加下一个单元就超宽了,就换行。
这个方法的好处是完全适配屏幕宽度,不管是手机还是平板,都能自动调整:
List<List<ChordLyricPair>> splitIntoLines( List<ChordLyricPair> pairs, double maxWidth, TextStyle lyricStyle, ) { final lines = <List<ChordLyricPair>>[]; var currentLine = <List<ChordLyricPair>>[]; var currentLineWidth = 0.0; for (final pair in pairs) { // 用TextPainter测量这段歌词的宽度(因为和弦要放在歌词上方,宽度以歌词为准) final textPainter = TextPainter( text: TextSpan(text: pair.lyric, style: lyricStyle), textDirection: TextDirection.ltr, )..layout(); final pairWidth = textPainter.width; // 如果加当前单元会超宽,就把当前行存入,新建一行 if (currentLineWidth + pairWidth > maxWidth && currentLine.isNotEmpty) { lines.add(currentLine); currentLine = []; currentLineWidth = 0.0; } currentLine.add(pair); currentLineWidth += pairWidth; } // 把最后一行加进去 if (currentLine.isNotEmpty) lines.add(currentLine); return lines; }
3. 把分好的行渲染成和弦+歌词的组合
最后就是把每一行的单元转换成对应的和弦文本和歌词文本,因为用的是等宽字体,直接用padRight按歌词的字符数填充和弦,就能完美对齐:
LayoutBuilder( builder: (context, constraints) { // 定义等宽字体的样式 final chordStyle = TextStyle( fontFamily: 'Courier', fontSize: 16, color: Colors.redAccent, ); final lyricStyle = TextStyle( fontFamily: 'Courier', fontSize: 14, color: Colors.black87, ); // 解析成绑定单元 final chordLine = 'C G Am'; final lyricLine = "I'm singing in the rain just singing in the rain"; final pairs = parseChordLyricLines(chordLine, lyricLine); // 按屏幕宽度拆分行 final lines = splitIntoLines(pairs, constraints.maxWidth, lyricStyle); // 渲染每一行的和弦和歌词 return Column( crossAxisAlignment: CrossAxisAlignment.start, children: lines.map((line) { // 把行内的和弦按歌词长度填充,保证对齐 final chordText = line.map((pair) => pair.chord.padRight(pair.lyric.length)).join(); final lyricText = line.map((pair) => pair.lyric).join(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(chordText, style: chordStyle), Text(lyricText, style: lyricStyle), SizedBox(height: 12), ], ); }).toList(), ); }, )
几个关键的注意点
- 解析逻辑要适配你的实际格式:如果你的和弦不是靠空格对齐的,比如用
[C]这种标记,一定要修改parseChordLyricLines函数,准确提取每个和弦对应的歌词段,这是对齐的基础。 - 必须用等宽字体:非等宽字体的话,字符宽度不一样,用
padRight按字符数填充就会错位,所以一定要指定等宽字体(比如Courier、Consolas这类)。 - 用TextPainter测量宽度更准确:不要自己算字符数×单个字符宽度,因为不同字体的字距可能有差异,
TextPainter是Flutter官方推荐的测量文本宽度的方法,结果最准确。
备注:内容来源于stack exchange,提问作者Phoenyx

