使用 Jetpack Compose 快速构建音乐软件歌词界面

2,395 阅读3分钟

使用 Jetpack Compose 快速构建歌词界面

需求

使用了近一个半月的时间将自己的应用迁移到 Jetpack Compose 。效果比较满意,不过 Jetpack Compose 性能还是太差了,特别是在列表滑动和 RecyclerView 差距过大。 也存在一些困难,特别的一个就是 Jetpack Compose 暂不支持和安卓 View 滑动嵌套。由此我需要重新为自己的音乐软件写一个歌词界面。


先来看看效果图:

Screenshot_2021-12-27-12-13-57-621_com.salt.music.jpg

实现

决定采用 LazyColumn 实现。

首先是每句歌词的 Item

/**
 * 歌词 Item
 *
 * @param lyricsEntry 歌词 [LyricsEntry]
 * @param current 是否为当前播放
 * @param textSize 字体大小
 * @param textColor 字体颜色
 * @param centerAlign 是否居中对齐
 * @param showSubText 是否显示翻译
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LazyItemScope.LyricsItem(
    lyricsEntry: LyricsEntry,
    current: Boolean = false,
    currentTextElementHeightPxState: MutableState<Int>,
    textSize: Int,
    textColor: Color = Color.White,
    centerAlign: Boolean = false,
    showSubText: Boolean = true,
    onClick: () -> Unit
) {
    // 当前歌词,若不显示翻译则只显示主句
    val mainLyrics = if (showSubText) lyricsEntry.lyrics else lyricsEntry.main ?: ""
    // 当前正在播放的歌词高亮
    val textAlpha = animateFloatAsState(if (current) 1F else 0.32F).value
    // 歌词文本对齐方式,可选左 / 中
    val align = if (centerAlign) TextAlign.Center else TextAlign.Left
    Card(
        modifier = Modifier
            .animateItemPlacement()
            .fillMaxWidth()
            .onSizeChanged {
                if (current) {
                    // 告知当前高亮歌词 Item 高度
                    currentTextElementHeightPxState.value = it.height
                }
            }
            .padding(0.dp, (textSize * 0.1F).dp)
        ,
        shape = SuperEllipseCornerShape(8.dp),
        backgroundColor = Color.Transparent,
        elevation = 0.dp
    ) {
        val paddingY = (textSize * 0.3F).dp
        // 这里使用 Column 是为了若以后拓展具体显示
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onClick()
                }
                .padding(8.dp, paddingY),
            verticalArrangement = Arrangement.Center
        ) {
            val mainTextSize = textSize.textDp
            Text(
                modifier = Modifier
                    .alpha(textAlpha)
                    .fillMaxWidth()
                ,
                text = mainLyrics,
                fontSize = mainTextSize,
                color = textColor,
                textAlign = align
            )
        }
    }
}

告知当前高亮歌词 Item 高度是因为当文字过多超过一行了,2 行或者更多行,现在需要定位到中间高亮需要通过计算。

Modifier.animateItemPlacement()

Modifier.animateItemPlacement() 是在 1.1.0-beta03 版本推出的,若项变化会产生一个动画,此在歌词文本大小变化、歌词翻译开关会带来更好的效果。(文末动图有演示)


image.png


主体实现

/**
 * LyricsUI
 *
 * @param textSize dp Size
 */
