Zoltan Demant
03/04/2025, 2:50 PMAnimatedContent
to transition betwen active/idle states here, out of which both should be centered vertically. However.. if you look closely at the video, the animation dithers (I think thats the correct term) due to the sizes being different. This seems like such a simple problem to solve, and perhaps it is?Stylianos Gakis
03/04/2025, 3:03 PMZoltan Demant
03/04/2025, 3:08 PMTransitionContent(
modifier = modifier.debugBorder(),
targetState = active,
effect = // fadeIn + expandIn togetherWith fadeOut + shrinkOut
alignment = Alignment.Center,
content = { resting ->
if (resting) {
Pill(
color = Theme.palette.surfaceContainerHighest,
size = Large,
onClick = onOpen,
onLongClick = onStop,
fill =
rememberUpdatedFill(
color = if (expiring) Theme.palette.negative else Theme.palette.primaryContainer,
fraction = progress,
),
content = {
Alarm(icon, expiring)
Spacer(SpaceMedium)
label()
},
)
} else {
IconButton(
icon = icon,
onClick = onOpen,
onLongClick = onStart,
)
}
},
)
Under the hood its just AnimatedContent. I think the blue pill comes from outside of its bounds due to me using clip=false on the animation. It does look much better with it.Zoltan Demant
03/04/2025, 3:09 PMStylianos Gakis
03/04/2025, 3:22 PMZoltan Demant
03/04/2025, 3:23 PMZoltan Demant
03/04/2025, 3:24 PMStylianos Gakis
03/04/2025, 4:38 PMZoltan Demant
03/04/2025, 6:46 PMStylianos Gakis
03/04/2025, 9:07 PMZoltan Demant
03/05/2025, 7:23 AMZoltan Demant
03/05/2025, 8:23 AMTgo1014
03/05/2025, 10:36 AMZoltan Demant
03/05/2025, 10:41 AMTgo1014
03/05/2025, 10:42 AMZoltan Demant
03/05/2025, 10:48 AMTgo1014
03/05/2025, 10:50 AMZoltan Demant
03/05/2025, 10:51 AMTgo1014
03/05/2025, 10:51 AMvar isShowing by remember { mutableStateOf(false) }
val background by animateColorAsState(
if (isShowing) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
)
val shape = CircleShape
Row(
modifier = Modifier
.clip(shape)
.clickable { isShowing = !isShowing }
.background(background, shape = shape)
.padding(8.dp)
) {
Icon(Icons.Default.Build, contentDescription = null)
AnimatedVisibility(isShowing) {
Text("This is my long text")
}
}
Zoltan Demant
03/05/2025, 10:52 AMTgo1014
03/05/2025, 10:55 AMTgo1014
03/05/2025, 10:55 AMTgo1014
03/05/2025, 10:56 AMZoltan Demant
03/05/2025, 11:06 AMZoltan Demant
03/05/2025, 11:57 AMSharedTransitionLayout { Row { Pill() IconButton() } }
with icon using Modifier.sharedElement
so that it moves across.
Of course, the shaking issue is sitll present, just less noticeable because the vertical size no longer plays a role.
Now just gotta figure out why it still shakes + stop the icon from blinking during the shared element transition (this is the first time Ive really tried using them, so no clue what Im doing).Tgo1014
03/05/2025, 11:58 AMStylianos Gakis
03/05/2025, 12:07 PMZoltan Demant
03/05/2025, 12:21 PMsharedBounds(state, scope, enter = EnterTransition.None, exit = ExitTransition.None)
I dont know if thats correct, but it seems to work.Stylianos Gakis
03/05/2025, 12:27 PMModifier.skipToLookaheadSize()
. I'd give that a try tooZoltan Demant
03/05/2025, 12:30 PMStylianos Gakis
03/05/2025, 12:32 PMshared*
modifier. In your case perhaps just chain it directly after on the card/iconbutton itself?
I am not sure either, I'd play around with it.
Shared element shouldn't be using a fade by default, shared bounds does however since the content is not meant to be the exact same visually, so it's a sane default. Are you sure there is a fade in the default shared element overload you're using?Zoltan Demant
03/05/2025, 12:55 PMStylianos Gakis
03/05/2025, 12:58 PMZoltan Demant
03/05/2025, 1:12 PMStylianos Gakis
03/05/2025, 1:15 PMZoltan Demant
03/05/2025, 1:28 PMZoltan Demant
03/05/2025, 2:01 PMZoltan Demant
03/05/2025, 2:05 PMZoltan Demant
03/05/2025, 2:30 PMcontext(SharedTransitionScope)
@Composable
private fun Modifier.sharedIcon(
scope: AnimatedVisibilityScope,
state: SharedContentState = rememberSharedContentState("icon"),
) = sharedBounds(
sharedContentState = state,
animatedVisibilityScope = scope,
enter = EnterTransition.None,
exit = ExitTransition.None,
)
Gives me a much better effect, because I do want other composables to fade, just not specifically the shared element (icon). Ive tried to put Modifier.animateEnterExit in various places, but I think thats problematic due to me wanting to animate the pill itself (which would then fade the icon). If anyone knows a better way etc, please teach me!Stylianos Gakis
03/05/2025, 2:36 PMsharedX
modifiers on each thing individually?
To me it sounds like the AnimatedContent wraps everything
The card itself can have a sharedBounds
The icon itself can have sharedElement
And that should make it so that the icon does not fade. Now I see you're doing sharedBounds
but then removing the transition instead of just doing sharedElement
on the icon directly. If you just do this replacement, what breaks?
Btw I am also not very experienced using these APIs, so it's also much trial and error while I am still learning to use them.Zoltan Demant
03/05/2025, 2:36 PMZoltan Demant
03/05/2025, 2:38 PMZoltan Demant
03/05/2025, 2:40 PMZoltan Demant
03/05/2025, 2:44 PMZoltan Demant
03/05/2025, 2:57 PM@Composable
fun Rest(
active: Boolean,
progress: Float,
expiring: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
onOpen: () -> Unit,
label: String,
modifier: Modifier = Modifier,
icon: Icon = Theme.icons.rest,
) {
SharedTransitionLayout(modifier) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Center,
content = {
TransitionContent(active, Crossfade, alignment = CenterEnd) { active ->
val iconModifier =
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState("icon"),
animatedVisibilityScope = this,
enter = EnterTransition.None,
exit = ExitTransition.None,
)
if (active) {
Active(
iconModifier = iconModifier,
icon = icon,
progress = progress,
expiring = expiring,
label = label,
onOpen = onOpen,
onStop = onStop,
)
} else {
Idle(
iconModifier = iconModifier,
icon = icon,
onOpen = onOpen,
onStart = onStart,
)
}
}
},
)
}
}
Stylianos Gakis
03/05/2025, 3:11 PMideally it should remain "2:00" until it has faded outI'd pass the state itself into the
AnimatedContent
entirely, instead of just if it should show or not. That way inside the lambda you'll get the state as it
which will still be what it was before it was flipped while the animation is playing. Which is why I advocated for AnimatedContent in the first place
It's what's described here https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedcontent where animatedContent gives you the power of still holding onto the "old" state so that the UI does not magically jump to the new state as it also animates out.Zoltan Demant
03/05/2025, 3:12 PMZoltan Demant
03/05/2025, 3:21 PMZoltan Demant
03/10/2025, 8:51 AMZoltan Demant
03/10/2025, 9:11 AMDoris Liu
03/10/2025, 11:40 PMSharedTransitionLayout
. If the SharedTransitionLayout
is being offseted by its parents, you may still see some strange movement patterns. It's worth logging the position of SharedTransitionLayout
to check if that's the culpritZoltan Demant
03/11/2025, 4:35 AMModifier.onGloballyPositioned
and `positionInRoot`:
โข The SharedTransitionLayout only moves horizontally (which makes sense, the pill is wider than just the icon).
โข However, if I do the same test on the icon itself, it moves 1 pixel vertically during the transition. This seems to be due to IconButton using Modifier.minimumInteractiveComponentSize
. But.. even if I ditch that and the icon doesnt move vertically, the shake is still there.
My animation sits inside a Toolbar with static height. Heres my "current" code for the animation itself.
SharedTransitionLayout(modifier) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Center,
content = {
TransitionContent( // AnimatedContent
targetState = state,
effect = /* fadeIn + expandHorizontally togetherWith fadeOut + shrinkHorizontally */
alignment = CenterEnd,
content = { current ->
val iconModifier =
Modifier.sharedBounds( // SharedBounds/SharedElement: Same behavior in terms of shakiness
animatedVisibilityScope = this,
sharedContentState = rememberSharedContentState("icon"),
enter = EnterTransition.None,
exit = ExitTransition.None,
)
when (current) {
is Active ->
Active(
iconModifier = iconModifier,
..
)
Inactive ->
Inactive(
iconModifier = iconModifier,
..
)
}
},
)
},
)
}
Doris Liu
03/11/2025, 5:23 PMZoltan Demant
03/12/2025, 8:43 AMprivate fun <T> spec() = tween<T>(5_000)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Bug() {
val expanded by produceState(false) {
while (isActive) {
delay(5_000 + 500)
value = !value
}
}
// Scaffold
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Center) {
// Toolbar / TopAppBar
Row(
modifier = Modifier.requiredHeight(60.dp).fillMaxWidth().background(Color.Gray),
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = {
Box(Modifier.weight(1f))
SharedTransitionLayout {
// The box doesnt seem to make a difference, same alignment as row, but including
// because my own code had it last time I ran it
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Center,
content = {
AnimatedContent(
targetState = expanded,
contentAlignment = Alignment.CenterEnd,
transitionSpec = {
fadeIn(spec()) +
expandHorizontally(
animationSpec = spec(),
clip = false,
) togetherWith fadeOut(animationSpec = spec()) +
shrinkHorizontally(
animationSpec = spec(),
clip = false,
) using SizeTransform(false) { _, _ -> spec() }
},
content = { expanded ->
val iconModifier =
Modifier.sharedBounds(
sharedContentState =
rememberSharedContentState(
"icon",
),
// None used so that the icon doesnt fade during animation
enter = EnterTransition.None,
exit = ExitTransition.None,
animatedVisibilityScope = this,
// This is just for debugging purposes
boundsTransform = { _, _ ->
spec()
},
)
if (expanded) {
Row(
modifier =
Modifier.background(Color.Red).padding(
horizontal = 8.dp,
vertical = 4.dp,
),
verticalAlignment = CenterVertically,
content = {
Icon(24.dp, modifier = iconModifier)
BasicText(
"1:23",
color = { Color.Cyan },
modifier = Modifier.padding(start = 4.dp),
)
},
)
} else {
IconButton(iconModifier)
}
},
)
},
)
}
// Just for sanity, there are other iconbuttons in the toolbar and they shoul match in size etc
repeat(3) {
IconButton(color = Magenta)
}
},
)
}
}
@Composable
private fun IconButton(
iconModifier: Modifier = Modifier,
color: Color = Blue,
) {
Box(Modifier.requiredSize(40.dp), contentAlignment = Center) {
Icon(color = color, modifier = iconModifier)
}
}
@Composable
private fun Icon(
size: Dp = 24.dp,
color: Color = Blue,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.requiredSize(size).clip(CircleShape).background(color),
)
}
Zoltan Demant
03/12/2025, 9:30 AM@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.Content(expanded: Boolean) {
val enter =
fadeIn(spec()) +
expandHorizontally(
animationSpec = spec(),
clip = false,
)
val exit =
fadeOut(animationSpec = spec()) +
shrinkHorizontally(
animationSpec = spec(),
clip = false,
)
Row(
modifier = Modifier.fillMaxHeight(),
// contentAlignment = Center,
verticalAlignment = CenterVertically,
content = {
AnimatedVisibility(!expanded, enter = enter, exit = exit, content = {
val iconModifier =
Modifier.sharedBounds(
sharedContentState =
rememberSharedContentState(
"icon",
),
// None used so that the icon doesnt fade during animation
enter = EnterTransition.None,
exit = ExitTransition.None,
animatedVisibilityScope = this,
// This is just for debugging purposes
boundsTransform = { _, _ ->
spec()
},
)
Icon(modifier = iconModifier)
})
AnimatedVisibility(
expanded,
enter = enter,
exit = exit,
) {
val iconModifier =
Modifier.sharedBounds(
sharedContentState =
rememberSharedContentState(
"icon",
),
// None used so that the icon doesnt fade during animation
enter = EnterTransition.None,
exit = ExitTransition.None,
animatedVisibilityScope = this,
// This is just for debugging purposes
boundsTransform = { _, _ ->
spec()
},
)
Row(
modifier =
Modifier.background(Color.Red).padding(
horizontal = 8.dp,
vertical = 4.dp,
),
verticalAlignment = CenterVertically,
content = {
Icon(modifier = iconModifier)
BasicText(
"1:23",
color = { Color.Cyan },
modifier = Modifier.padding(start = 4.dp),
)
},
)
}
},
)
}
Doris Liu
03/12/2025, 6:19 PMDoris Liu
03/12/2025, 6:28 PMDoris Liu
03/12/2025, 7:59 PMZoltan Demant
03/13/2025, 10:02 AMcompose-multiplatform = "1.7.3"
Doris Liu
03/13/2025, 6:25 PMZoltan Demant
03/14/2025, 11:37 AMcompose = "1.8.0-alpha04"
just using the repro sample. Maybe you were using a more recent 1.8 release than I am?Zoltan Demant
03/14/2025, 12:29 PMDoris Liu
03/14/2025, 4:35 PMZoltan Demant
03/14/2025, 5:40 PMDoris Liu
03/14/2025, 8:28 PMDoris Liu
03/14/2025, 10:43 PMDoris Liu
03/14/2025, 10:43 PM