I want to build this awesome button animation pressed from the AirBnB App with Jetpack Compose
Unfortunately, the Animation/Transition API was changed recently and there's almost no documentation for it. Can someone help me get the right approach to implement this button press animation?
Edit
Based on #Amirhosein answer I have developed a button that looks almost exactly like the Airbnb example
Code:
#Composable
fun AnimatedButton() {
val boxHeight = animatedFloat(initVal = 50f)
val relBoxWidth = animatedFloat(initVal = 1.0f)
val fontSize = animatedFloat(initVal = 16f)
fun animateDimensions() {
boxHeight.animateTo(45f)
relBoxWidth.animateTo(0.95f)
// fontSize.animateTo(14f)
}
fun reverseAnimation() {
boxHeight.animateTo(50f)
relBoxWidth.animateTo(1.0f)
//fontSize.animateTo(16f)
}
Box(
modifier = Modifier
.height(boxHeight.value.dp)
.fillMaxWidth(fraction = relBoxWidth.value)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black)
.clickable { }
.pressIndicatorGestureFilter(
onStart = {
animateDimensions()
},
onStop = {
reverseAnimation()
},
onCancel = {
reverseAnimation()
}
),
contentAlignment = Alignment.Center
) {
Text(text = "Explore Airbnb", fontSize = fontSize.value.sp, color = Color.White)
}
}
Video:
Unfortunately, I cannot figure out how to animate the text correctly as It looks very bad currently
Are you looking for something like this?
#Composable
fun AnimatedButton() {
val selected = remember { mutableStateOf(false) }
val scale = animateFloatAsState(if (selected.value) 2f else 1f)
Column(
Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = { },
modifier = Modifier
.scale(scale.value)
.height(40.dp)
.width(200.dp)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
selected.value = true }
MotionEvent.ACTION_UP -> {
selected.value = false }
}
true
}
) {
Text(text = "Explore Airbnb", fontSize = 15.sp, color = Color.White)
}
}
}
Here's the implementation I used in my project. Seems most concise to me.
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val sizeScale by animateFloatAsState(if (isPressed) 0.5f else 1f)
Button(
onClick = { },
modifier = Modifier
.wrapContentSize()
.graphicsLayer(
scaleX = sizeScale,
scaleY = sizeScale
),
interactionSource = interactionSource
) { Text(text = "Open the reward") }
Use pressIndicatorGestureFilter to achieve this behavior.
Here is my workaround:
#Preview
#Composable
fun MyFancyButton() {
val boxHeight = animatedFloat(initVal = 60f)
val boxWidth = animatedFloat(initVal = 200f)
Box(modifier = Modifier
.height(boxHeight.value.dp)
.width(boxWidth.value.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black)
.clickable { }
.pressIndicatorGestureFilter(
onStart = {
boxHeight.animateTo(55f)
boxWidth.animateTo(180f)
},
onStop = {
boxHeight.animateTo(60f)
boxWidth.animateTo(200f)
},
onCancel = {
boxHeight.animateTo(60f)
boxWidth.animateTo(200f)
}
), contentAlignment = Alignment.Center) {
Text(text = "Utforska Airbnb", color = Color.White)
}
}
The default jetpack compose Button consumes tap gestures in its onClick event and pressIndicatorGestureFilter doesn't receive taps. That's why I created this custom button
You can use the Modifier.pointerInput to detect the tapGesture.
Define an enum:
enum class ComponentState { Pressed, Released }
Then:
var toState by remember { mutableStateOf(ComponentState.Released) }
val modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
toState = ComponentState.Pressed
tryAwaitRelease()
toState = ComponentState.Released
}
)
}
// Defines a transition of `ComponentState`, and updates the transition when the provided [targetState] changes
val transition: Transition<ComponentState> = updateTransition(targetState = toState, label = "")
// Defines a float animation to scale x,y
val scalex: Float by transition.animateFloat(
transitionSpec = { spring(stiffness = 50f) }, label = ""
) { state ->
if (state == ComponentState.Pressed) 1.25f else 1f
}
val scaley: Float by transition.animateFloat(
transitionSpec = { spring(stiffness = 50f) }, label = ""
) { state ->
if (state == ComponentState.Pressed) 1.05f else 1f
}
Apply the modifier and use the Modifier.graphicsLayer to change also the text dimension.
Box(
modifier
.padding(16.dp)
.width((100 * scalex).dp)
.height((50 * scaley).dp)
.background(Color.Black, shape = RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center) {
Text("BUTTON", color = Color.White,
modifier = Modifier.graphicsLayer{
scaleX = scalex;
scaleY = scaley
})
}
Here is the ScalingButton, the onClick callback is fired when users click the button and state is reset when users move their finger out of the button area after pressing the button and not releasing it. I'm using Modifier.pointerInput function to detect user inputs:
#Composable
fun ScalingButton(onClick: () -> Unit, content: #Composable RowScope.() -> Unit) {
var selected by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (selected) 0.7f else 1f)
Button(
onClick = onClick,
modifier = Modifier
.scale(scale)
.pointerInput(Unit) {
while (true) {
awaitPointerEventScope {
awaitFirstDown(false)
selected = true
waitForUpOrCancellation()
selected = false
}
}
}
) {
content()
}
}
OR
Another approach without using an infinite loop:
#Composable
fun ScalingButton(onClick: () -> Unit, content: #Composable RowScope.() -> Unit) {
var selected by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (selected) 0.75f else 1f)
Button(
onClick = onClick,
modifier = Modifier
.scale(scale)
.pointerInput(selected) {
awaitPointerEventScope {
selected = if (selected) {
waitForUpOrCancellation()
false
} else {
awaitFirstDown(false)
true
}
}
}
) {
content()
}
}
If you want to animated button with different types of animation like scaling, rotating and many different kind of animation then you can use this library in jetpack compose. Check Here
Related
I want to make a button like this in Compose:
https://pub.dev/packages/flutter_bounceable
But the clickable method is not work in my code.
I tried with this code, but it has an error.
Pushing the button, but there's no action.
Animations are working well, but not for the clickable.
fun Modifier.bounceClick(onClick: () -> Unit,animationDuration: Int = 100,
scaleDown: Float = 0.9f) = composed {
val interactionSource = MutableInteractionSource()
val coroutineScope = rememberCoroutineScope()
val scale = remember {
Animatable(1f)
}
this
.scale(scale = scale.value)
.background(
color = Color(0xFF35898F),
shape = RoundedCornerShape(size = 12f)
)
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
.pointerInput(Unit) {
while(true)
awaitPointerEventScope {
awaitFirstDown()
coroutineScope.launch {
scale.animateTo(
scaleDown,
animationSpec = tween(animationDuration),
)
}
waitForUpOrCancellation()
coroutineScope.launch {
scale.animateTo(
scaleDown,
animationSpec = tween(20),
)
scale.animateTo(
1f,
animationSpec = tween(animationDuration),
)
}
}
}
}
This is quite simple to do with Compose.
You should use foreachGesture or awaitEachGesture if Compose version is 1.4.0-alpha03 with Modifier.pointerInput instead of while. Also when you have clickable you don't need Modifier.pointerInput as well , you can use either of them.
I will only demonstrate how to do it with Modifier.clickable and interactionSource.collectIsPressedAsState() as below.
Result
Implementation
fun Modifier.bounceClick(
animationDuration: Int = 100,
scaleDown: Float = 0.9f,
onClick: () -> Unit
) = composed {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val animatable = remember {
Animatable(1f)
}
LaunchedEffect(key1 = isPressed) {
if (isPressed) {
animatable.animateTo(scaleDown)
} else animatable.animateTo(1f)
}
Modifier
.graphicsLayer {
val scale = animatable.value
scaleX = scale
scaleY = scale
}
.clickable(
interactionSource = interactionSource,
indication = null
) {
onClick()
}
}
Usage
#Composable
private fun BounceExample() {
Row {
Box(
Modifier
.background(Color.Red, RoundedCornerShape(10.dp))
.bounceClick {
}
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(text = "Hello World", color = Color.White, fontSize = 20.sp)
}
Spacer(modifier = Modifier.width(10.dp))
Box(
Modifier
.bounceClick {
}
.background(Color.Green, RoundedCornerShape(10.dp))
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(text = "Hello World", color = Color.White, fontSize = 20.sp)
}
}
}
I have a Compose component with a box and 2 components inside it, but they are never visible both at the same time. I want to adjust size of this box to the first component and stay unchanged when this component will be not visible.
Box(
modifier = modifier.then(Modifier.background(bgColor)),
contentAlignment = Alignment.Center
) {
if (componentVisible1) {
Button(
modifier = Modifier.height(48.dp).widthIn(144.dp),
onClick = onClicked,
enabled = enabled
) {
Text(
text = "text1",
)
}
}
if (component2Visible) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).background(buttonColor, CircleShape).padding(2.dp),
strokeWidth = 2.dp
)
}
}
Now, width of the box reduces when the component1 is not visible.
You can use SubcomposeLayout to pass a Composable's dimension if another one depends on it without recomposition or in your case even if it's not even in composition.
#Composable
internal fun DimensionSubcomposeLayout(
modifier: Modifier = Modifier,
mainContent: #Composable () -> Unit,
dependentContent: #Composable (DpSize) -> Unit
) {
val density = LocalDensity.current
SubcomposeLayout(
modifier = modifier
) { constraints: Constraints ->
// Subcompose(compose only a section) main content and get Placeable
val mainPlaceable: Placeable = subcompose(SlotsEnum.Main, mainContent)
.map {
it.measure(constraints.copy(minWidth = 0, minHeight = 0))
}.first()
val dependentPlaceable: Placeable =
subcompose(SlotsEnum.Dependent) {
dependentContent(
DpSize(
density.run { mainPlaceable.width.toDp() },
density.run { mainPlaceable.height.toDp() }
)
)
}
.map { measurable: Measurable ->
measurable.measure(constraints)
}.first()
layout(mainPlaceable.width, mainPlaceable.height) {
dependentPlaceable.placeRelative(0, 0)
}
}
}
/**
* Enum class for SubcomposeLayouts with main and dependent Composables
*/
enum class SlotsEnum { Main, Dependent }
And you can use it to set dimensions of Box based on measuring or subcomposing your Button.
#Composable
private fun DimensionSample() {
var componentVisible1 by remember { mutableStateOf(false) }
var component2Visible by remember { mutableStateOf(true) }
Column {
Button(onClick = {
componentVisible1 = !componentVisible1
component2Visible = !component2Visible
}) {
Text(text = "Toggle")
}
val button = #Composable {
Button(
modifier = Modifier
.height(48.dp)
.widthIn(144.dp),
onClick = {}
) {
Text(
text = "text1",
)
}
}
val circularProgressIndicator = #Composable {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.background(Color.Yellow, CircleShape)
.padding(2.dp),
strokeWidth = 2.dp
)
}
DimensionSubcomposeLayout(mainContent = {
button()
}) {
Box(
modifier = Modifier
.size(it)
.then(Modifier.background(Color.Red)),
contentAlignment = Alignment.Center
) {
if (componentVisible1) {
button()
}
if (component2Visible) {
circularProgressIndicator()
}
}
}
}
}
Here is a function is which I create 2 different animationAsState function.
#Composable
fun AnimateAsStateDemo1() {
var boxState by remember {
mutableStateOf(false)
}
val color by animateColorAsState(
targetValue = if (boxState) Color.Blue else Color.Red,
)
val size by animateDpAsState(targetValue = if (boxState) 100.dp else 50.dp)
Box(
modifier = Modifier
.size(size = size)
.background(color = color)
.clickable {
boxState = !boxState
}
)
}
Here I create a single updateTransition transition to achieve the same result.
#Composable
fun AnimateAsStateDemo2() {
var boxState by remember {
mutableStateOf(false)
}
val transition = updateTransition(targetState = boxState, label = "")
val color by transition.animateColor(label = "") { state ->
when (state) {
true -> Color.Blue
false -> Color.Red
}
}
val size by transition.animateDp(label = "") { state ->
when (state) {
true -> 100.dp
false -> 50.dp
}
}
Box(
modifier = Modifier
.size(size = size)
.background(color = color)
.clickable {
boxState = !boxState
}
)
}
They both achieve the same result.
Is the second approach superior to the first approach? If so why?
I have an existing app where I have implemented FlipCard animation like below using Objectanimator in XML. If I click on a card it flips horizontally. But now I want to migrate it to jetpack compose. So is it possible to make flip card animation in jetpack compose?
Update
Finally, I have ended up with this. Though I don't know if it is the right way or not but I got exactly what I wanted. If there is any better alternative you can suggest. Thank you.
Method 1: Using animate*AsState
#Composable
fun FlipCard() {
var rotated by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (rotated) 180f else 0f,
animationSpec = tween(500)
)
val animateFront by animateFloatAsState(
targetValue = if (!rotated) 1f else 0f,
animationSpec = tween(500)
)
val animateBack by animateFloatAsState(
targetValue = if (rotated) 1f else 0f,
animationSpec = tween(500)
)
val animateColor by animateColorAsState(
targetValue = if (rotated) Color.Red else Color.Blue,
animationSpec = tween(500)
)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
Modifier
.fillMaxSize(.5f)
.graphicsLayer {
rotationY = rotation
cameraDistance = 8 * density
}
.clickable {
rotated = !rotated
},
backgroundColor = animateColor
)
{
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = if (rotated) "Back" else "Front",
modifier = Modifier
.graphicsLayer {
alpha = if (rotated) animateBack else animateFront
rotationY = rotation
})
}
}
}
}
Method 2: Encapsulate a Transition and make it reusable.
You will get the same output as method 1. But it is reusable and for the complex case.
enum class BoxState { Front, Back }
#Composable
fun AnimatingBox(
rotated: Boolean,
onRotate: (Boolean) -> Unit
) {
val transitionData = updateTransitionData(
if (rotated) BoxState.Back else BoxState.Front
)
Card(
Modifier
.fillMaxSize(.5f)
.graphicsLayer {
rotationY = transitionData.rotation
cameraDistance = 8 * density
}
.clickable { onRotate(!rotated) },
backgroundColor = transitionData.color
)
{
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = if (rotated) "Back" else "Front",
modifier = Modifier
.graphicsLayer {
alpha =
if (rotated) transitionData.animateBack else transitionData.animateFront
rotationY = transitionData.rotation
})
}
}
}
private class TransitionData(
color: State<Color>,
rotation: State<Float>,
animateFront: State<Float>,
animateBack: State<Float>
) {
val color by color
val rotation by rotation
val animateFront by animateFront
val animateBack by animateBack
}
#Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
val transition = updateTransition(boxState, label = "")
val color = transition.animateColor(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> Color.Blue
BoxState.Back -> Color.Red
}
}
val rotation = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 0f
BoxState.Back -> 180f
}
}
val animateFront = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 1f
BoxState.Back -> 0f
}
}
val animateBack = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 0f
BoxState.Back -> 1f
}
}
return remember(transition) { TransitionData(color, rotation, animateFront, animateBack) }
}
Output
setContent {
ComposeAnimationTheme {
Surface(color = MaterialTheme.colors.background) {
var state by remember {
mutableStateOf(CardFace.Front)
}
FlipCard(
cardFace = state,
onClick = {
state = it.next
},
axis = RotationAxis.AxisY,
back = {
Text(text = "Front", Modifier
.fillMaxSize()
.background(Color.Red))
},
front = {
Text(text = "Back", Modifier
.fillMaxSize()
.background(Color.Green))
}
)
}
}
}
enum class CardFace(val angle: Float) {
Front(0f) {
override val next: CardFace
get() = Back
},
Back(180f) {
override val next: CardFace
get() = Front
};
abstract val next: CardFace
}
enum class RotationAxis {
AxisX,
AxisY,
}
#ExperimentalMaterialApi
#Composable
fun FlipCard(
cardFace: CardFace,
onClick: (CardFace) -> Unit,
modifier: Modifier = Modifier,
axis: RotationAxis = RotationAxis.AxisY,
back: #Composable () -> Unit = {},
front: #Composable () -> Unit = {},
) {
val rotation = animateFloatAsState(
targetValue = cardFace.angle,
animationSpec = tween(
durationMillis = 400,
easing = FastOutSlowInEasing,
)
)
Card(
onClick = { onClick(cardFace) },
modifier = modifier
.graphicsLayer {
if (axis == RotationAxis.AxisX) {
rotationX = rotation.value
} else {
rotationY = rotation.value
}
cameraDistance = 12f * density
},
) {
if (rotation.value <= 90f) {
Box(
Modifier.fillMaxSize()
) {
front()
}
} else {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
if (axis == RotationAxis.AxisX) {
rotationX = 180f
} else {
rotationY = 180f
}
},
) {
back()
}
}
}
}
Check this article. https://fvilarino.medium.com/creating-a-rotating-card-in-jetpack-compose-ba94c7dd76fb.
I'm using the clickable modifier on Surface and want to create a custom indication to have the surface (along with its contents) appear with 0.5 alpha when being pressed. But it seems the indication can only be used to draw additional UI.
How do I redraw the Surface with 0.5 alpha when it is pressed?
Surface(
modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = CustomIndication, onClick = onClick)
) {
...
}
In the clickable modifier you can specify the Indication parameter. You can use the default ripple defined by rememberRipple changing the color.
Something like:
val interactionSource = remember { MutableInteractionSource() }
//clickable modifier
val clickable = Modifier.clickable(
interactionSource = interactionSource,
indication = rememberRipple(color = /* use you custom color */
MaterialTheme.colors.primary.copy(alpha = 0.5f))
) { /* update some business state here */ }
Surface(modifier = clickable){
//...
}
Otherwise you can use something like:
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val backgroundAlpha = if (isPressed) 0.5f else 1f
Surface(
modifier = Modifier.clickable(true){},
color= MaterialTheme.colors.secondary.copy(alpha = backgroundAlpha)
) {
//....
}
Try this:
var isPressed by remember { mutableStateOf(false) }
val backgroundAlpha = if (isPressed) 0.5f else 1f
Surface(
modifier = Modifier
.clickable { }
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
val success = tryAwaitRelease()
if (success) isPressed = false
else isPressed = true
}
)
},
color = MaterialTheme.colors.primary.copy(alpha = backgroundAlpha)
) {
...
}
#Composable
fun AlphaSurface(){
val isClicked = remember { mutableStateOf(false) }
val alphaValue = if(isClicked.value) 0.5f else 1f
Surface(
modifier = Modifier.clickable {
isClicked.value = isClicked.value.not() // toggle the value
}.fillMaxHeight().fillMaxWidth(),
color = MaterialTheme.colors.primary.copy(alpha = alphaValue )
) {
}
}