@Composable
fun LyricsUI(
    liveTime: Long = 0L,
    lyricsEntryList: List<LyricsEntry>,
    textColor: Color = Color.White,
    textSize: Int = 20,
    paddingWidth: Dp = 30.dp,
    alignCenter: Boolean = false,
    openTranslation: Boolean = true,
    itemOnClick: (LyricsEntry) -> Unit,
) {
    val state = rememberLazyListState()
    // 当没歌词的时候
    if (lyricsEntryList.isEmpty()) {
        Box(
            modifier = Modifier.fillMaxSize()
        ) {
            Text(
                text = stringResource(id = R.string.no_lyrics),
                modifier = Modifier.align(Alignment.Center),
                color = textColor,
                fontSize = textSize.textDp
            )
        }
    } else {
        val currentTextElementHeightPx = remember { mutableStateOf(0) }
        BoxWithConstraints(
            modifier = Modifier
                .fillMaxSize()
        ) {
            // 前后空白
            val blackItem: (LazyListScope.() -> Unit) = {
                item {
                    Box(
                        modifier = Modifier
                            .height(maxHeight / 2)
                    ) {

                    }
                }
            }
            // 歌词主体
            val lyricsEntryListItems: (LazyListScope.() -> Unit) = {
                items(lyricsEntryList) { lyricsEntry ->
                    LyricsItem(
                        current = liveTime == lyricsEntry.time,
                        currentTextElementHeightPxState = currentTextElementHeightPx,
                        lyricsEntry = lyricsEntry,
                        textColor = textColor,
                        textSize = textSize,
                        centerAlign = alignCenter,
                        showSubText = openTranslation,
                        onClick = {
                            itemOnClick(lyricsEntry)
                        }
                    )
                }
            }
            LazyColumn(
                Modifier
                    .fillMaxWidth()
                    .fillMaxHeight()
                    .padding(paddingWidth, 0.dp)
                    .graphicsLayer { alpha = 0.99F }
                    .drawWithContent {
                        val colors = listOf(Color.Transparent, Color.Black, Color.Black, Color.Black, Color.Black,
                            Color.Black, Color.Black, Color.Black, Color.Transparent)
                        drawContent()
                        drawRect(
                            brush = Brush.verticalGradient(colors),
                            blendMode = BlendMode.DstIn
                        )
                    }
                ,
                state = state
            ) {
                blackItem()
                lyricsEntryListItems()
                blackItem()
            }
            // 定位中间
            LaunchedEffect(key1 = liveTime, key2 = currentTextElementHeightPx.value, block = {
                val height = (dp2px(maxHeight.value) - currentTextElementHeightPx.value) / 2
                val index = findShowLine(lyricsEntryList, liveTime)
                state.animateScrollToItem((index + 1).coerceAtLeast(0), -height.toInt())
            })
        }
    }
}

Modifier.drawWithContent

绘制内容,因为要给歌词界面上下给个淡出的渐变,这可以这样实现,先 drawContent() 绘制内容,然后 drawRect() 在上层绘制一个边缘淡出的矩形,以 BlendMode.DstIn 叠加。

image.png

animateScrollToItem()

其实最开始也比较头疼,此无法将项目定位到屏幕中间,因为偏移参数 offset 不支持负值,最开始只能用定位到后一个 Item 让高亮的下来一点,就和 QQ 音乐那样。但是因为软件给了用户歌词文本大小的调节,所以当用户设置文本很小的时候,会显得很违和。(Apple Music 安卓版本歌词界面是使用 RecyclerView 制作的,但是它的字很大而不能调整且现在 RecyclerView 定位到中间方法很多)。

本来打算这样算了,直到本月 15 号,谷歌更新了。

image.png

所以可以通过计算界面高度和当前高亮的歌词 Item 高度达到定位中间。


Tips

文本使用 Dp 值而不使用 Sp 方法

谷歌推荐使用 Sp 值作为文本文字大小单位为了更好的体验(如为老年人提供更大的文字),但之前阿里的安卓开发说使用 Dp,因为能更好地还原 UI 。 其实都没错,还是根据业务需要。

val Int.textDp: TextUnit
    @Composable get() =  this.textDp(density = LocalDensity.current)

private fun Int.textDp(density: Density): TextUnit = with(density) {
    this@textDp.dp.toSp()
}

总结

与原先的自定义 View 对比代码行数由 600 下降到 200 行左右,也更加灵活效果也很棒,为开发者节省很多时间。

最终效果 - 竖屏

Screenshot_2021-12-27-12-31-10-963_com.salt.music.jpg

最终效果 - 横屏(GIF 演示)

QQ图片20211227122900.gif