Android Jetpack Compose Flicker Image Clone - android

I have one Image composable, then I retrieve its boundary box using onGloballyPositioned listener. When I press a button a new Image is displayed, that have the same resId and initial position and size, so it matches the size of the original Image. And the original image gets hidden, while the copy image changes it locating using absoluteOffset and its size using the width and height attributes. I am using LaunchedEffect, to generate float values from 0f to 1f, and then use them to change the position and size of the copied image.
Here is the result:
Everything works well, except the fact that there is some flickering, since we hide the original and show the copy image immediately, and probably there is a empty frame, when both images are recomposed at the same time. So the original image is hidden, but the copied image is still not show, so there is a frame where both images are invisible.
Is there a way I can set the the order in which the images are recomposed, so the copied image get its visible state, before the original image is hidden?
I saw that there is way to use key inside columns/rows from here. But I am not so sure it is related.
The other idea I got is to use opacity animation, so there can be a delay, something like
time | Original Image (opacity) | Copy Image (opacity)
-------|---------------------------|-----------------------
0s | 1 | 0
0.2s | 0.75 | 0.25
0.4s | 0.5 | 0.5
0.6s | 0.25 | 0.75
0.8s | 0.0 | 1
Also I know I can use single image to achieve the same effect, but I want to have separate image, that is not part of the compose navigation. So if I transition to another destination, I want the image to be transferred to that destination with smooth animation.
Here is the source code:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.slaviboy.myapplication.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
val viewModel by viewModels<ViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val left = with(LocalDensity.current) { 200.dp.toPx() }
val top = with(LocalDensity.current) { 300.dp.toPx() }
val width = with(LocalDensity.current) { 100.dp.toPx() }
viewModel.setSharedImageToCoord(Rect(left, top, left + width, top + width))
Box(modifier = Modifier.fillMaxSize()) {
if (!viewModel.isSharedImageVisible.value) {
Image(painter = painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.width(130.dp)
.height(130.dp)
.onGloballyPositioned { coordinates ->
coordinates.parentCoordinates
?.localBoundingBoxOf(coordinates, false)
?.let {
viewModel.setSharedImageFromCoord(it)
}
})
}
SharedImage(viewModel)
}
Button(onClick = {
viewModel.setIsSharedImageVisible(true)
viewModel.triggerAnimation()
}) {
}
}
}
}
#Composable
fun SharedImage(viewModel: ViewModel) {
var left by remember { mutableStateOf(0f) }
var top by remember { mutableStateOf(0f) }
var width by remember { mutableStateOf(330f) }
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(1700, 0),
typeConverter = Float.VectorConverter,
initialValue = 0f,
targetValue = 1f
)
}
var playTime by remember { mutableStateOf(0L) }
LaunchedEffect(viewModel.triggerAnimation.value) {
val from = viewModel.sharedImageFromCoord.value
val to = viewModel.sharedImageToCoord.value
val fromLeft = from.left
val fromTop = from.top
val fromSize = from.width
val toLeft = to.left
val toTop = to.top
val toSize = to.width
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = anim.getValueFromNanos(playTime)
left = fromLeft + animationValue * (toLeft - fromLeft)
top = fromTop + animationValue * (toTop - fromTop)
width = fromSize + animationValue * (toSize - fromSize)
} while (playTime < anim.durationNanos)
}
if (viewModel.isSharedImageVisible.value) {
Image(
painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
modifier = Modifier
.absoluteOffset {
IntOffset(left.toInt(), top.toInt())
}
.width(
with(LocalDensity.current) { width.toDp() }
)
.height(
with(LocalDensity.current) { width.toDp() }
)
)
}
}
class ViewModel : androidx.lifecycle.ViewModel() {
private val _isSharedImageVisible = mutableStateOf(false)
val isSharedImageVisible: State<Boolean> = _isSharedImageVisible
fun setIsSharedImageVisible(isSharedImageVisible: Boolean) {
_isSharedImageVisible.value = isSharedImageVisible
}
private val _sharedImageFromCoord = mutableStateOf(Rect.Zero)
val sharedImageFromCoord: State<Rect> = _sharedImageFromCoord
fun setSharedImageFromCoord(sharedImageFromCoord: Rect) {
_sharedImageFromCoord.value = sharedImageFromCoord
}
private val _sharedImageToCoord = mutableStateOf(Rect.Zero)
val sharedImageToCoord: State<Rect> = _sharedImageToCoord
fun setSharedImageToCoord(sharedImageToCoord: Rect) {
_sharedImageToCoord.value = sharedImageToCoord
}
private val _setSharedImageResId = mutableStateOf(R.drawable.ic_launcher_background)
val setSharedImageResId: State<Int> = _setSharedImageResId
fun setSharedImageResId(setSharedImageResId: Int) {
_setSharedImageResId.value = setSharedImageResId
}
private val _triggerAnimation = mutableStateOf(false)
val triggerAnimation: State<Boolean> = _triggerAnimation
fun triggerAnimation() {
_triggerAnimation.value = !_triggerAnimation.value
}
}

