Last active
April 22, 2025 16:58
-
-
Save ChathuraHettiarachchi/27ac6429091c0464888b5fbd995ef4ac to your computer and use it in GitHub Desktop.
This is a sample implementation of AirBnB search bar transition on Android usin Jetpack Compose MotionLayout, MotionScene with DSL. You need to replace the images on `destinations` to work this. FInd the video link https://twitter.com/i/status/1778640663086829622
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.chootadev.composetryout | |
import androidx.annotation.DrawableRes | |
import androidx.compose.animation.core.animateFloatAsState | |
import androidx.compose.animation.core.keyframes | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxHeight | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.layout.wrapContentHeight | |
import androidx.compose.foundation.lazy.LazyRow | |
import androidx.compose.foundation.lazy.items | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material.icons.Icons | |
import androidx.compose.material.icons.filled.Close | |
import androidx.compose.material.icons.filled.Search | |
import androidx.compose.material3.Button | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.draw.drawBehind | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.drawscope.DrawScope | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.layout.layoutId | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.font.FontStyle | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.tooling.preview.Devices | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.TextUnit | |
import androidx.compose.ui.unit.TextUnitType | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.constraintlayout.compose.ConstraintLayout | |
import androidx.constraintlayout.compose.Dimension | |
import androidx.constraintlayout.compose.MotionLayout | |
import androidx.constraintlayout.compose.MotionScene | |
@Preview(showBackground = true, device = Devices.PIXEL_6_PRO) | |
@Composable | |
fun MotionLayoutCollapsed() { | |
MotionLayoutTryV3(false) | |
} | |
@Preview(showBackground = true, device = Devices.PIXEL_6_PRO) | |
@Composable | |
fun MotionLayoutExpanded() { | |
MotionLayoutTryV3(true) | |
} | |
@Composable | |
fun MotionLayoutTryV3(isExpanded: Boolean) { | |
var expanded by remember { mutableStateOf(isExpanded) } | |
val progress by animateFloatAsState( | |
targetValue = if (expanded) 1f else 0f, | |
animationSpec = tween(1000), label = "Expand Animation" | |
) | |
var cornerRadiusState by remember { | |
mutableStateOf(50f) | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color(0xFFECECEC)) | |
.padding(16.dp) | |
) { | |
MotionLayout( | |
modifier = Modifier | |
.fillMaxSize() | |
.clickable { expanded = !expanded }, | |
motionScene = AirBnbMotionScene(), | |
progress = progress | |
) { | |
val radiusCustomProperty = customProperties(id = "card").float("corner") | |
radiusCustomProperty.let { cornerRadiusState = it } | |
val whereToFontProperty = customProperties(id = "txtWhereTo").float("fontSize") | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.layoutId("card") | |
.height(80.dp), | |
shape = RoundedCornerShape(cornerRadiusState.dp), | |
colors = CardDefaults.cardColors(containerColor = Color.White), | |
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) | |
) {} | |
Icon( | |
imageVector = Icons.Default.Search, | |
contentDescription = "Favorite", | |
modifier = Modifier | |
.size(34.dp) | |
.layoutId("searchIcon"), | |
tint = Color.Black | |
) | |
Text( | |
text = "Where to?", | |
modifier = Modifier.layoutId("txtWhereTo"), | |
fontSize = whereToFontProperty.sp, | |
fontWeight = if (expanded) FontWeight.Bold else FontWeight.Normal | |
) | |
Text( | |
text = "Any where . Any week . Add guests", | |
modifier = Modifier.layoutId("txtInfo"), | |
fontSize = 14.sp, | |
color = Color.Gray | |
) | |
Text( | |
text = "Search destinations", | |
modifier = Modifier.layoutId("txtSearchDestination"), | |
fontSize = 14.sp, | |
color = Color.Gray | |
) | |
Box(modifier = Modifier | |
.fillMaxWidth() | |
.layoutId("searchBar") | |
.height(60.dp) | |
.clip(RoundedCornerShape(15.dp)) | |
.border( | |
width = 1.dp, | |
color = Color.LightGray, | |
shape = RoundedCornerShape(15.dp) | |
)) | |
LazyRow (modifier = Modifier.layoutId("destinationList"), contentPadding = PaddingValues(horizontal = 24.dp)) { | |
items(destinations){ item -> | |
Destination(destination = item) | |
} | |
} | |
TopActions() | |
Row(modifier = Modifier.layoutId("bottomActions").padding(16.dp).background(Color.White)){ | |
BottomActions() | |
} | |
OptionRow(layoutId = "whenRow") | |
OptionRow(leftText = "Who", rightText = "Add guests", layoutId = "whoRow") | |
} | |
} | |
} | |
@Composable | |
fun AirBnbMotionScene(): MotionScene { | |
return MotionScene { | |
val (card, searchIcon, txtWhereTo, topActions, info, searchBar, txtSearchDestination, destinationList, bottomActions, whenRow, whoRow) = createRefsFor( | |
"card", | |
"searchIcon", | |
"txtWhereTo", | |
"topActions", | |
"txtInfo", | |
"searchBar", | |
"txtSearchDestination", | |
"destinationList", | |
"bottomActions", | |
"whenRow", | |
"whoRow" | |
) | |
val start1 = constraintSet { | |
constrain(topActions) { | |
height = Dimension.value(0.dp) | |
start.linkTo(parent.start) | |
end.linkTo(parent.end) | |
top.linkTo(parent.top) | |
alpha = 0f | |
} | |
constrain(card) { | |
height = Dimension.value(80.dp) | |
width = Dimension.matchParent | |
start.linkTo(parent.start, 16.dp) | |
end.linkTo(parent.end, 16.dp) | |
top.linkTo(topActions.bottom) | |
customFloat("corner", 50f) | |
} | |
constrain(searchIcon) { | |
start.linkTo(card.start, 32.dp) | |
top.linkTo(card.top, 16.dp) | |
bottom.linkTo(card.bottom, 16.dp) | |
} | |
constrain(txtWhereTo) { | |
start.linkTo(searchIcon.end, 16.dp) | |
top.linkTo(parent.top, 14.dp) | |
customFontSize("fontSize", 20.sp) | |
} | |
constrain(info) { | |
start.linkTo(searchIcon.end, 16.dp) | |
top.linkTo(txtWhereTo.bottom, 4.dp) | |
alpha = 1f | |
} | |
constrain(searchBar) { | |
alpha = 0f | |
} | |
constrain(txtSearchDestination) { | |
alpha = 0f | |
} | |
constrain(destinationList) { | |
alpha = 0f | |
} | |
constrain(bottomActions) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-40).dp) | |
end.linkTo(parent.end, (-40).dp) | |
bottom.linkTo(parent.bottom, (-180).dp) | |
} | |
constrain(whenRow) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-16).dp) | |
end.linkTo(parent.end, (-16).dp) | |
top.linkTo(card.bottom, 0.dp) | |
alpha = 0f | |
} | |
constrain(whoRow) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-16).dp) | |
end.linkTo(parent.end, (-16).dp) | |
top.linkTo(whenRow.bottom, (-16).dp) | |
alpha = 0f | |
} | |
} | |
val end1 = constraintSet { | |
constrain(topActions) { | |
height = Dimension.wrapContent | |
start.linkTo(parent.start) | |
end.linkTo(parent.end) | |
top.linkTo(parent.top) | |
alpha = 1f | |
} | |
constrain(card) { | |
height = Dimension.value(380.dp) | |
width = Dimension.matchParent | |
start.linkTo(parent.start, 0.dp) | |
end.linkTo(parent.end, 0.dp) | |
top.linkTo(topActions.bottom, 8.dp) | |
customFloat("corner", 16f) | |
} | |
constrain(searchIcon) { | |
start.linkTo(card.start, 36.dp) | |
top.linkTo(searchBar.top, 12.dp) | |
} | |
constrain(txtWhereTo) { | |
start.linkTo(card.start, 24.dp) | |
top.linkTo(card.top, 24.dp) | |
customFontSize("fontSize", 28.sp) | |
} | |
constrain(info) { | |
start.linkTo(searchIcon.end, 16.dp) | |
top.linkTo(txtWhereTo.bottom, 4.dp) | |
alpha = 0f | |
} | |
constrain(searchBar) { | |
width = Dimension.matchParent | |
start.linkTo(card.start, 24.dp) | |
end.linkTo(card.end, 24.dp) | |
top.linkTo(txtWhereTo.top,60.dp) | |
} | |
constrain(txtSearchDestination) { | |
start.linkTo(searchIcon.end, 8.dp) | |
top.linkTo(searchIcon.top,8.dp) | |
} | |
constrain(destinationList) { | |
width = Dimension.matchParent | |
end.linkTo(card.end, 0.dp) | |
top.linkTo(searchBar.bottom, 24.dp) | |
} | |
constrain(bottomActions) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-40).dp) | |
end.linkTo(parent.end, (-40).dp) | |
bottom.linkTo(parent.bottom, (-32).dp) | |
} | |
constrain(whenRow) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-16).dp) | |
end.linkTo(parent.end, (-16).dp) | |
top.linkTo(card.bottom, 0.dp) | |
alpha = 1f | |
} | |
constrain(whoRow) { | |
width = Dimension.matchParent | |
start.linkTo(parent.start, (-16).dp) | |
end.linkTo(parent.end, (-16).dp) | |
top.linkTo(whenRow.bottom, (-16).dp) | |
alpha = 1f | |
} | |
} | |
transition(start1, end1, "default") { | |
keyAttributes(topActions) { | |
frame(50) { | |
alpha = 0f | |
} | |
frame(100) { | |
alpha = 1f | |
} | |
} | |
keyAttributes(info) { | |
frame(25) { | |
alpha = 0f | |
} | |
} | |
keyAttributes(searchBar) { | |
frame(50) { | |
alpha = 0f | |
} | |
frame(100) { | |
alpha = 1f | |
} | |
} | |
keyAttributes(destinationList) { | |
frame(75) { | |
alpha = 0f | |
} | |
frame(100) { | |
alpha = 1f | |
} | |
} | |
keyAttributes(whoRow) { | |
frame(75) { | |
alpha = 0f | |
} | |
frame(100) { | |
alpha = 1f | |
} | |
} | |
keyAttributes(whenRow) { | |
frame(75) { | |
alpha = 0f | |
} | |
frame(100) { | |
alpha = 1f | |
} | |
} | |
} | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun Destination( | |
destination: Destination = destinations[0], | |
isSelected: Boolean = destination.selected | |
) { | |
Column( | |
modifier = Modifier | |
.width(160.dp) | |
.padding(end = 16.dp) | |
) { | |
Box( | |
modifier = Modifier | |
.size(160.dp) | |
.clip(RoundedCornerShape(15.dp)) | |
.border( | |
width = if (isSelected) 2.dp else 1.dp, | |
color = if (isSelected) Color.Black else Color.LightGray, | |
shape = RoundedCornerShape(15.dp) | |
) | |
) { | |
Image( | |
modifier = Modifier.fillMaxSize(), | |
painter = painterResource(id = destination.image), | |
contentDescription = "Destination option ${destination.name}", | |
contentScale = ContentScale.Crop | |
) | |
} | |
Spacer(modifier = Modifier.height(4.dp)) | |
Text(text = destination.name, fontSize = 14.sp) | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun OptionRow(leftText: String = "When", rightText: String = "Any week", layoutId: String = "") { | |
Box( | |
modifier = Modifier | |
.padding(16.dp) | |
.layoutId(layoutId) | |
) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(80.dp), | |
colors = CardDefaults.cardColors(containerColor = Color.White), | |
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(start = 24.dp, end = 24.dp), | |
horizontalArrangement = Arrangement.SpaceBetween, | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text(leftText, color = Color.Gray, fontSize = 14.sp) | |
Text(rightText, fontSize = 16.sp) | |
} | |
} | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun BottomActions( | |
layoutId: String = "bottomActions-1", | |
onClickClear: () -> Unit = {}, | |
onClickSearch: () -> Unit = {} | |
) { | |
Row( | |
modifier = Modifier | |
.padding(top = 16.dp, bottom = 16.dp, start = 24.dp, end = 24.dp) | |
.fillMaxWidth() | |
.height(80.dp) | |
.layoutId(layoutId) | |
.background(Color.White), | |
horizontalArrangement = Arrangement.SpaceBetween, | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text(modifier = Modifier.drawBehind { | |
drawUnderLine() | |
}, text = "Clear all", color = Color.Black, fontSize = 14.sp) | |
Button( | |
modifier = Modifier.height(50.dp), | |
onClick = onClickClear, | |
shape = RoundedCornerShape(12.dp), | |
colors = ButtonDefaults.buttonColors( | |
containerColor = Color( | |
0xFFCF2E2E | |
) | |
) | |
) { | |
Row { | |
Icon( | |
imageVector = Icons.Default.Search, | |
contentDescription = "Favorite", | |
modifier = Modifier.size(24.dp) | |
) | |
} | |
Text(text = "Search") | |
} | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun TopActions(layoutId: String = "topActions", onClose: () -> Unit = {}, selectedAction: Int = 0) { | |
Row( | |
modifier = Modifier | |
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp) | |
.fillMaxWidth() | |
.layoutId(layoutId) | |
) { | |
ConstraintLayout( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentHeight() | |
) { | |
val (close, options) = createRefs() | |
Button( | |
onClick = { }, | |
shape = CircleShape, | |
contentPadding = PaddingValues(1.dp), | |
elevation = ButtonDefaults.buttonElevation(defaultElevation = 12.dp), | |
colors = ButtonDefaults.buttonColors(containerColor = Color.White), | |
modifier = Modifier | |
.size(40.dp) | |
.constrainAs(close) { | |
start.linkTo(parent.start) | |
} | |
) { | |
Icon( | |
imageVector = Icons.Default.Close, | |
contentDescription = "Search close", | |
modifier = Modifier.size(20.dp), | |
tint = Color.Black | |
) | |
} | |
Row( | |
modifier = Modifier | |
.constrainAs(options) { | |
centerHorizontallyTo(parent) | |
centerVerticallyTo(parent) | |
}, | |
horizontalArrangement = Arrangement.spacedBy(16.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text( | |
modifier = Modifier.drawBehind { | |
if (selectedAction == 0) drawUnderLine() | |
}, | |
text = "Stays", | |
color = if (selectedAction == 0) Color.Black else Color.LightGray, | |
fontSize = 16.sp | |
) | |
Text( | |
modifier = Modifier.drawBehind { | |
if (selectedAction == 1) drawUnderLine() | |
}, | |
text = "Experiences", | |
color = if (selectedAction == 1) Color.Black else Color.Gray, | |
fontSize = 16.sp | |
) | |
} | |
} | |
} | |
} | |
private fun DrawScope.drawUnderLine() { | |
val strokeWidthPx = 1.dp.toPx() | |
val verticalOffset = size.height - 1.sp.toPx() | |
drawLine( | |
color = Color.Black, | |
strokeWidth = strokeWidthPx, | |
start = Offset(0f, verticalOffset), | |
end = Offset(size.width, verticalOffset) | |
) | |
} | |
data class Destination(val name: String, @DrawableRes val image: Int, val selected: Boolean = false) | |
private val destinations = listOf( | |
Destination("I'm flexible", R.drawable.any_001, true), | |
Destination("Europe", R.drawable.europe_002), | |
Destination("Australia", R.drawable.aus_003), | |
Destination("South Asia", R.drawable.asia_004), | |
Destination("United Kingdom", R.drawable.uk_005), | |
Destination("United States", R.drawable.usa_006), | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment