Jetpack Compose: How to create multi-item FAB with animation? - android

I'm trying to create a multi-item floating action button with the following animation:
I created a multi-item floating action button but I could not implement the intended animation.
I have FilterFabMenuButton composable that I show as a menu item :
FilterFabMenuButton
#Composable
fun FilterFabMenuButton(
item: FilterFabMenuItem,
onClick: (FilterFabMenuItem) -> Unit,
modifier: Modifier = Modifier
) {
FloatingActionButton(
modifier = modifier,
onClick = {
onClick(item)
},
backgroundColor = colorResource(
id = R.color.primary_color
)
) {
Icon(
painter = painterResource(item.icon), contentDescription = null, tint = colorResource(
id = R.color.white
)
)
}
}
I have FilterFabMenuLabel composable which is a label for FilterFabMenuButton:
FilterFabMenuLabel
#Composable
fun FilterFabMenuLabel(
label: String,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = Color.Black.copy(alpha = 0.8f)
) {
Text(
text = label, color = Color.White,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp),
fontSize = 14.sp,
maxLines = 1
)
}
}
I have FilterFabMenuItem composable which is a Row that contains FilterFabMenuLabel and FilterFabMenuButton composables:
FilterFabMenuItem
#Composable
fun FilterFabMenuItem(
menuItem: FilterFabMenuItem,
onMenuItemClick: (FilterFabMenuItem) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
//label
FilterFabMenuLabel(label = menuItem.label)
//fab
FilterFabMenuButton(item = menuItem, onClick = onMenuItemClick)
}
}
I have FilterFabMenu composable which is a Column that shows menu items:
FilterFabMenu
#Composable
fun FilterFabMenu(
visible: Boolean,
items: List<FilterFabMenuItem>,
modifier: Modifier = Modifier
) {
val enterTransition = remember {
expandVertically(
expandFrom = Alignment.Bottom,
animationSpec = tween(150, easing = FastOutSlowInEasing)
) + fadeIn(
initialAlpha = 0.3f,
animationSpec = tween(150, easing = FastOutSlowInEasing)
)
}
val exitTransition = remember {
shrinkVertically(
shrinkTowards = Alignment.Bottom,
animationSpec = tween(150, easing = FastOutSlowInEasing)
) + fadeOut(
animationSpec = tween(150, easing = FastOutSlowInEasing)
)
}
AnimatedVisibility(visible = visible, enter = enterTransition, exit = exitTransition) {
Column(
modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items.forEach { menuItem ->
FilterFabMenuItem(
menuItem = menuItem,
onMenuItemClick = {}
)
}
}
}
}
I have FilterFab composable that expands/collapses FilterMenu:
FilterFab
#Composable
fun FilterFab(
state: FilterFabState,
rotation:Float,
onClick: (FilterFabState) -> Unit,
modifier: Modifier = Modifier
) {
FloatingActionButton(
modifier = modifier
.rotate(rotation),
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 0.dp),
onClick = {
onClick(
if (state == FilterFabState.EXPANDED) {
FilterFabState.COLLAPSED
} else {
FilterFabState.EXPANDED
}
)
},
backgroundColor = colorResource(
R.color.primary_color
),
shape = CircleShape
) {
Icon(
painter = painterResource(R.drawable.fab_add),
contentDescription = null,
tint = Color.White
)
}
}
Last but not least, I have a FilterView composable which is a Column that contains FilterFabMenu and FilterFab composables:
FilterView
#SuppressLint("UnusedTransitionTargetStateParameter")
#Composable
fun FilterView(
items: List<FilterFabMenuItem>,
modifier: Modifier = Modifier
) {
var filterFabState by rememberSaveable() {
mutableStateOf(FilterFabState.COLLAPSED)
}
val transitionState = remember {
MutableTransitionState(filterFabState).apply {
targetState = FilterFabState.COLLAPSED
}
}
val transition = updateTransition(targetState = transitionState, label = "transition")
val iconRotationDegree by transition.animateFloat({
tween(durationMillis = 150, easing = FastOutSlowInEasing)
}, label = "rotation") {
if (filterFabState == FilterFabState.EXPANDED) 230f else 0f
}
Column(
modifier = modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp,Alignment.Bottom)
) {
FilterFabMenu(items = items, visible = filterFabState == FilterFabState.EXPANDED)
FilterFab(
state = filterFabState,
rotation = iconRotationDegree, onClick = { state ->
filterFabState = state
})
}
}
This produces the following result:

expandVertically in your enterTransition is not the correct approach for this kind of animation. Per documentation, it animates a clip revealing the content of the animated item from top to bottom, or vice versa. You apply this animation to the entire column (so all items at once), resulting in the gif you have shown us.
Instead, you should use a different enter/exit animation type, maybe a custom animation where you work with the item scaling to emulate the "pop in" effect like such:
scaleFactor.animateTo(2f, tween(easing = FastOutSlowInEasing, durationMillis = 50))
scaleFactor.animateTo(1f, tween(easing = FastOutSlowInEasing, durationMillis = 70))
(the scaleFactor is an animatabale of type Animatable<Float, AnimationVector1D> in this instance).
Then you create such an animatable for each of the column items, i.e. your menu items. After that, just run the animations in a for loop for each menu item inside a coroutine scope (since compose animations are suspend by default, they will run in sequence, use async/awaitAll if you want to do it in parallel).
Also, don't forget to put your animatabales in remember {}, then just use the values you are animating like scaleFactor inside modifiers of your column items, and trigger them inside a LaunchedEffect when you click to expand/close the menu.

Related

Highlight only one Card in Compose through selection

i have a Compose LazyColumnList which shows the content (from a List) in single cards. I want to highlight the selected card only. Now when i select another card, the first card stays highlighted.
Here is the code:
...
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(
items = sql_quran,
itemContent = { SurahListItem(quran_einzeln = it, navController)
})
}
#Composable
fun SurahListItem(
quran_einzeln: ItemData_quran,
navController: NavController,
) {
var selectedCard by remember { mutableStateOf(false) }
var cardColor = if (selectedCard) Blue else White;
// var cardColor= if (selectedCard) LightGray else White
Card(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp).fillMaxWidth().fillMaxSize(),
elevation = 2.dp,
shape = RoundedCornerShape(corner = CornerSize(16.dp)),
backgroundColor = cardColor,
onClick = {
selectedCard=!selectedCard
}
)
All state changes are happening locally within your SurahListItem and nothing tells your LazyColumn to perform any updates to its item children every time you click one of them.
I can't compile your entire code, so I just assumed some parts of it resulting to the codes below. I hoisted the "selection state" that you want one level above from your SurahListItem and have the composable that contains the LazyColumn do the updates.
You can copy-and-paste all of these in a single .kt file and you'll be able to run it separately from some root composable screen/content.
data class ItemData_quran(val content: String)
#Composable
fun MyCards( sql_quran: List<ItemData_quran>) {
var selectedItem by remember {
mutableStateOf<ItemData_quran?>(null)
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(
items = sql_quran,
itemContent = { surah ->
SurahListItem(
quran_einzeln = surah,
setSelected = selectedItem?.let {
surah.content == it.content
} ?: false
) {
selectedItem = it
}
}
)
}
}
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun SurahListItem(
quran_einzeln: ItemData_quran,
setSelected: Boolean,
isSelected: (ItemData_quran?) -> Unit
) {
val cardColor = if (setSelected) Blue else White
Card(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.wrapContentSize(),
elevation = 2.dp,
shape = RoundedCornerShape(corner = CornerSize(16.dp)),
backgroundColor = cardColor,
onClick = {
isSelected(if(!setSelected) quran_einzeln else null)
}
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(text = quran_einzeln.content)
}
}
}
Usage:
MyCards(
listOf(
ItemData_quran("Item 1"),
ItemData_quran("Item 2"),
ItemData_quran("Item 3"),
ItemData_quran("Item 4"),
ItemData_quran("Item 5"),
ItemData_quran("Item 6"),
ItemData_quran("Item 7"),
)
)

Expand/collapse animation issue in LazyColumn Jetpack compose Android