Well I manage to fix that problem by applying 200ms delay to the transition animation and also apply the same delay to the navigation transition animation!
During those 200ms, I start another animation that changes the opacity of the shared (copied) image from [0,1]. So basically I show the shared image, during those 200ms, and it get drawn on top of the item (original) image. Then on the last frame I hide the item (original) image, and only the transition image it displayed.
Then after those 200ms delay, I start the transition of the shared(copied) image to its new location. Here is simple graph to demonstrate the animations, during the 200ms delay and the 700ms duration.
#Composable
fun SharedImage(viewModel: ViewModel) {
// opacity animation for the shared image
// if triggered from Home -> change the opacity of the shared image [0,1]
// if triggered from Detail -> change the opacity of the shared image [1,0]
LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {
val duration: Int
val delay: Int
val opacityFrom: Float
val opacityTo: Float
if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
duration = 200
delay = 0
opacityFrom = 0f
opacityTo = 1f
} else {
duration = 200
delay = 700 + 200
opacityFrom = 1f
opacityTo = 0f
}
val animation = TargetBasedAnimation(
animationSpec = tween(duration, delay),
typeConverter = Float.VectorConverter,
initialValue = opacityFrom,
targetValue = opacityTo
)
var playTime = 0L
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = animation.getValueFromNanos(playTime)
viewModel.setSharedImageOpacity(animationValue)
} while (playTime <= animation.durationNanos)
// on last frame set item opacity to 0
if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.setItemImageOpacity(0f)
}
}
var left by remember { mutableStateOf(0f) }
var top by remember { mutableStateOf(0f) }
var width by remember { mutableStateOf(0f) }
// transition animation for the shared image
// it changes the position and size of the shared image
LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {
val animation = TargetBasedAnimation(
animationSpec = tween(700, 200),
typeConverter = Float.VectorConverter,
initialValue = 0f,
targetValue = 1f
)
val from = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.sharedImageFromCoord.value
} else viewModel.sharedImageToCoord.value
val to = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.sharedImageToCoord.value
} else viewModel.sharedImageFromCoord.value
// offset and size for changing the shared image position and size
val fromLeft = from.left
val fromTop = from.top
val fromSize = from.width
val toLeft = to.left
val toTop = to.top
val toSize = to.width
var playTime = 0L
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = animation.getValueFromNanos(playTime)
left = fromLeft + animationValue * (toLeft - fromLeft)
top = fromTop + animationValue * (toTop - fromTop)
width = fromSize + animationValue * (toSize - fromSize)
} while (playTime <= animation.durationNanos)
// on last frame set item opacity to 1
if (viewModel.changeSharedImagePositionFrom.value is Screen.Detail) {
viewModel.setItemImageOpacity(1f)
viewModel.setEnableItemsScroll(true)
}
}
Image(
painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
modifier = Modifier
.absoluteOffset { IntOffset(left.toInt(), top.toInt()) }
.width(with(LocalDensity.current) { width.toDp() })
.height(with(LocalDensity.current) { width.toDp() }),
alpha = viewModel.sharedImageOpacity.value
)
}
Here is the result

Related

