I'm trying to implement a rating bar. I refer to https://gist.github.com/vitorprado/0ae4ad60c296aefafba4a157bb165e60 but I don't understand anything from this code. It works but when I use this code the stars don't have rounded corners. I want to implement something like the following :
I made very basic sample for this, it would give the basic idea for creating rating bar with sample border and filled png files.
#Composable
private fun RatingBar(
modifier: Modifier = Modifier,
rating: Float,
spaceBetween: Dp = 0.dp
) {
val image = ImageBitmap.imageResource(id = R.drawable.star)
val imageFull = ImageBitmap.imageResource(id = R.drawable.star_full)
val totalCount = 5
val height = LocalDensity.current.run { image.height.toDp() }
val width = LocalDensity.current.run { image.width.toDp() }
val space = LocalDensity.current.run { spaceBetween.toPx() }
val totalWidth = width * totalCount + spaceBetween * (totalCount - 1)
Box(
modifier
.width(totalWidth)
.height(height)
.drawBehind {
drawRating(rating, image, imageFull, space)
})
}
private fun DrawScope.drawRating(
rating: Float,
image: ImageBitmap,
imageFull: ImageBitmap,
space: Float
) {
val totalCount = 5
val imageWidth = image.width.toFloat()
val imageHeight = size.height
val reminder = rating - rating.toInt()
val ratingInt = (rating - reminder).toInt()
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
drawImage(
image = image,
topLeft = Offset(start, 0f)
)
}
drawWithLayer {
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
// Destination
drawImage(
image = imageFull,
topLeft = Offset(start, 0f)
)
}
val end = imageWidth * totalCount + space * (totalCount - 1)
val start = rating * imageWidth + ratingInt * space
val size = end - start
// Source
drawRect(
Color.Transparent,
topLeft = Offset(start, 0f),
size = Size(size, height = imageHeight),
blendMode = BlendMode.SrcIn
)
}
}
private fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
Usage
Column {
RatingBar(rating = 3.7f, spaceBetween = 3.dp)
RatingBar(rating = 2.5f, spaceBetween = 2.dp)
RatingBar(rating = 4.5f, spaceBetween = 2.dp)
RatingBar(rating = 1.3f, spaceBetween = 4.dp)
}
Result
Also created a library that uses gestures, other png files and vectors as rating items is available here.
RatingBar(
rating = rating,
space = 2.dp,
imageBackground = imageBackground,
imageForeground = imageForeground,
animationEnabled = false,
gestureEnabled = true,
itemSize = 60.dp
) {
rating = it
}
You can pass the custom drawable as icon. check this code.
Replace your RatingStar() function as it is using canvas to draw star, instead pass the custom drawable.
#Composable
private fun starShow(){
val icon = if (isSelected)
//your selected drawable
else
//your unselected drawable
Icon(
painter = painterResource(id = icon),
contentDescription = null,
tint = MyColor.starColor)
}
I'm drawing a custom progress bar, the idea is to have a vertical "line" of circles with space between them like this:
As you progress, the circles change color and size, and the text ("60" in the image I shared)is displayed alongside the top level circle. With the current text size it seems centered enough, but if I change the text size it is noticeable that is bottom aligned, like this:
Here is the code:
Box(
modifier
.aspectRatio(1f)
.drawWithCache {
onDrawBehind {
val height = size.height
val space = height / 10
for (i in 1..numberOfCircles) {
val onBrush =
if (i <= progressLevel) progressBrush(progressLevel) else Color.Gray.toBrush()
val circleSize =
if (i == progressLevel) circleRadius.value * 2.5F else if (i == progressLevel - 1) circleRadius.value * 1.5F else circleRadius.value
drawProgressCircle(
onBrush,
sizeArray[i - 1].value,
size.height - (space * i)
)
if (i == state.level) {
drawText(
onBrush,
circleSize,
size.height - (space * i),
(progressLevel * 10).toString()
)
}
}
}
},
contentAlignment = Alignment.Center
)
The draw circles function:
fun DrawScope.drawProgressCircle(
brush: Brush,
radius: Float,
place: Float
) {
drawCircle(
brush = brush,
radius = radius,
center = Offset(x = size.width/2, y = place),
)
}
The draw text function:
fun DrawScope.drawText(
brush: Brush,
radius: Float,
place: Float,
text: String
) {
drawContext.canvas.nativeCanvas.apply {
drawText(
text,
size.width/2.2F,
place + radius,
Paint().apply {
textSize = 20.sp.toPx()
color = Color.White.toArgb()
textAlign = Paint.Align.RIGHT
}
)
}
}
How can I keep the text vertically aligned at the last circle center with any text size?
I am trying to get what part of the image has been clicked and get the Color of that point
I'm able to get the tap coordinates through a Modifier but I don't know how to relate it with the drawable of the Image:
#Composable
fun InteractableImage(
modifier = Modifier
...
) {
val sectionClicked by rememberSaveable { mutableStateOf<Offset>(Offset.Unspecified) }
Image(
modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectTapGestures (
onTap = { offset -> sectionClicked = offset },
painter = painterResource(id = R.drawable.ic_square),
contentDescription = "square"
)
....
}
With the classic view system, I could access the matrix of the imageView, and with the scale property and the intrinsic drawable dimensions, I could find the part of the image that was clicked. How could I achieve this using Android Compose?
It's a little bit long but working solution except when there are spaces at edges of the Bitmap inside Image because of contentScale param of Image. Need to do another operation for calculating space around but couldn't find it at the moment. And if anyone finds the space just set it to startX, startY and use linear interpolation.
1- Instead of painter get an ImageBitmap
val imageBitmap: ImageBitmap = ImageBitmap.imageResource(
LocalContext.current.resources,
R.drawable.landscape7
)
val bitmapWidth = imageBitmap.width
val bitmapHeight = imageBitmap.height
2- Create variables for touch position on x, y axes and size of the Image that we put bitmap into
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var imageSize by remember { mutableStateOf(Size.Zero) }
Get Image width from Modifier.onSizeChanged{imageSize = it.toSize()}
3- Get touch position scale it to bitmap position, convert to pixel and return r, g, b values from pixel
.pointerInput(Unit) {
detectTapGestures { offset: Offset ->
// Touch coordinates on image
offsetX = offset.x
offsetY = offset.y
// Scale from Image touch coordinates to range in Bitmap
val scaledX = (bitmapWidth/imageSize.width)*offsetX
val scaledY = (bitmapHeight/imageSize.height)*offsetY
try {
val pixel: Int =
imageBitmap
.asAndroidBitmap()
.getPixel(scaledX.toInt(), scaledY.toInt())
// Don't know if there is a Compose counterpart for this
val red = android.graphics.Color.red(pixel)
val green = android.graphics.Color.green(pixel)
val blue = android.graphics.Color.blue(pixel)
colorInTouchPosition = Color(red,green,blue)
}catch (e:Exception){
println("Exception e: ${e.message}")
}
}
}
4- Instead of scaling and lerping you can just use
val scaledX = (bitmapWidth/endImageX)*offsetX but when your bitmap is not fit into image you need to use linear interpolation after calculating space on left, right, top or bottom of the image, or you should make sure that Image has the same width/height ratio as Bitmap.
i used contentScale = ContentScale.FillBounds for simplicity
// Scale from Image touch coordinates to range in Bitmap
val scaledX = (bitmapWidth/imageSize.width)*offsetX
val scaledY = (bitmapHeight/imageSize.height)*offsetY
Full Implementation
#Composable
private fun TouchOnImageExample() {
val imageBitmap: ImageBitmap = ImageBitmap.imageResource(
LocalContext.current.resources,
R.drawable.landscape6
)
val bitmapWidth = imageBitmap.width
val bitmapHeight = imageBitmap.height
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var imageSize by remember { mutableStateOf(Size.Zero) }
// These are for debugging
var text by remember { mutableStateOf("") }
var colorInTouchPosition by remember { mutableStateOf(Color.Unspecified) }
val imageModifier = Modifier
.background(Color.LightGray)
.fillMaxWidth()
// This is for displaying different ratio, optional
.aspectRatio(4f / 3)
.pointerInput(Unit) {
detectTapGestures { offset: Offset ->
// Touch coordinates on image
offsetX = offset.x
offsetY = offset.y
// Scale from Image touch coordinates to range in Bitmap
val scaledX = (bitmapWidth/imageSize.width)*offsetX
val scaledY = (bitmapHeight/imageSize.height)*offsetY
// TODO This section needed when Bitmap does not fill Image completely
// However i couldn't find a solution to find spaces correctly
// // Need to calculate spaces at edges of the bitmap inside Image Composable if
// // not exactly filling the bounds of Image
// val startImageX = 0f
// val startImageY = 0f
//
// // End positions, this might be less than Image dimensions if bitmap doesn't fit Image
// val endImageX = imageSize.width - startImageX
// val endImageY = imageSize.height - startImageY
// val scaledX =
// scale(
// start1 = startImageX,
// end1 = endImageX,
// pos = offsetX,
// start2 = 0f,
// end2 = bitmapWidth.toFloat()
// ).coerceAtMost(bitmapWidth.toFloat())
// val scaledY =
// scale(
// start1 = startImageY,
// end1 = endImageY,
// pos = offsetY,
// start2 = 0f,
// end2 = bitmapHeight.toFloat()
// ).coerceAtMost(bitmapHeight.toFloat())
try {
val pixel: Int =
imageBitmap
.asAndroidBitmap()
.getPixel(scaledX.toInt(), scaledY.toInt())
// Don't know if there is a Compose counterpart for this
val red = android.graphics.Color.red(pixel)
val green = android.graphics.Color.green(pixel)
val blue = android.graphics.Color.blue(pixel)
text = "Image Touch: $offsetX, offsetY: $offsetY\n" +
"size: $imageSize\n" +
"bitmap width: ${bitmapWidth}, height: $bitmapHeight\n" +
"scaledX: $scaledX, scaledY: $scaledY\n" +
"red: $red, green: $green, blue: $blue\n"
colorInTouchPosition = Color(red,green,blue)
}catch (e:Exception){
println("Exception e: ${e.message}")
}
}
}
.onSizeChanged { imageSize = it.toSize() }
Image(
bitmap = imageBitmap,
contentDescription = null,
modifier = imageModifier,
contentScale = ContentScale.FillBounds
)
Text(text = text)
Box(
modifier = Modifier
.then(
if (colorInTouchPosition == Color.Unspecified) {
Modifier
} else {
Modifier.background(colorInTouchPosition)
}
)
.size(100.dp)
)
}
/**
* Interpolate position x linearly between start and end
*/
fun lerp(start: Float, end: Float, amount: Float): Float {
return start + amount * (end - start)
}
/**
* Scale x1 from start1..end1 range to start2..end2 range
*/
fun scale(start1: Float, end1: Float, pos: Float, start2: Float, end2: Float) =
lerp(start2, end2, calculateFraction(start1, end1, pos))
/**
* Calculate fraction for value between a range [end] and [start] coerced into 0f-1f range
*/
fun calculateFraction(start: Float, end: Float, pos: Float) =
(if (end - start == 0f) 0f else (pos - start) / (end - start)).coerceIn(0f, 1f)
Result
Is there a way to make a box with a wavy top with Canvas?
I would like to know if this effect can be achieved directly with a Canvas, it is not necessary to have a scrolling animation.
It's not quite clear why you're talking about Canvas. To crop a view like this, you can use a custom Shape and apply it to your view with Modifier.clip. Here's a shape you can use:
class WavyShape(
private val period: Dp,
private val amplitude: Dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
) = Outline.Generic(Path().apply {
val wavyPath = Path().apply {
val halfPeriod = with(density) { period.toPx() } / 2
val amplitude = with(density) { amplitude.toPx() }
moveTo(x = -halfPeriod / 2, y = amplitude)
repeat(ceil(size.width / halfPeriod + 1).toInt()) { i ->
relativeQuadraticBezierTo(
dx1 = halfPeriod / 2,
dy1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dx2 = halfPeriod,
dy2 = 0f,
)
}
lineTo(size.width, size.height)
lineTo(0f, size.height)
}
val boundsPath = Path().apply {
addRect(Rect(offset = Offset.Zero, size = size))
}
op(wavyPath, boundsPath, PathOperation.Intersect)
})
}
If you really need to use this inside Canvas for some reason, you can pass the same Path that I create inside WavyShape to DrawScope.clipPath, so that the contents of the clipPath block will be clipped.
Apply custom shape to your Image or any other view:
Image(
painter = painterResource(id = R.drawable.my_image_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.clip(WavyShape(period = 100.dp, amplitude = 50.dp))
)
Result:
In the past, a kind of animation could be included in the text, in which if the text exceeded the limits, it would automatically scroll horizontally. This was done by including: android:ellipsize="marquee", and the result was something similar to the one shown here:
The problem is that in Jetpack Compose I don't see a way to include that option inside the Composable Text, there is the TextOverflow that includes the Clip, Ellipsis or Visible options, but I don't know if there is a way to include or use the "Marquee" option in Jetpack Compose. Is there any way to do it?
Modifier.basicMarquee was introduced in 1.4.0-alpha04. It's gonna animate position of your content if it doesn't fit the container width. Here's usage example:
Text(
LoremIpsum().values.first().take(10),
maxLines = 1,
modifier = Modifier
.width(50.dp)
.basicMarquee()
)
It's not gonna add gradient edges, as XML attribute does. The tricky part is that this modifier doesn't has any state for you to know, if content fits the bounds or not, so you can't optionally draw the gradient. Maybe they'll add it later.
Below solution would work prior Compose 1.4 and also would only add gradient in case content doesn't fit the container.
You will need TargetBasedAnimation, which will update the text offset, and SubcomposeLayout, which lies under most collections. Inside you can define the size of the text, and also place the second similar Text, which will appear from the right edge.
#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)
Usage:
MarqueeText(LoremIpsum().values.first().take(90))
Result:
Simple solution yet not perfect
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)
)
Starting from 1.4.0-alpha04 you can use the basicMarquee() modifier:
// Marquee only animates when the content doesn't fit in the max width.
Column(Modifier.width(30.dp)) {
Text("hello world hello world hello",
Modifier.basicMarquee())
}
If you want to add a fade effect at the edges you can use:
Text(
"the quick brown fox jumped over the lazy dogs",
Modifier
.widthIn(max = edgeWidth * 4)
// Rendering to an offscreen buffer is required to get the faded edges' alpha to be
// applied only to the text, and not whatever is drawn below this composable (e.g. the
// window).
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawFadedEdge(leftEdge = true)
drawFadedEdge(leftEdge = false)
}
.basicMarquee(
// Animate forever.
iterations = Int.MAX_VALUE,
spacing = MarqueeSpacing(0.dp)
)
.padding(start = edgeWidth)
)
with:
val edgeWidth = 32.dp
fun ContentDrawScope.drawFadedEdge(leftEdge: Boolean) {
val edgeWidthPx = edgeWidth.toPx()
drawRect(
topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f),
size = Size(edgeWidthPx, size.height),
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = if (leftEdge) 0f else size.width,
endX = if (leftEdge) edgeWidthPx else size.width - edgeWidthPx
),
blendMode = BlendMode.DstIn
)
}
You can refer to https://issuetracker.google.com/issues/139321650 for this. The code is available at : https://android-review.googlesource.com/c/platform/frameworks/support/+/2334291/20/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
It adds an extension function for Modifier.basicMarquee(parameters)
The code works perfectly fine for Text composable.
EDIT: You can refer here as well->
const val DefaultMarqueeIterations: Int = 3
const val DefaultMarqueeDelayMillis: Int = 1_200
val DefaultMarqueeSpacing: MarqueeSpacing = MarqueeSpacing.fractionOfContainer(1f / 3f)
val DefaultMarqueeVelocity: Dp = 30.dp
/**
* Applies an animated marquee effect to the modified content if it's too wide to fit in the
* available space. This modifier has no effect if the content fits in the max constraints. The
* content will be measured with unbounded width.
*
* When the animation is running, it will restart from the initial state any time:
* - any of the parameters to this modifier change, or
* - the content or container size change.
*
* The animation only affects the drawing of the content, not its position. The offset returned by
* the [LayoutCoordinates] of anything inside the marquee is undefined relative to anything outside
* the marquee, and may not match its drawn position on screen. This modifier also does not
* currently support content that accepts position-based input such as pointer events.
*
*
* #param iterations The number of times to repeat the animation. `Int.MAX_VALUE` will repeat
* forever, and 0 will disable animation.
* #param animationMode Whether the marquee should start animating [Immediately] or only
* [WhileFocused].
* #param delayMillis The duration to wait before starting each subsequent iteration, in millis.
* #param initialDelayMillis The duration to wait before starting the first iteration of the
* animation, in millis. By default, there will be no initial delay if [animationMode] is
* [Immediately], otherwise the initial delay will be [delayMillis].
* #param spacing A [MarqueeSpacing] that specifies how much space to leave at the end of the
* content before showing the beginning again.
* #param velocity The speed of the animation in dps / second.
*/
fun Modifier.basicMarquee(
iterations: Int = DefaultMarqueeIterations,
animationMode: MarqueeAnimationMode = Immediately,
delayMillis: Int = DefaultMarqueeDelayMillis,
initialDelayMillis: Int = if (animationMode == Immediately) delayMillis else 0,
spacing: MarqueeSpacing = DefaultMarqueeSpacing,
velocity: Dp = DefaultMarqueeVelocity
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "basicMarquee"
properties["iterations"] = iterations
properties["animationMode"] = animationMode
properties["delayMillis"] = delayMillis
properties["initialDelayMillis"] = initialDelayMillis
properties["spacing"] = spacing
properties["velocity"] = velocity
}
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val modifier = remember(
iterations,
delayMillis,
initialDelayMillis,
velocity,
spacing,
animationMode,
density,
layoutDirection,
) {
MarqueeModifier(
iterations = iterations,
delayMillis = delayMillis,
initialDelayMillis = initialDelayMillis,
velocity = velocity * if (layoutDirection == Ltr) 1f else -1f,
spacing = spacing,
animationMode = animationMode,
density = density
)
}
LaunchedEffect(modifier) {
modifier.runAnimation()
}
return#composed modifier
}
private class MarqueeModifier(
private val iterations: Int,
private val delayMillis: Int,
private val initialDelayMillis: Int,
private val velocity: Dp,
private val spacing: MarqueeSpacing,
private val animationMode: MarqueeAnimationMode,
private val density: Density,
) : Modifier.Element, LayoutModifier, DrawModifier, FocusEventModifier {
private var contentWidth by mutableStateOf(0)
private var containerWidth by mutableStateOf(0)
private var hasFocus by mutableStateOf(false)
private val offset = Animatable(0f)
private val direction = sign(velocity.value)
private val spacingPx by derivedStateOf {
with(spacing) {
density.calculateSpacing(contentWidth, containerWidth)
}
}
private val firstCopyVisible by derivedStateOf {
when (direction) {
1f -> offset.value < contentWidth
else -> offset.value < containerWidth
}
}
private val secondCopyVisible by derivedStateOf {
when (direction) {
1f -> offset.value > (contentWidth + spacingPx) - containerWidth
else -> offset.value > spacingPx
}
}
private val secondCopyOffset: Float by derivedStateOf {
when (direction) {
1f -> contentWidth + spacingPx
else -> -contentWidth - spacingPx
}.toFloat()
}
private val contentWidthPlusSpacing: Float?
get() {
// Don't animate if content fits. (Because coroutines, the int will get boxed anyway.)
if (contentWidth <= containerWidth) return null
if (animationMode == WhileFocused && !hasFocus) return null
return (contentWidth + spacingPx).toFloat()
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val childConstraints = constraints.copy(maxWidth = Constraints.Infinity)
val placeable = measurable.measure(childConstraints)
containerWidth = constraints.constrainWidth(placeable.width)
contentWidth = placeable.width
return layout(containerWidth, placeable.height) {
// Placing the marquee content in a layer means we don't invalidate the parent draw
// scope on every animation frame.
placeable.placeWithLayer(x = (-offset.value * direction).roundToInt(), y = 0)
}
}
override fun ContentDrawScope.draw() {
val clipOffset = offset.value * direction
clipRect(left = clipOffset, right = clipOffset + containerWidth) {
if (firstCopyVisible) {
this#draw.drawContent()
}
if (secondCopyVisible) {
translate(left = secondCopyOffset) {
this#draw.drawContent()
}
}
}
}
override fun onFocusEvent(focusState: FocusState) {
hasFocus = focusState.hasFocus
}
suspend fun runAnimation() {
if (iterations <= 0) {
// No animation.
return
}
snapshotFlow { contentWidthPlusSpacing }.collectLatest { contentWithSpacingWidth ->
// Don't animate when the content fits.
if (contentWithSpacingWidth == null) return#collectLatest
val spec = createMarqueeAnimationSpec(
iterations,
contentWithSpacingWidth,
initialDelayMillis,
delayMillis,
velocity,
density
)
offset.snapTo(0f)
try {
offset.animateTo(contentWithSpacingWidth, spec)
} finally {
offset.snapTo(0f)
}
}
}
}
private fun createMarqueeAnimationSpec(
iterations: Int,
targetValue: Float,
initialDelayMillis: Int,
delayMillis: Int,
velocity: Dp,
density: Density
): AnimationSpec<Float> {
val pxPerSec = with(density) { velocity.toPx() }
val singleSpec = velocityBasedTween(
velocity = pxPerSec.absoluteValue,
targetValue = targetValue,
delayMillis = delayMillis
)
// Need to cancel out the non-initial delay.
val startOffset = StartOffset(-delayMillis + initialDelayMillis)
return if (iterations == Int.MAX_VALUE) {
infiniteRepeatable(singleSpec, initialStartOffset = startOffset)
} else {
repeatable(iterations, singleSpec, initialStartOffset = startOffset)
}
}
/**
* Calculates a float [TweenSpec] that moves at a constant [velocity] for an animation from 0 to
* [targetValue].
*
* #param velocity Speed of animation in px / sec.
*/
private fun velocityBasedTween(
velocity: Float,
targetValue: Float,
delayMillis: Int
): TweenSpec<Float> {
val pxPerMilli = velocity / 1000f
return tween(
durationMillis = ceil(targetValue / pxPerMilli).toInt(),
easing = LinearEasing,
delayMillis = delayMillis
)
}
/** Specifies when the [basicMarquee] animation runs. */
#JvmInline
value class MarqueeAnimationMode private constructor(private val value: Int) {
override fun toString(): String = when (this) {
Immediately -> "Immediately"
WhileFocused -> "WhileFocused"
else -> error("invalid value: $value")
}
companion object {
/** Starts animating immediately, irrespective of focus state. */
#Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val Immediately = MarqueeAnimationMode(0)
/**
* Only animates while the marquee has focus. This includes when a focusable child in the
* marquee's content is focused.
*/
#Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val WhileFocused = MarqueeAnimationMode(1)
}
}
/**
* A [MarqueeSpacing] with a fixed size.
*/
fun MarqueeSpacing(spacing: Dp): MarqueeSpacing = MarqueeSpacing { _, _ -> spacing.roundToPx() }
/**
* Defines a [calculateSpacing] method that determines the space after the end of [basicMarquee]
* content before drawing the content again.
*/
fun interface MarqueeSpacing {
/**
* Calculates the space after the end of [basicMarquee] content before drawing the content
* again.
*
* This is a restartable method: any state used to calculate the result will cause the spacing
* to be re-calculated when it changes.
*
* #param contentWidth The width of the content inside the marquee, in pixels. Will always be
* larger than [containerWidth].
* #param containerWidth The width of the marquee itself, in pixels. Will always be smaller than
* [contentWidth].
* #return The space in pixels between the end of the content and the beginning of the content
* when wrapping.
*/
fun Density.calculateSpacing(
contentWidth: Int,
containerWidth: Int
): Int
companion object {
/**
* A [MarqueeSpacing] that is a fraction of the container's width.
*/
fun fractionOfContainer(fraction: Float): MarqueeSpacing = MarqueeSpacing { _, width ->
(fraction * width).roundToInt()
}
}
}
and you can use it like this:
Text(text = "marquee text",modifier = Modifier.basicMarquee())