Jetpack Compose 中的字幕文本效果
Marquee Text Effect in Jetpack Compose
以前可以在文本中加入一种动画,如果文本超过限制,它会自动水平滚动。这是通过包括:android:ellipsize="marquee"
完成的,结果类似于此处显示的结果:
问题是,在 Jetpack Compose 中,我看不到在可组合文本中包含该选项的方法,TextOverflow
包含 Clip、Ellipsis 或 Visible 选项,但我不知道是否有办法在 Jetpack Compose 中包含或使用“Marquee”选项。有什么办法吗?
Compose 尚不支持此功能,但实施起来并不难。您将需要 TargetBasedAnimation
,它将更新文本偏移量,以及 SubcomposeLayout
,它位于大多数集合之下。在里面你可以定义文本的大小,也可以放置第二个类似的Text
,它会从右边缘出现。
@Composable
fun MarqueeText(
text: String,
modifier: Modifier = Modifier,
textModifier: Modifier = Modifier,
gradientEdgeColor: Color = Color.White,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
val createText = @Composable { localModifier: Modifier ->
Text(
text,
textAlign = textAlign,
modifier = localModifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = 1,
onTextLayout = onTextLayout,
style = style,
)
}
var offset by remember { mutableStateOf(0) }
val textLayoutInfoState = remember { mutableStateOf<TextLayoutInfo?>(null) }
LaunchedEffect(textLayoutInfoState.value) {
val textLayoutInfo = textLayoutInfoState.value ?: return@LaunchedEffect
if (textLayoutInfo.textWidth <= textLayoutInfo.containerWidth) return@LaunchedEffect
val duration = 7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth
val delay = 1000L
do {
val animation = TargetBasedAnimation(
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = duration,
delayMillis = 1000,
easing = LinearEasing,
),
repeatMode = RepeatMode.Restart
),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = -textLayoutInfo.textWidth
)
val startTime = withFrameNanos { it }
do {
val playTime = withFrameNanos { it } - startTime
offset = (animation.getValueFromNanos(playTime))
} while (!animation.isFinishedFromNanos(playTime))
delay(delay)
} while (true)
}
SubcomposeLayout(
modifier = modifier.clipToBounds()
) { constraints ->
val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE)
var mainText = subcompose(MarqueeLayers.MainText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints)
var gradient: Placeable? = null
var secondPlaceableWithOffset: Pair<Placeable, Int>? = null
if (mainText.width <= constraints.maxWidth) {
mainText = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier.fillMaxWidth())
}.first().measure(constraints)
textLayoutInfoState.value = null
} else {
val spacing = constraints.maxWidth * 2 / 3
textLayoutInfoState.value = TextLayoutInfo(
textWidth = mainText.width + spacing,
containerWidth = constraints.maxWidth
)
val secondTextOffset = mainText.width + offset + spacing
val secondTextSpace = constraints.maxWidth - secondTextOffset
if (secondTextSpace > 0) {
secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints) to secondTextOffset
}
gradient = subcompose(MarqueeLayers.EdgesGradient) {
Row {
GradientEdge(gradientEdgeColor, Color.Transparent)
Spacer(Modifier.weight(1f))
GradientEdge(Color.Transparent, gradientEdgeColor)
}
}.first().measure(constraints.copy(maxHeight = mainText.height))
}
layout(
width = constraints.maxWidth,
height = mainText.height
) {
mainText.place(offset, 0)
secondPlaceableWithOffset?.let {
it.first.place(it.second, 0)
}
gradient?.place(0, 0)
}
}
}
@Composable
private fun GradientEdge(
startColor: Color, endColor: Color,
) {
Box(
modifier = Modifier
.width(10.dp)
.fillMaxHeight()
.background(
brush = Brush.horizontalGradient(
0f to startColor, 1f to endColor,
)
)
)
}
private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient }
private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int)
用法:
MarqueeText("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt")
结果:
简单但不完美的解决方案
val scrollState = rememberScrollState()
var shouldAnimate by remember {
mutableStateOf(true)
}
LaunchedEffect(key1 = shouldAnimated){
scrollState.animateScrollTo(
scrollState.maxValue,
animationSpec = tween(10000, 200, easing = CubicBezierEasing(0f,0f,0f,0f))
)
scrollState.scrollTo(0)
shouldAnimated = !shouldAnimated
}
Text(
text = value,
color = Color.White,
fontSize = 10.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState, false)
)
以前可以在文本中加入一种动画,如果文本超过限制,它会自动水平滚动。这是通过包括:android:ellipsize="marquee"
完成的,结果类似于此处显示的结果:
问题是,在 Jetpack Compose 中,我看不到在可组合文本中包含该选项的方法,TextOverflow
包含 Clip、Ellipsis 或 Visible 选项,但我不知道是否有办法在 Jetpack Compose 中包含或使用“Marquee”选项。有什么办法吗?
Compose 尚不支持此功能,但实施起来并不难。您将需要 TargetBasedAnimation
,它将更新文本偏移量,以及 SubcomposeLayout
,它位于大多数集合之下。在里面你可以定义文本的大小,也可以放置第二个类似的Text
,它会从右边缘出现。
@Composable
fun MarqueeText(
text: String,
modifier: Modifier = Modifier,
textModifier: Modifier = Modifier,
gradientEdgeColor: Color = Color.White,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
val createText = @Composable { localModifier: Modifier ->
Text(
text,
textAlign = textAlign,
modifier = localModifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = 1,
onTextLayout = onTextLayout,
style = style,
)
}
var offset by remember { mutableStateOf(0) }
val textLayoutInfoState = remember { mutableStateOf<TextLayoutInfo?>(null) }
LaunchedEffect(textLayoutInfoState.value) {
val textLayoutInfo = textLayoutInfoState.value ?: return@LaunchedEffect
if (textLayoutInfo.textWidth <= textLayoutInfo.containerWidth) return@LaunchedEffect
val duration = 7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth
val delay = 1000L
do {
val animation = TargetBasedAnimation(
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = duration,
delayMillis = 1000,
easing = LinearEasing,
),
repeatMode = RepeatMode.Restart
),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = -textLayoutInfo.textWidth
)
val startTime = withFrameNanos { it }
do {
val playTime = withFrameNanos { it } - startTime
offset = (animation.getValueFromNanos(playTime))
} while (!animation.isFinishedFromNanos(playTime))
delay(delay)
} while (true)
}
SubcomposeLayout(
modifier = modifier.clipToBounds()
) { constraints ->
val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE)
var mainText = subcompose(MarqueeLayers.MainText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints)
var gradient: Placeable? = null
var secondPlaceableWithOffset: Pair<Placeable, Int>? = null
if (mainText.width <= constraints.maxWidth) {
mainText = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier.fillMaxWidth())
}.first().measure(constraints)
textLayoutInfoState.value = null
} else {
val spacing = constraints.maxWidth * 2 / 3
textLayoutInfoState.value = TextLayoutInfo(
textWidth = mainText.width + spacing,
containerWidth = constraints.maxWidth
)
val secondTextOffset = mainText.width + offset + spacing
val secondTextSpace = constraints.maxWidth - secondTextOffset
if (secondTextSpace > 0) {
secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) {
createText(textModifier)
}.first().measure(infiniteWidthConstraints) to secondTextOffset
}
gradient = subcompose(MarqueeLayers.EdgesGradient) {
Row {
GradientEdge(gradientEdgeColor, Color.Transparent)
Spacer(Modifier.weight(1f))
GradientEdge(Color.Transparent, gradientEdgeColor)
}
}.first().measure(constraints.copy(maxHeight = mainText.height))
}
layout(
width = constraints.maxWidth,
height = mainText.height
) {
mainText.place(offset, 0)
secondPlaceableWithOffset?.let {
it.first.place(it.second, 0)
}
gradient?.place(0, 0)
}
}
}
@Composable
private fun GradientEdge(
startColor: Color, endColor: Color,
) {
Box(
modifier = Modifier
.width(10.dp)
.fillMaxHeight()
.background(
brush = Brush.horizontalGradient(
0f to startColor, 1f to endColor,
)
)
)
}
private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient }
private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int)
用法:
MarqueeText("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt")
结果:
简单但不完美的解决方案
val scrollState = rememberScrollState()
var shouldAnimate by remember {
mutableStateOf(true)
}
LaunchedEffect(key1 = shouldAnimated){
scrollState.animateScrollTo(
scrollState.maxValue,
animationSpec = tween(10000, 200, easing = CubicBezierEasing(0f,0f,0f,0f))
)
scrollState.scrollTo(0)
shouldAnimated = !shouldAnimated
}
Text(
text = value,
color = Color.White,
fontSize = 10.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState, false)
)