How to add drag to custom layout and change the color of the dragged surface in jetpack compose

I am using this tutorial
https://danielrampelt.com/blog/jetpack-compose-custom-schedule-layout-part-1/ and part 2
to draw a custom schedule. How can I want to use draggable inside of it so I can change the color of the dragged surface in jetpack compose. My aim is to drag an time slot vertically. This is my composable function. I have put the draggable function but I don't know how to implement it. Now my screen freezes, I can't scroll vertically.
#Composable
fun DynamicSchedule(
viewModel: CalenderViewModel,
modifier: Modifier = Modifier,
appointmentContent: #Composable (appointment: Appointment) -> Unit = {
ScheduleCard(
appointment = it
)
},
minDate: LocalDate,
maxDate: LocalDate,
dayWidth: Dp,
hourHeight: Dp
) {
val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1
val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
var offsetY by remember { mutableStateOf(0f) }
Layout(
content = {
viewModel.state.value.appointmentList.sortedBy { it.startDate }
.forEach { appointment ->
Box(modifier = Modifier.appointmentData(appointment)) {
appointmentContent(appointment)
}
}
},
modifier = modifier
.drawBehind {
repeat(23) {
drawLine(
dividerColor,
start = Offset(0f, (it + 1) * hourHeight.toPx()),
end = Offset(size.width, (it + 1) * hourHeight.toPx()),
strokeWidth = 1.dp.toPx()
)
}
repeat(numDays - 1) {
drawLine(
dividerColor,
start = Offset((it + 1) * dayWidth.toPx(), 0f),
end = Offset((it + 1) * dayWidth.toPx(), size.height),
strokeWidth = 1.dp.toPx()
)
}
}
.pointerInput(Unit) {
detectTapGestures {
val x = it.x.toDp()
val y = it.y.toDp()
val time = y.value.toInt() / hourHeight.value
val date = (x.value.toInt() / dayWidth.value)
println("X: $x, Y: $y")
println("Day: $date, Time: $time")
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offsetY += delta
println("Delta: $offsetY")
}
),
) { measurables, constraints ->
println("i got recomposed ======== ")
val height = hourHeight.roundToPx() * 24
val width = dayWidth.roundToPx() * numDays
val placeablesWithAppointment = measurables.map { measurable ->
val appointment = measurable.parentData as Appointment
val appointmentDurationInMinutes =
ChronoUnit.MINUTES.between(
appointment.startDate.time.toJavaLocalTime(),
appointment.endDate.time.toJavaLocalTime()
)
val appointmentHeight =
((appointmentDurationInMinutes / 60f) * hourHeight.toPx()).roundToInt()
val placeable = measurable.measure(
constraints.copy(
minWidth = dayWidth.roundToPx(),
maxWidth = dayWidth.roundToPx(),
minHeight = appointmentHeight,
maxHeight = appointmentHeight
)
)
Pair(placeable, appointment)
}
layout(width, height) {
placeablesWithAppointment.forEach { (placeable, appointment) ->
//appointment time - midnight
val appointmentOffsetMinutes =
ChronoUnit.MINUTES.between(
LocalTime.MIN,
appointment.startDate.time.toJavaLocalTime()
)
val appointmentY =
((appointmentOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
val appointmentOffsetDays =
ChronoUnit.DAYS.between(
minDate,
appointment.startDate.date.toJavaLocalDate()
).toInt()
val appointmentX = appointmentOffsetDays * dayWidth.roundToPx()
placeable.place(appointmentX, appointmentY)
}
}
}
}

How to implement a molecule drawing app in Jetpack Compose

I wanted to implement a drawing app written in Jetpack compose, I wanted to achieve goal like below in kotlin canvas:
I have tried to build a simple app base on Compose Multiplatform Canvas, but the functionability is pretty bad, My experimental code is below. What I want to know are:
(1) How can I detect mouse entering the drown shapes?
(2) Is there any canvas API can draw rotated line on canvas?
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.cos
import kotlin.math.sin
data class PathProperties(val Angle: Float, val length: Float, val startPoint: Pair<Float, Float>, val endPoint: Pair<Float, Float>)
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun customCanvas(){
var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
var previousPosition by remember { mutableStateOf(Offset.Unspecified) }
val randomAngle = listOf(45f, -45f)
val paths = remember { mutableStateListOf<Pair<Path, PathProperties>>() }
var currentPath by remember { mutableStateOf(Path()) }
val lineLength = 30f
var cPaths = remember { mutableStateListOf<Rect>() }
var dotList = remember { mutableStateListOf<Color>() }
Canvas(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Gray)
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown().also {
currentPosition = it.position
previousPosition = currentPosition
currentPath.moveTo(currentPosition.x, currentPosition.y)
val angle = randomAngle.random()
val startPoint = Pair(currentPosition.x, currentPosition.y)
val endPoint = getPointByAngle(lineLength, angle, startPoint)
currentPath.lineTo(endPoint.first, endPoint.second)
paths.add(Pair(currentPath, PathProperties(angle, 30f, startPoint, endPoint)))
cPaths.add(Rect(
left = currentPosition.x - 4,
right = currentPosition.x + 4,
top = currentPosition.y - 4,
bottom = currentPosition.y + 4,
))
dotList.add(Color.Cyan)
}
}
}
}
.onPointerEvent(PointerEventType.Move) {
val position = it.changes.first().position
for ((idx, rect) in cPaths.withIndex()) {
if (rect.contains(position)) {
dotList[idx] = Color.Black
break
} else {
dotList[idx] = Color.Cyan
}
}
}
){
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
paths.forEachIndexed() { idx,it: Pair<Path, PathProperties> ->
drawPath(
color = Color.Black,
path = it.first,
style = Stroke(
width = 3f,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
)
)
drawCircle(
color = dotList[idx],
radius = 8f,
center = Offset(it.second.startPoint.first, it.second.startPoint.second),
)
drawCircle(
color = dotList[idx],
radius = 8f,
center = Offset(it.second.endPoint.first, it.second.endPoint.second),
)
}
}
}
}
//calculate the end point x and y coordinate by cos() and sin()
fun getPointByAngle(length: Float, angle: Float, startPoint: Pair<Float, Float>): Pair<Float, Float> {
return Pair(startPoint.first + length * cos(angle), startPoint.second + length * sin(angle))
}