I am trying to show a section within LazyColumn which has a list of Rows that are shown horizontally using LazyRow. I would like to have a button which displays show/hide so that I can show a minimal list in this section instead of full list. I would like to animate the expand/collapse part and currently expanding on button click is working as expected but when collapsing, the LazyCloumn scrolls up which seems to push this section out of screen (as shown in the video below). Is there any way we can collapse so that the button at least gets snapped to the top and the remaining section is removed? This way, user can still expand the list if required rather than scrolling up to find the button.
I have tried the following but none of them seem to work:
Using AnimatedVisibility
Using animate*AsState low level API's
Also tried to just remove the contents from list allowing LazyColumn to re-order based on the list content
val RandomColor
get() = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
typealias ClickHandler = (Boolean) -> Unit
#Composable
fun DemoLayout(demoDataList: List<DemoData>, isExpanded: Boolean, clickHandler: ClickHandler) {
LazyColumn {
demoDataList.forEachIndexed { index, it ->
when (it) {
is DemoData.Header -> item(key = "cell_$index") { HeaderComposable(header = it) }
is DemoData.BigCard -> item(key = "hero_$index") { BigCardComposable(bigCard = it) }
is DemoData.Card -> item(key = "banner_$index") { CardComposable(card = it) }
is DemoData.ExpandableSection -> {
items(count = 2, key = { indexInner: Int -> "categories_first_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index]
)
}
//Comment below and try another approach
item(key = "first_approach_$index") {
FirstApproach(
expandableSection = DemoData.ExpandableSection(
it.sectionInfo.subList(
3,
5
)
)
)
}
//Second approach
/*if (isExpanded)
items(count = 3, key = { indexInner -> "categories_second_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index + 2]
)
}
item(key = "button_$index") {
ShowHideButton(isExpanded, clickHandler)
}*/
}
}
}
}
}
#Composable
fun FirstApproach(expandableSection: DemoData.ExpandableSection) {
var expanded by remember { mutableStateOf(false) }
val density = LocalDensity.current
Column {
AnimatedVisibility(
visible = expanded,
enter = slideInVertically() +
expandVertically(
// Expand from the top.
expandFrom = Alignment.Top,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeIn(
// Fade in with the initial alpha of 0.3f.
initialAlpha = 0.3f
),
exit = slideOutVertically(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + shrinkVertically(
shrinkTowards = Alignment.Bottom,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeOut(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing),
targetAlpha = 0f
)
) {
Column {
for (i in 0 until expandableSection.sectionInfo.size) {
HeaderComposable(header = expandableSection.sectionInfo[i].header)
InfoCardsComposable(expandableSection.sectionInfo[i].infoCard)
DetailsCardComposable(expandableSection.sectionInfo[i].detailCard)
}
}
}
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
expanded = !expanded
}) {
Text(text = if (expanded) "Hide" else "Show")
}
}
}
#Composable
fun HeaderComposable(header: DemoData.Header) {
Row(
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = header.title, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun CardComposable(card: DemoData.Card) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(164.dp),
backgroundColor = RandomColor
) {
Text(text = card.cardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun BigCardComposable(bigCard: DemoData.BigCard) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(172.dp),
backgroundColor = RandomColor
) {
Text(text = bigCard.bigCardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun Section(sectionInfo: SectionInfo) {
Column(
modifier = Modifier.animateContentSize()
) {
HeaderComposable(header = sectionInfo.header)
InfoCardsComposable(sectionInfo.infoCard)
DetailsCardComposable(sectionInfo.detailCard)
}
}
#Composable
private fun ShowHideButton(isExpanded: Boolean, clickHandler: ClickHandler) {
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
clickHandler.invoke(
!isExpanded
)
}) {
Text(text = if (isExpanded) "Hide" else "Show")
}
}
#Composable
fun DetailsCardComposable(detailCardsList: List<DetailCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(detailCardsList) {
DetailCardComposable(detailCard = it)
}
}
}
#Composable
fun InfoCardsComposable(infoCardsList: List<InfoCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(infoCardsList) {
InfoCardComposable(infoCard = it)
}
}
}
#Composable
fun InfoCardComposable(infoCard: InfoCard) {
Card(
modifier = Modifier
.size(136.dp),
backgroundColor = RandomColor
) {
Text(text = infoCard.infoText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun DetailCardComposable(detailCard: DetailCard) {
Card(
modifier = Modifier
.size(156.dp),
backgroundColor = RandomColor
) {
Text(text = detailCard.detailText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
Complete code to try out is available here: https://github.com/DirajHS/ComposeAnimation/tree/master
I would like to know if this is the expected behavior or am I doing something wrong?
Any suggestions on snapping the button to the top during collapse would be much appreciated.

How can Navigation Drawer be opened with a drag when a list item is draggable in compose?

So I am rewriting an app's UI using Jetpack Compose. I have implemented a Navigation Drawer using the regular Scaffold function. Out of the box this provides two ways of opening the drawer: either press the navigationIcon or drag towards End of screen. The screen in question is a LazyColumn of list items.
I have at a later date implemented the SwipeToDismiss pattern on these list items. The swipe to dismiss works fine but it is no longer possible to drag anywhere to open the navigation drawer.
In the old View-based system, the navigation drawer would reserve a small width inside which you could always drag to open the drawer - regardless of child items having drag support. I am unsure how to achieve the same using Compose. It seems like it should be the job of the navigation drawer to handle this - and not a screen inside it.
The screen with navigation drawer:
val coroutineScope = rememberCoroutineScope()
val scaffoldState = rememberScaffoldState(
rememberDrawerState(initialValue = DrawerValue.Closed)
)
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopAppBar(
title = { Text(screenTitle) },
navigationIcon = {
IconButton(
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.open()
}
}
) {
Icon(
Icons.Default.Menu,
contentDescription = "Drawer toggle button"
)
}
},
actions = {
...
}
)
},
drawerContent = {
// List of stuff
...
},
floatingActionButton = {
...
}
) { padding ->
/// Layout with a LazyColumn with elements having SwipeToDismiss
...
}
and swipe to dismiss item (displayed inside LazyColumn)
#OptIn(
ExperimentalFoundationApi::class,
ExperimentalMaterialApi::class,
ExperimentalAnimationApi::class
)
#Composable
fun SwipeableFeedItemPreview(
onSwipe: suspend () -> Unit,
onlyUnread: Boolean,
item: FeedListItem,
showThumbnail: Boolean,
imagePainter: #Composable (String) -> Unit,
onMarkAboveAsRead: () -> Unit,
onMarkBelowAsRead: () -> Unit,
onItemClick: () -> Unit
) {
val animatedVisibilityState = remember { MutableTransitionState(true) }
val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
// Needs to be set once layout is complete
var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
val anchors = mapOf(
0f to FeedItemSwipeState.NONE,
-itemSize.width to FeedItemSwipeState.LEFT,
itemSize.width to FeedItemSwipeState.RIGHT
)
AnimatedVisibility(
visibleState = animatedVisibilityState,
enter = fadeIn(1f),
exit = shrinkVertically(Alignment.CenterVertically) + fadeOut()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { layoutCoordinates ->
itemSize = layoutCoordinates.size.toSize()
}
.swipeable(
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
thresholds = { _, _ ->
FractionalThreshold(0.25f)
}
)
) {
Box(
contentAlignment = swipeIconAlignment,
modifier = Modifier
.matchParentSize()
.background(color)
.padding(horizontal = 24.dp)
) {
AnimatedVisibility(
visible = swipeableState.targetValue != FeedItemSwipeState.NONE,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
when (item.unread) {
true -> Icons.Default.VisibilityOff
false -> Icons.Default.Visibility
},
contentDescription = stringResource(id = R.string.toggle_read_status)
)
}
}
FeedItemPreview(
item = item,
showThumbnail = showThumbnail,
imagePainter = imagePainter,
onMarkAboveAsRead = onMarkAboveAsRead,
onMarkBelowAsRead = onMarkBelowAsRead,
onItemClick = onItemClick,
modifier = Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
)
}
}
}
You can easily decrease swipeable range using padding, like this:
enum class FeedItemSwipeState {
NONE, LEFT, RIGHT,
}
#Composable
fun TestView(
) {
val scaffoldState = rememberScaffoldState(
rememberDrawerState(initialValue = DrawerValue.Closed)
)
Scaffold(
scaffoldState = scaffoldState,
drawerContent = {
},
) {
val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
// Needs to be set once layout is complete
var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
val anchors = mapOf(
0f to FeedItemSwipeState.NONE,
-itemSize.width to FeedItemSwipeState.LEFT,
itemSize.width to FeedItemSwipeState.RIGHT
)
Box(
modifier = Modifier
.fillMaxWidth()
) {
Box(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.matchParentSize()
.clickable { // clickable on whole view
}
.padding(start = 30.dp) // left distance for drawer
.onGloballyPositioned { layoutCoordinates ->
itemSize = layoutCoordinates.size.toSize()
}
.swipeable( // swipeable after padding to allow drawerContent work
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
thresholds = { _, _ ->
FractionalThreshold(0.25f)
}
)
)
Text(
"item",
modifier = Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
)
}
}
}
}
I'm not sure if that's something Scaffold should be responsible for, if you think it should - create an issue on compose issue tracker
This is the approach I ended up using after Philip gave his answer. It's slightly "less boxy". In summary - the key was simply to let the parent box handle the clicking - allowing a separate box to focus solely on the swiping - and the feeditem itself doesn't handle clicking
enum class FeedItemSwipeState {
NONE, LEFT, RIGHT,
}
#Composable
fun TestView(
) {
val scaffoldState = rememberScaffoldState(
rememberDrawerState(initialValue = DrawerValue.Closed)
)
Scaffold(
scaffoldState = scaffoldState,
drawerContent = {
},
) {
val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
// Needs to be set once layout is complete
var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
val anchors = mapOf(
0f to FeedItemSwipeState.NONE,
-itemSize.width to FeedItemSwipeState.LEFT,
itemSize.width to FeedItemSwipeState.RIGHT
)
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { layoutCoordinates ->
itemSize = layoutCoordinates.size.toSize()
}
.combinedClickable(
onLongClick = { ... },
onClick = { ... },
)
) {
Box(
modifier = Modifier
.padding(start = 48.dp)
.matchParentSize()
.swipeable(
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
thresholds = { _, _ ->
FractionalThreshold(0.25f)
}
)
)
FeedItemPreview(
item = "item",
swipeableModifier = Modifier
.padding(start = 30.dp) // left distance for drawer
.onGloballyPositioned { layoutCoordinates ->
itemSize = layoutCoordinates.size.toSize()
}
.swipeable(
state = swipeableState,
anchors = anchors,
orientation = Orientation.Horizontal,
thresholds = { _, _ ->
FractionalThreshold(0.25f)
}
)
,
modifier = Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
)
}
}
}
#Composable
fun FeedItemPreview(
item: String,
modifier: Modifier,
) {
Text(
item,
modifier = modifier
)
}
With example in app where swipeable area is highlighted by a border:

Button Long Press Listener in Android jetpack compose

I am having an Android Composable UI with a Button.
How can I track button long press events? I got it working for the Text long press, but for Button, It is not working. Same way like below if I apply a modifier to the button, it is not working.
Text(
text = view.text,
fontSize = view.textFontSize.toInt().sp,
fontWeight = FontWeight(view.textFontWeight.toInt()),
color = Color(android.graphics.Color.parseColor(view.textColor)),
modifier = Modifier.clickable(
onClick = {
println("Single Click")
},
onLongClick = {
println("Long Click")
},
onDoubleClick = {
println("Double Tap")
},
),
)
You can use combinedClickable like the following:
Modifier
.combinedClickable(
onClick = { },
onLongClick = { },
)
Warning: with Compose 1.0.1 this method is marked as #ExperimentalFoundationApi so this answer may get outdated in the future releases.
https://developer.android.com/jetpack/compose/gestures can be used as well.
for example:
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
modifier = Modifier
.weight(2f)
.pointerInput(Unit){
detectTapGestures(
onLongPress = {
// perform some action here..
}
)
}
The best way to handle this is to roll your own Button. The Material Button is basically just a Surface and a Row. The reason adding your own Modifier.clickable doesn't work is because one is already set.
So, if you'd like to add onLongPress, etc you can copy/paste the default implementation and pass those lambdas in.
#Composable
#OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionState: InteractionState = remember { InteractionState() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: #Composable RowScope.() -> Unit
) {
val contentColor by colors.contentColor(enabled)
Surface(
shape = shape,
color = colors.backgroundColor(enabled).value,
contentColor = contentColor.copy(alpha = 1f),
border = border,
elevation = elevation?.elevation(enabled, interactionState)?.value ?: 0.dp,
modifier = modifier.combinedClickable(
onClick = onClick,
onDoubleClick = onDoubleClick,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionState = interactionState,
indication = null
)
) {
Providers(LocalContentAlpha provides contentColor.alpha) {
ProvideTextStyle(
value = MaterialTheme.typography.button
) {
Row(
Modifier
.defaultMinSizeConstraints(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.indication(interactionState, rememberRipple())
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
}
Usage:
Button(
onClick = {},
onLongClick = {},
onDoubleClick = {}
) {
Text(text = "I'm a button")
}
According to documentation
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
5 months later, the accepted answer doesn't work because of API changes. detectTapGestures() on Button didn't work for me either (i guess .clickable() steals the event?).
Surface now has two public constructors. First one is not clickable and explicitly overrides .pointerInput(Unit) to be empty
Surface(
...
clickAndSemanticsModifier = Modifier
.semantics(mergeDescendants = false) {}
.pointerInput(Unit) { detectTapGestures { } }
)
Second one (that is used by Button) is clickable and explicitly sets Modifier.clickable(). And if Button with detectTapGestures() doesn't work for you, this one won't work either.
There is a third private constructor that doesn't override your click events. So I ended up just stealing that and putting it next to custom LongPressButton.
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun LongPressButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongPress: () -> Unit = {},
onDoubleClick: () -> Unit = {},
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: #Composable RowScope.() -> Unit
) {
val contentColor by colors.contentColor(enabled)
Surface(
modifier = modifier,
shape = shape,
color = colors.backgroundColor(enabled).value,
contentColor = contentColor.copy(alpha = 1f),
border = border,
elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
clickAndSemanticsModifier = Modifier.combinedClickable(
interactionSource = interactionSource,
indication = rememberRipple(),
enabled = enabled,
role = Role.Button,
onClick = onClick,
onDoubleClick = onDoubleClick,
onLongClick = onLongPress,
)
) {
CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
ProvideTextStyle(
value = MaterialTheme.typography.button
) {
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
}
#Composable
private fun Surface(
modifier: Modifier,
shape: Shape,
color: Color,
contentColor: Color,
border: BorderStroke?,
elevation: Dp,
clickAndSemanticsModifier: Modifier,
content: #Composable () -> Unit
) {
val elevationOverlay = LocalElevationOverlay.current
val absoluteElevation = LocalAbsoluteElevation.current + elevation
val backgroundColor = if (color == MaterialTheme.colors.surface && elevationOverlay != null) {
elevationOverlay.apply(color, absoluteElevation)
} else {
color
}
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalAbsoluteElevation provides absoluteElevation
) {
Box(
modifier
.shadow(elevation, shape, clip = false)
.then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(
color = backgroundColor,
shape = shape
)
.clip(shape)
.then(clickAndSemanticsModifier),
propagateMinConstraints = true
) {
content()
}
}
}
If there is a better way that works, please share. Because current solution is ugly.
I tried #adneal answer and for some reason it wouldn't pick up the "onLongClick".
After some research I updated as follow to make it work :
#OptIn(ExperimentalMaterialApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
#Composable
fun ButtonWithLongPress(
onClick: () -> Unit,
onDoubleClick:()->Unit = {},
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: #Composable RowScope.() -> Unit
) {
val contentColor by colors.contentColor(enabled)
Surface(
onClick = { },
modifier = modifier
.combinedClickable(
interactionSource,
rememberRipple(),
true,
null,
Role.Button,
null,
onClick = { onClick() },
onLongClick = { onLongClick() },
onDoubleClick = {onDoubleClick()}),
enabled = enabled,
shape = shape,
color = colors.backgroundColor(enabled).value,
contentColor = contentColor.copy(alpha = 1f),
border = border,
elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
interactionSource = interactionSource,
) {
CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
ProvideTextStyle(
value = MaterialTheme.typography.button
) {
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.padding(contentPadding)
.combinedClickable(interactionSource,
null,
true,
null,
Role.Button,
null,
onClick = { onClick() },
onLongClick = { onLongClick() },
onDoubleClick = { onDoubleClick() }),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}}
Now it works the way it should, and setting up a double-click is optional if necessary
This is my solution
#OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
#Composable
fun MyButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: () -> Unit = {},
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: #Composable RowScope.() -> Unit
) {
val contentColor by colors.contentColor(enabled)
var tapped by remember { mutableStateOf(false) }
Surface(
modifier = modifier
.clip(shape)
.indication(interactionSource, LocalIndication.current)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
tapped = true
val press = PressInteraction.Press(offset)
interactionSource.emit(press)
tryAwaitRelease()
interactionSource.emit(PressInteraction.Release(press))
tapped = false
},
onTap = { onClick() },
onLongPress = { onLongClick() }
)
}
,
shape = shape,
color = colors.backgroundColor(enabled).value,
contentColor = contentColor.copy(alpha = 1f),
border = border,
elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
) {
CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
ProvideTextStyle(
value = MaterialTheme.typography.button
) {
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
}
Simply override default button and use it when you need to catch click or longClick event

Jetpack Compose onClick ripple is not propagating with a circular motion?

As can be seen in gif
when Column that contains of Text, Spacer, and LazyRowForIndexed is touched ripple is not propagating with circular motion. And it gets touched effect even when horizontal list is touched.
#Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
Card(
modifier = modifier,
border = BorderStroke(color = Color.Black, width = Dp.Hairline),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.preferredSize(16.dp, 16.dp)
.background(color = MaterialTheme.colors.secondary)
)
Spacer(Modifier.preferredWidth(4.dp))
Text(text = text)
}
}
}
#Composable
fun TutorialSectionCard(model: TutorialSectionModel) {
Column(
modifier = Modifier
.padding(top = 8.dp)
.clickable(onClick = { /* Ignoring onClick */ })
.padding(16.dp)
) {
Text(text = model.title, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h6)
Spacer(Modifier.preferredHeight(8.dp))
Providers(AmbientContentAlpha provides ContentAlpha.medium) {
Text(model.description, style = MaterialTheme.typography.body2)
}
Spacer(Modifier.preferredHeight(16.dp))
LazyRowForIndexed(items = model.tags) { _: Int, item: String ->
Chip(text = item)
Spacer(Modifier.preferredWidth(4.dp))
}
}
}
#Preview
#Composable
fun TutorialSectionCardPreview() {
val model = TutorialSectionModel(
clazz = MainActivity::class.java,
title = "1-1 Column/Row Basics",
description = "Create Rows and Columns that adds elements in vertical order",
tags = listOf("Jetpack", "Compose", "Rows", "Columns", "Layouts", "Text", "Modifier")
)
Column {
TutorialSectionCard(model)
TutorialSectionCard(model)
TutorialSectionCard(model)
}
}
What should be done to have circular effect, but not when list itself or an item from list is touched, or scrolled?
You have to apply a Theme to your composable, which in turn provides a default ripple factory, or you have to set the ripple explicitly:
#Preview
#Composable
fun TutorialSectionCardPreview() {
MaterialTheme() {
Column {
TutorialSectionCard
...
}
}
}
or
Column(
modifier = Modifier
.padding(top = 8.dp)
.clickable(
onClick = { /* Ignoring onClick */ },
indication = rememberRipple(bounded = true)
)
.padding(16.dp)
) {
// content
}
(As of compose version 1.0.0-alpha09 there seems to be no way to prevent the ripple from showing when content is scrolled)
I'm using this approach:
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(
color = Color.Black
),
onClick = {
}
)
I also figured out how to keep ripple only for the card not the scrollable list that contains tags. To prevent ripple only move through cards use a Box which places it's children as a stack, and add clickable to section that contains header and text.
#Composable
fun TutorialSectionCard(
model: TutorialSectionModel,
onClick: ((TutorialSectionModel) -> Unit)? = null
) {
Card(
modifier = Modifier.padding(vertical = 3.dp, horizontal = 8.dp),
elevation = 1.dp,
shape = RoundedCornerShape(8.dp)
) {
Box(
contentAlignment = Alignment.BottomStart
) {
TutorialContentComponent(onClick, model)
TutorialTagsComponent(model)
}
}
}
#Composable
private fun TutorialContentComponent(
onClick: ((TutorialSectionModel) -> Unit)?,
model: TutorialSectionModel
) {
Column(Modifier
.clickable(
onClick = { onClick?.invoke(model) }
)
.padding(16.dp)
) {
Text(
text = model.title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h6
)
// Vertical spacing
Spacer(Modifier.height(8.dp))
// Description text
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(model.description, style = MaterialTheme.typography.body2)
}
// Vertical spacing
Spacer(Modifier.height(36.dp))
}
}
#Composable
private fun TutorialTagsComponent(model: TutorialSectionModel) {
Column(Modifier.padding(12.dp)) {
// Horizontal list for tags
LazyRow(content = {
items(model.tags) { tag ->
TutorialChip(text = tag)
Spacer(Modifier.width(8.dp))
}
})
}
}

Categories

Resources