使用 Jetpack Compose 快速构建歌词界面
需求
使用了近一个半月的时间将自己的应用迁移到 Jetpack Compose 。效果比较满意,不过 Jetpack Compose 性能还是太差了,特别是在列表滑动和 RecyclerView 差距过大。 也存在一些困难,特别的一个就是 Jetpack Compose 暂不支持和安卓 View 滑动嵌套。由此我需要重新为自己的音乐软件写一个歌词界面。
先来看看效果图:
实现
决定采用 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 版本推出的,若项变化会产生一个动画,此在歌词文本大小变化、歌词翻译开关会带来更好的效果。(文末动图有演示)
主体实现
/**
* 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 叠加。
animateScrollToItem()
其实最开始也比较头疼,此无法将项目定位到屏幕中间,因为偏移参数 offset 不支持负值,最开始只能用定位到后一个 Item 让高亮的下来一点,就和 QQ 音乐那样。但是因为软件给了用户歌词文本大小的调节,所以当用户设置文本很小的时候,会显得很违和。(Apple Music 安卓版本歌词界面是使用 RecyclerView 制作的,但是它的字很大而不能调整且现在 RecyclerView 定位到中间方法很多)。
本来打算这样算了,直到本月 15 号,谷歌更新了。
所以可以通过计算界面高度和当前高亮的歌词 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 行左右,也更加灵活效果也很棒,为开发者节省很多时间。