Android Jetpack Compose - Animate canvas path inside LazyRow

i'm new to android compose, i'm trying to animate a canvas path inside items in a LazyRow. When application is running, if i scroll back and forth, before animations ends, some elements do not draw correctly. Cannot figure out if is a state problem or what else.
Is it also possible to animate only one time per element, so when scrolling the same element animation will not repeat?
Below the code used for the test. Any help is appreciated! Thanks
#Composable
private fun drawPointsAnimation() {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(15) { _ ->
val points = getPoints(
path = getPathBox(
Size(LocalDensity.current.run { 50.dp.toPx() },
LocalDensity.current.run { 50.dp.toPx() })
)
)
val pointsCopy = mutableListOf<Offset>()
var targetIndexValue by remember {
mutableStateOf(0)
}
val currentIndex by animateIntAsState(
targetValue = targetIndexValue,
animationSpec = tween(4000, easing = LinearEasing)
)
LaunchedEffect(Unit) {
targetIndexValue = points.size - 1
}
Box(
modifier = Modifier
.height(50.dp)
.width(
50.dp
)
.background(Color.Blue)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
pointsCopy.add(points[currentIndex])
drawPoints(
points = pointsCopy,
strokeWidth = 3f,
pointMode = PointMode.Lines,
color = Color.White
)
}
}
}
}
}
private fun getPoints(path: Path): MutableList<Offset> {
val pm = android.graphics.PathMeasure(path.asAndroidPath(), false)
val pointsList = mutableListOf<Offset>()
var i = 0.0f
while (i < 1) {
val aCoordinates = floatArrayOf(0f, 0f)
pm.getPosTan(pm.length * i, aCoordinates, null)
pointsList.add(Offset(x = aCoordinates[0], y = aCoordinates[1]))
i += 0.01f
}
return pointsList
}
private fun getPathBox(size: Size): Path {
val path = Path()
path.moveTo(size.width, 0f)
path.lineTo(0f, size.height)
return path
}
You declare all of the variable to do the animation inside the item() block. I guess everytime the list need to render an item at some index it will re-declare you variable value, so that's why the animation is restarted. Maybe try to declare variable outside of the item() block? Like store animateIntAsState() inside a list and access that value for each of the item index independently?

