You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 10:48:13