Quickly build a lyric interface using Jetpack Compose

demand

It took about a month and a half to migrate my application to Jetpack Compose. The result is satisfactory, but the performance of Jetpack Compose is still poor, especially in list sliding and RecyclerView. There are a few difficulties, one in particular being that Jetpack Compose doesn’t currently support android View sliding nesting. Therefore, I need to write a lyrics interface for my music software.


Take a look at the renderings:

implementation

The LazyColumn implementation was decided upon.

The first is the Item of each lyric

/** ** **@param[lyricsEntry] *@paramCurrent Indicates whether the current play is *@paramTextSize Font size *@paramTextColor Font color *@paramCenterAlign is centered *@paramShowSubText Whether to display translation */
@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
) {
    // Display only the main sentence if the translation is not displayed
    val mainLyrics = if (showSubText) lyricsEntry.lyrics elselyricsEntry.main ? :""
    // Highlight the lyrics currently playing
    val textAlpha = animateFloatAsState(if (current) 1F else 0.32 F).value
    // Lyrics text alignment, optional left/center
    val align = if (centerAlign) TextAlign.Center else TextAlign.Left
    Card(
        modifier = Modifier
            .animateItemPlacement()
            .fillMaxWidth()
            .onSizeChanged {
                if (current) {
                    // Tell the height of the currently highlighted lyrics Item
                    currentTextElementHeightPxState.value = it.height
                }
            }
            .padding(0.dp, (textSize * 0.1 F).dp)
        ,
        shape = SuperEllipseCornerShape(8.dp),
        backgroundColor = Color.Transparent,
        elevation = 0.dp
    ) {
        val paddingY = (textSize * 0.3 F).dp
        // Column is used to expand the display in the future
        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
            )
        }
    }
}
Copy the code

The reason for telling the current highlight lyric Item height is that when you have more text than one line, 2 or more lines, you now need to locate it in the middle and highlight it by calculation.

Modifier.animateItemPlacement()

Modifier. AnimateItemPlacement () is introduced in version 1.1.0 – beta03, if change will produce an animation, this text size changes in the lyrics, lyrics translation switch will bring better effect. (The GIF at the end of the article has a demonstration)



Subject to realize

/**
 * 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()
    // When there are no lyrics
    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()
        ) {
            // Blank before and after
            val blackItem: (LazyListScope.() -> Unit) = {
                item {
                    Box(
                        modifier = Modifier
                            .height(maxHeight / 2) {}}}// The body of the lyrics
            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.99 F }
                    .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()
            }
            // Locate the middle
            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())
            })
        }
    }
}
Copy the code

Modifier.drawWithContent

DrawContent () draws content and then drawRect() draws a rectangle with faded edges on the top layer, superimposed as blendmode.dstin.

animateScrollToItem()

In fact, I had a headache at the beginning. I could not locate the Item in the middle of the screen, because the offset parameter does not support negative values. At the beginning, I had to locate the Item to the next Item to make it highlighted a little bit, just like QQ music. But because the software allows users to adjust the size of the lyrics’ text, it can be a violation when users set the text to be too small. (Apple Music Android version of the lyrics interface is made of RecyclerView, but its word is very large and can not be adjusted and now RecyclerView positioning to the middle of a lot of methods).

I was going to leave it that way until The 15th of this month, when Google updated it.

So you can reach the middle of the location by calculating the height of the interface and the height of the currently highlighted lyric Item.


Tips

The text uses Dp values instead of Sp methods

Google recommends using Sp as the text size unit for a better experience (such as larger text for the elderly), but previously Ali’s Android developer said to use Dp because it can restore the UI better. In fact, it’s all right. It depends on the business needs.

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

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

conclusion

Compared with the original custom View, the number of lines of code is reduced from 600 to about 200, which is also more flexible and great, saving developers a lot of time.

Final effect – portrait

Final Effect – Landscape (GIF demo)