Jetpack compose 仿网抑云音乐歌词效果

1,621 阅读1分钟

1.自动滑动到播放的位置

这里用LazyColumn来实现,滑动到指定位置用LazyListState.animateScrollToItem方法,为了减少不必要的组合用produceState来实现:

val density = LocalDensity.current
//得到歌词的最大宽度
val lyricWidth = remember(maxWidth) {
    with(density) {
        //lyricHorizontalPadding 是 LazyColumn横向的padding,可以自行设置
        (maxWidth - lyricHorizontalPadding * 2).roundToPx()
    }
}
//获取歌词列表
val lyricList:List<LrcEntry> by viewModel.getLyric()

val state = rememberLazyListState()
//是否在拖动列表
val isDragState = isDrag(state.interactionSource)

//播放歌词的位置
val playIndex by produceState(initialValue = 0, lyricList) {
    //播放进度的flow,每秒钟发射一次
    playbackHelper.progressStateFlow.collect {
        //播放器的播放进度,单位毫秒
        val playPosition = playbackHelper.currentPlayBackPosition
        val index =
            lyricList.indexOfFirst { it.startTime <= playPosition && playPosition < it.endTime }
        if (index >= 0) {
            if (index != state.firstVisibleItemIndex) {
                //判断用户是否在拖动歌词,如果再拖动歌词,则不ScrollToItem
                if (!isDragState.value) {
                    launch {
                        //滑动到正中间
                        val playItemsInfo =
                            state.layoutInfo.visibleItemsInfo.find { it.index == index }
                        if (playItemsInfo != null) {
                            state.animateScrollToItem(index, playItemsInfo.size / 2)
                        } else {
                            //通过StaticLayout测量得到text高度 然后再加上padding就是item的高度了
                            val itemHeight = lyricList[index].getTextHeight(
                                width = lyricWidth,
                                textSize = with(density) { lyricFontSize.toPx() },
                                typeface = LrcEntry.DEFAULT_BOLD
                            ) + with(density) {
                                (lyricRowVerticalPadding * 2).roundToPx()
                            }
                            state.animateScrollToItem(index, itemHeight / 2)
                        }
                    }
                }
            }
            value = index
        }
    }
}

2.设置LazyColumncontentPadding,让播放的歌词居中显示

这里用BoxWithConstraints得到容器的高度,然后除以2就是LazyColumn的纵向contentPadding

BoxWithConstraints(Modifier.fillMaxSize()) {
    //lazyColumn 的纵向内容padding
    val verticalContentPadding = maxHeight / 2
    
    /**这里省略,可以把第一步的代码直接copy过来**/
    
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = lyricHorizontalPadding),
        state = state,
        contentPadding = PaddingValues(
            vertical = verticalContentPadding
        ),
        //verticalArrangement = Arrangement.spacedBy(20.dp)
    ) {
        itemsIndexed(
            items = lyricList,
            key = { index, it -> "$index-${it.startTime}-${it.endTime}" }) { index, item ->
            Text(
                text = item.displayText,
                color = if (index == playIndex) MaterialTheme.colors.primary
                else if (state.firstVisibleItemIndex == index) MaterialTheme.colors.onSurface.copy(
                    alpha = 0.8f
                )
                else MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
                fontSize = lyricFontSize,
                fontWeight = if (index == playIndex) FontWeight.Medium else FontWeight.Normal,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxWidth()
                    //lyricRowVerticalPadding 是 item纵向的padding,可以自行设置
                    .padding(vertical = lyricRowVerticalPadding)
            )
        }
    }
}

compose 1.2.1 版本 lazyColumn设置verticalArrangement = Arrangement.spacedBy() ,调用 state.animateScrollToItem滑动到不可见的item会导致整个list动画一卡一卡的,这里建议在item里面设置padding,来设置lazyColumnitem之间的间距

效果如下:

upiee-xemoq.gif

3. 滑动歌词显示中间的这一行歌词的时间,点击可直接跳转到该行歌词进行播放。

BoxWithConstraints(Modifier.fillMaxSize()) {
    //lazyColumn 的纵向内容padding
    val verticalContentPadding = maxHeight / 2
    
    /**这里省略,可以把第一步的代码和第二部分的lazyColumn直接copy过来**/
    
    //用户滑动lazyColumn,显示中间的横线、播放按钮、该行词的时间
    AnimatedVisibility(
        isDragState.value,
        modifier = Modifier.align(Alignment.CenterStart),
        enter = fadeIn(),
        exit = fadeOut(),
    ) {
        Row(
            Modifier
                .padding(horizontal = 15.dp)
                .fillMaxWidth()
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null
                ) {
                    if (state.firstVisibleItemIndex in lyricList.indices) {
                        playbackHelper.seekTo(lyricList[state.firstVisibleItemIndex].startTime)
                        isDragState.value = false
                    }
                }
                .padding(vertical = 6.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(imageVector = Icons.Rounded.PlayArrow, contentDescription = "")
            Divider(Modifier.fillMaxWidth(0.9f))
            Text(
                text = lyricList.getOrNull(state.firstVisibleItemIndex)?.startTimeFormat?: "",
                fontSize = 10.sp,
                color = MaterialTheme.colors.onSurface.copy(0.7f)
            )
        }
    }
}

一些常量的配置

//歌词的字体大小
private val lyricFontSize = 15.sp

//歌词横向的padding
private val lyricHorizontalPadding = 30.dp

//单行歌词纵向的padding
private val lyricRowVerticalPadding = 10.dp

补充1.判断是否是用户在滑动而不是LazyListState的自动滑动

判断lazyColumn用户是否在拖动的方法用LazyListStateinteractionSource来判断

@Composable
fun isDrag(interactionSource: InteractionSource): MutableState<Boolean> {
    val isDragged = remember { mutableStateOf(false) }
    LaunchedEffect(interactionSource) {
        var delayJob : Job? = null
        val interactions = mutableSetOf<Interaction>()
        interactionSource.interactions.map { interaction ->
            when (interaction) {
                is DragInteraction.Start -> {
                    interactions.add(interaction)
                }
                is DragInteraction.Stop -> {
                    interactions.remove(interaction.start)
                }
                is DragInteraction.Cancel -> {
                    interactions.remove(interaction.start)
                }
            }
            interactions.isNotEmpty()
        }.collect { isDrag ->
            delayJob?.cancel()
            if(!isDrag){
                delayJob = launch {
                    delay(TOUCH_DELAY)
                    isDragged.value = isDrag
                }
            }else{
                isDragged.value = isDrag
            }
        }
    }
    return isDragged
}

private const val TOUCH_DELAY = 2000L

这里delay一下是为了让中间的播放按钮和线不那么快消失,也可以防止用户松开就马上自动滑动到播放歌词的那一行了,让用户滑了个寂寞。

补充2:歌词解析、获取单行歌词文字的高度

/**
 * 从文本解析歌词
 */
fun parseLrc(lrcText: String): List<LrcEntry> {
    if (TextUtils.isEmpty(lrcText)) {
        return emptyList()
    }
    val entryList: MutableList<LrcEntry> = mutableListOf()
    val array = lrcText.lines()
    for (line in array) {
        val list: List<LrcEntry>? = parseLine(line)
        if (list?.isNotEmpty() == true) {
            entryList.addAll(list)
        }
    }
    //排序
    val list = entryList.sortedBy { it.startTime }
    for (i in list.indices){
        if(i == list.size -1){
            list[i].endTime = Long.MAX_VALUE
        }else{
            list[i].endTime = list[i+1].startTime
        }

    }
    return list
}

/**
 * 解析一行歌词
 */
fun parseLine(lineText: String): List<LrcEntry>? {
    var line = lineText
    if (TextUtils.isEmpty(line)) {
        return null
    }
    line = line.trim()
    // [00:07]
    val lineMatcher = PATTERN_LINE.matcher(line)
    if (!lineMatcher.matches()) {
        return null
    }
    val times = lineMatcher.group(1) ?: ""
    val text = lineMatcher.group(3)
    val entryList: MutableList<LrcEntry> = ArrayList()

    // [00:17]
    val timeMatcher = PATTERN_TIME.matcher(times)
    while (timeMatcher.find()) {
        val min = timeMatcher.group(1)?.toLong() ?:0
        val sec = timeMatcher.group(2)?.toLong() ?: 0
        val milString = timeMatcher.group(3) ?: ""
        var mil = milString.toLong()
        // 如果毫秒是两位数,需要乘以10
        if (milString.length == 2) {
            mil *= 10
        }
        val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil
        entryList.add(LrcEntry(time, text?:""))
    }
    return entryList
}

private val PATTERN_LINE = Pattern.compile("((\[\d\d:\d\d\.\d{2,3}\])+)(.+)")
private val PATTERN_TIME = Pattern.compile("\[(\d\d):(\d\d)\.(\d{2,3})\]")

通过StaticLayout测量歌词的高度

@Immutable
data class LrcEntry(
    val startTime:Long,
    val text:String,
    val secondText:String = "",
){
    var endTime:Long = 0
    //分秒 mm:ss
    val startTimeFormat:String get() = toPlayTimeFormat(startTime)
    //该行显示的歌词
    val displayText :String get() = if(secondText.isNotBlank()) "${text}\n$secondText" else text

    //用于测量text的高度
    private var staticLayout: StaticLayout? = null

    /**
     * 获取text的高度
     */
    fun getTextHeight(width:Int,textSize:Float,typeface: Typeface = DEFAULT):Int{
        if(!checkMeasureParams(width,textSize,typeface)){
            staticLayout = buildStaticLayout(width,textSize)
        }
        return staticLayout!!.height
    }

    private fun buildStaticLayout(width:Int,textSize:Float,typeface: Typeface = DEFAULT):StaticLayout{
        lrcPaint.textSize = textSize
        lrcPaint.typeface = typeface
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            StaticLayout.Builder.obtain(displayText,0,displayText.length, lrcPaint,width)
                .setLineSpacing(0f,1f).setIncludePad(false)
                .build()
        }else{
            StaticLayout(displayText, lrcPaint, width, Layout.Alignment.ALIGN_CENTER, 1f, 0f, false);
        }
    }

    private fun checkMeasureParams(width:Int,textSize:Float,typeface: Typeface):Boolean{
        if(staticLayout == null){
            return false
        }
        return (staticLayout!!.width == width && lrcPaint.textSize == textSize && lrcPaint.typeface == typeface)
    }

    companion object{
        val DEFAULT_BOLD = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)

        val DEFAULT = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)

        val lrcPaint:TextPaint = TextPaint().apply {
            textSize = 30f
            typeface = DEFAULT
        }
    }
}

ok 最终效果(深色模式):

sdfsfasfd.gif