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.设置LazyColumn
的contentPadding
,让播映的歌词居中显现
这儿用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,来设置lazyColumn
item之间的距离
作用如下:
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
用户是否在拖动的办法用LazyListState
的interactionSource
来判别
@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 最终作用(深色形式):