Skip to content

Instantly share code, notes, and snippets.

@ChathuraHettiarachchi
Last active April 15, 2024 05:36
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ChathuraHettiarachchi/27ac6429091c0464888b5fbd995ef4ac to your computer and use it in GitHub Desktop.
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
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