如何用Jetpack Compose实现带上方和弦的歌词显示组件?
问题:Jetpack Compose解析和弦歌词并适配屏幕渲染
我有如下JSON歌曲数据,需要用Jetpack Compose解析后将和弦对应显示在歌词上方,同时适配任意屏幕尺寸。尝试编写的代码无法正常工作,寻求解决办法。
原始JSON数据
{ "number": "060", "audio": false, "chord": false, "code": "each_step_of_the_way", "chordUrl": null, "name": "EACH STEP OF THE WAY", "description": null, "mainChord": "F", "audioUrl": "/files/en/only_believe/audios/060.mp3", "songBook": { "id": 1, "name": "ONLY BELIEVE", "code": "only_believe", "userId": 3, "hidden": false, "addedBy": "SYSTEM", "description": null, "languageId": 2, "isDraft": false, "version": "v0.0.1", "createdAt": "2023-10-15T23:22:12.485+00:00", "updatedAt": "2023-10-15T23:22:12.485+00:00", "language": { "id": 2, "name": "English", "code": "en", "createdAt": null, "updatedAt": null } }, "author": null, "language": { "id": 2, "name": "English", "code": "en", "createdAt": null, "updatedAt": null }, "verses": [ { "id": 17371, "verseOrder": 1, "orderToShow": 1, "isRefrain": false, "line": "I'm [G]following Jesus, [C]One step at [D]a time;", "songId": 4008, "createdAt": "2023-10-15T23:22:12.594+00:00", "updatedAt": "2023-10-15T23:22:12.594+00:00" }, { "id": 17372, "verseOrder": 2, "orderToShow": 2, "isRefrain": false, "line": "[G]The pathway is [C]narrow, But He leads [D]me on;", "songId": 4008, "createdAt": "2023-10-15T23:22:12.595+00:00", "updatedAt": "2023-10-15T23:22:12.595+00:00" } ] }
期望渲染效果
EACH STEP OF THE WAY G C D 1. I'm following Jesus, One step at a time; G C D 2. The pathway is narrow, But He leads me on;
尝试的问题代码
fun getSongs(): MutableList<Line> { val verses = listOf( "Я[A] так слаб, но силен Т[Bm]ы,$ У[E7]держать меня вда[A]л[E7]и$", "О[A]т всег[A7]о, что мне вред[D]ит[Bm],$ О[D#o],", " позво[A/e]ль лишь с Тобо[E7]ю идт[A]и[D][A][E7].$", ) val list: MutableList<MutableList<Line>> = mutableListOf() val chordRegex = """\[(\w+)]""".toRegex() val line: MutableList<Line> = mutableListOf() verses.forEach { verse -> val chords = mutableListOf<Word>() val words = mutableListOf<Word>() verse.split(" ").forEach { word -> val matchResult = chordRegex.find(word) if (matchResult != null) { val extractedChord = matchResult.groupValues[1] chords.add(Word(text = extractedChord, isChord = true)) words.add( Word( text = word.replace("[$extractedChord]", ""), isChord = false, ) ) } else { chords.add(Word(text = "", isChord = false)) words.add(Word(text = word, isChord = false)) } } line.add(Line(row = chords, isChord = true)) line.add(Line(row = words, isChord = false)) } return line }
解决方案
1. 修正数据解析逻辑
原代码存在三个核心问题:正则无法匹配带特殊字符的和弦、拆分逻辑导致和弦与歌词绑定错位、数据结构设计不合理。以下是修正后的解析方案:
定义数据类
// 存储单个和弦与对应歌词段的绑定关系 data class ChordLyricPair( val chord: String?, // 可为空表示当前位置无和弦 val lyric: String // 对应歌词内容 ) // 单句歌词的完整数据:行号、和弦-歌词对列表 data class SongLine( val lineNumber: Int, val pairs: List<ChordLyricPair> ) // 歌曲整体数据结构 data class Song( val title: String, val lines: List<SongLine> )
解析函数实现
fun parseSong(jsonString: String): Song { val songJson = JSONObject(jsonString) val title = songJson.getString("name") val verses = songJson.getJSONArray("verses") val songLines = mutableListOf<SongLine>() // 支持所有常见和弦符号的正则:匹配[xxx]格式,允许斜线、#、o等字符 val chordRegex = """\[([^\]]+)]""".toRegex() for (i in 0 until verses.length()) { val verse = verses.getJSONObject(i) val lineNumber = verse.getInt("orderToShow") val lineText = verse.getString("line") val parts = mutableListOf<ChordLyricPair>() var lastIndex = 0 // 遍历所有匹配的和弦,拆分歌词与和弦 chordRegex.findAll(lineText).forEach { matchResult -> val chord = matchResult.groupValues[1] // 添加和弦前的歌词片段 val lyricBefore = lineText.substring(lastIndex, matchResult.range.first) if (lyricBefore.isNotEmpty()) { parts.add(ChordLyricPair(null, lyricBefore)) } // 添加和弦占位 parts.add(ChordLyricPair(chord, "")) lastIndex = matchResult.range.last + 1 } // 添加最后一段歌词 if (lastIndex < lineText.length) { parts.add(ChordLyricPair(null, lineText.substring(lastIndex))) } // 合并连续的无和弦歌词,优化结构 val mergedPairs = mutableListOf<ChordLyricPair>() var currentLyric = "" parts.forEach { pair -> if (pair.chord == null) { currentLyric += pair.lyric } else { if (currentLyric.isNotEmpty()) { mergedPairs.add(ChordLyricPair(null, currentLyric)) currentLyric = "" } mergedPairs.add(pair) } } if (currentLyric.isNotEmpty()) { mergedPairs.add(ChordLyricPair(null, currentLyric)) } songLines.add(SongLine(lineNumber, mergedPairs)) } return Song(title, songLines) }
2. Jetpack Compose渲染实现
使用Column和Row组合,确保和弦与歌词精准对齐,同时适配任意屏幕宽度:
核心渲染组件
@Composable fun SongLyricView(song: Song) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // 歌曲标题 Text( text = song.title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 24.dp) ) // 遍历每一句歌词 song.lines.forEach { line -> Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), horizontalAlignment = Alignment.Start ) { // 和弦行:与歌词行一一对应位置 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom ) { // 固定行号宽度,避免对齐偏移 Text( text = "${line.lineNumber}. ", style = MaterialTheme.typography.bodyLarge, color = Color.Gray, modifier = Modifier.widthIn(min = 40.dp) ) line.pairs.forEach { pair -> if (pair.chord != null) { Text( text = pair.chord, style = MaterialTheme.typography.bodyMedium, color = Color.Blue, modifier = Modifier.padding(end = 4.dp) ) } else { // 无和弦时,用占位符匹配对应歌词宽度 val lyricWidth = calculateTextWidth(pair.lyric) Spacer(modifier = Modifier.width(lyricWidth)) } } } // 歌词行 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = "${line.lineNumber}. ", style = MaterialTheme.typography.bodyLarge, color = Color.Gray, modifier = Modifier.widthIn(min = 40.dp) ) line.pairs.forEach { pair -> Text( text = pair.lyric, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(end = 4.dp) ) } } } } } } // 计算文本宽度的工具函数,用于和弦行占位对齐 @Composable fun calculateTextWidth(text: String): Dp { val textStyle = MaterialTheme.typography.bodyLarge val painter = remember(text) { TextPainter( text = AnnotatedString(text), style = textStyle ).apply { layout(Constraints()) } } return with(LocalDensity.current) { painter.width.toDp() } }
3. 屏幕适配优化
- 用
fillMaxWidth()确保组件占满屏幕宽度 - 行号设置固定最小宽度,避免不同行号导致的对齐偏移
- 使用
MaterialTheme内置文字样式,自动适配系统字体缩放 - 间距使用
dp单位,在不同分辨率屏幕上保持比例一致
4. 完整使用示例
@Composable fun SongScreen(jsonString: String) { val song = remember { parseSong(jsonString) } SongLyricView(song) }
内容的提问来源于stack exchange,提问作者codemossaka
相关产品推荐
相关产品推荐