Android - Horizontally (right-to-left) circular background color transition

I want to know how can I implement this animation?
Small image frame and text movement is independent from the animation. It's just like simple pager action with some scale and alpha transformation. Only problem is the color change of background like this.
I'm open to both XML and Jetpack Compose way solutions. Please..
The solution
After lots of hours searching, I've found the perfect one;
https://github.com/2307vivek/BubblePager
My Solution
import android.graphics.Path
import android.view.MotionEvent
import androidx.annotation.FloatRange
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.hypot
#Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: #Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label = "Circular reveal")
transition.CircularReveal(modifier, animationSpec, content = content)
}
#Composable
fun <T> Transition<T>.CircularReveal(
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: #Composable (targetState: T) -> Unit,
) {
var offset: Offset? by remember { mutableStateOf(null) }
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap = remember {
mutableMapOf<T, #Composable () -> Unit>()
}
if (currentState == targetState) {
// If not animating, just display the current state
if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
}
if (!contentMap.contains(targetState)) {
// Replace target with the same key if any
val replacementId = currentlyVisible.indexOfFirst {
it == targetState
}
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.forEach { stateForContent ->
contentMap[stateForContent] = {
val progress by animateFloat(
label = "Progress",
transitionSpec = { animationSpec }
) {
val targetedContent = stateForContent != currentlyVisible.last() || it == stateForContent
if (targetedContent) 1f else 0f
}
Box(Modifier.circularReveal(progress = progress, offset = offset)) {
content(stateForContent)
}
}
}
}
Box(
modifier = modifier.pointerInteropFilter {
if (it.action == MotionEvent.ACTION_DOWN) {
if (!started) offset = Offset(it.x, it.y)
}
started
}
) {
currentlyVisible.forEach {
key(it) {
contentMap[it]?.invoke()
}
}
}
}
private val <T> Transition<T>.started get() =
currentState != targetState || isRunning
fun Modifier.circularReveal(
#FloatRange(from = 0.0, to = 1.0) progress: Float,
offset: Offset? = null,
) = clip(CircularRevealShape(progress, offset))
class CircularRevealShape(
#FloatRange(from = 0.0, to = 1.0) private val progress: Float,
private val offset: Offset? = null,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
return Outline.Generic(Path().apply {
addCircle(
offset?.x ?: (size.width / 2f),
offset?.y ?: (size.height / 2f),
longestDistanceToACorner(size, offset) * progress,
Path.Direction.CW
)
}.asComposePath())
}
private fun longestDistanceToACorner(size: Size, offset: Offset?): Float {
if (offset == null) {
return hypot(size.width / 2f, size.height / 2f)
}
val topLeft = hypot(offset.x, offset.y)
val topRight = hypot(size.width - offset.x, offset.y)
val bottomLeft = hypot(offset.x, size.height - offset.y)
val bottomRight = hypot(size.width - offset.x, size.height - offset.y)
return topLeft.coerceAtLeast(topRight).coerceAtLeast(bottomLeft).coerceAtLeast(bottomRight)
}
}
#Preview
#Composable
fun CircularRevealAnimationPreview() {
val isSystemDark = isSystemInDarkTheme()
var darkTheme by remember { mutableStateOf(isSystemDark) }
val onThemeToggle = { darkTheme = !darkTheme }
CircularReveal(
targetState = darkTheme,
animationSpec = tween(1500)
) { isDark ->
MyAppTheme(darkTheme = isDark) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background,
onClick = onThemeToggle
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
modifier = Modifier.size(120.dp),
imageVector = if (isDark) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = "Toggle",
)
}
}
}
}
}

Marquee Text Effect in Jetpack Compose

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())

Categories

Resources