In Jetpack Compose, when you enable clickable {} on a modifier for a composable, by default it enables ripple effect for it. How to disable this behavior?
Example code
Row(modifier = Modifier
.clickable { // action }
)
Short answer:
to disable the ripple pass null in the indication parameter in the clickable modifier:
val interactionSource = remember { MutableInteractionSource() }
Column {
Text(
text = "Click me without any ripple!",
modifier = Modifier
.clickable(
interactionSource = interactionSource,
indication = null
) {
/* doSomething() */
}
)
Why it doesn't work with some Composables as Buttons:
Note that in some Composables, like Button or IconButton, it doesn't work since the indication is defined internally by the component which uses indication = rememberRipple(). This creates and remembers a Ripple using values provided by RippleTheme.
In this cases you can't disable it but you can change the appearance of the ripple that is based on a RippleTheme. You can define a custom RippleTheme and apply it to your composable with the LocalRippleTheme.
Something like:
CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
Button(
onClick = { /*...*/ },
) {
//...
}
}
with:
private object NoRippleTheme : RippleTheme {
#Composable
override fun defaultColor() = Color.Unspecified
#Composable
override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f,0.0f,0.0f,0.0f)
}
Custom modifier
If you prefer you can build your custom Modifier with the same code above, you can use:
fun Modifier.clickableWithoutRipple(
interactionSource: MutableInteractionSource,
onClick: () -> Unit
) = composed(
factory = {
this.then(
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { onClick() }
)
)
}
)
and then just apply it:
Row(
modifier = Modifier
.clickableWithoutRipple(
interactionSource = interactionSource,
onClick = { doSomething() }
)
){
//Row content
}
Long answer:
If you add the clickable modifier to a composable to make it clickable within its bounds it will show an Indication as specified in indication parameter.
By default, indication from LocalIndication will be used.
If you are using a MaterialTheme in your hierarchy, a Ripple, defined by rememberRipple(), will be used as the default Indication inside components such as androidx.compose.foundation.clickable and androidx.compose.foundation.indication.
Use this Modifier extension:
fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed {
clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}
then simply replace Modifier.clickable {} with Modifier.noRippleClickable {}
Row(modifier = Modifier.noRippleClickable {
// action
})
To disable the ripple effect, have to pass null to indication property of the modifier.
More about indication on Jetpack Compose documentation
Code
Row(
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() } // This is mandatory
) {
// action
}
)
You can handle it this way when working with Buttons.
Create a Ripple interactionSource class
class NoRippleInteractionSource : MutableInteractionSource {
override val interactions: Flow<Interaction> = emptyFlow()
override suspend fun emit(interaction: Interaction) {}
override fun tryEmit(interaction: Interaction) = true
}
In case of a button, you can handle it by passing the ripple interaction class as the interactionSource parameter i.e:
Button(
onClick = { /*...*/ },
interactionSource = NoRippleInteractionSource()
) {
//..
}
This solution works with all compossables that accept a mutableInteractionSource as a parameter for example Button(), TextButton(), Switch(), etc
Modifier extension with other parameters :
inline fun Modifier.noRippleClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
crossinline onClick: ()->Unit
): Modifier = composed {
clickable(
enabled = enabled,
indication = null,
onClickLabel = onClickLabel,
role = role,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}
With androidx.compose.foundation there is a enabled attribute inside clickable extension. I think that it is easiest way. Link
fun Modifier.clickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
): Modifier
I used #Mahdi-Malv's answer and modify as below:
remove inline and crossinline
modify according to my requirement
fun Modifier.noRippleClickable(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = null,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit,
) = clickable(
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
onClickLabel = onClickLabel,
role = role,
onClick = onClick,
)
Related
I am learning Jetpack Compose, and I've created a few set of buttons as a practice.
This is the button
#Composable
fun MyButton(
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
enabled = isEnabled,
onClick = { onClick() },
modifier = modifier.width(270.dp).wrapContentHeight(),
) {
Text(
text = text,
style = MaterialTheme.typography.button
)
}
}
The problem is, that if i set the height of the button to wrapContentHeight or use heightIn with different max and min values, compose automatically adds a space around the button as seen here
But if i remove WrapContent, and use a fixed height, or define same min and max height for heightIn this probblem does not appear
#Composable
fun MyButton(
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
enabled = isEnabled,
onClick = { onClick() },
modifier = modifier.width(270.dp).height(36.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.button
)
}
}
And this is the code used for the column/preview of the functions:
#Composable
private fun SampleScreen() {
MyTheme{
Surface(modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background,){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
modifier = Modifier.padding(20.dp)
) {
var isEnabled by remember { mutableStateOf(false) }
MyButton("Enable/Disable") {
isEnabled = !isEnabled
}
MyButton("Button") {}
MyButton(text = "Disabled Button", isEnabled = isEnabled) {}
}
}
}
}
Even if I remove the spacedBy operator from column the same issue appears.
I have tried to search for an explanation to this, but I did not manage to find anything.
Any help or resource with explanations is appreciated.
This is because Minimum dimension of Composables touch area is 48.dp by default for accessibility. However you can override this by using
CompositionLocalProvider(LocalMinimumTouchTargetEnforcement provides false) {
Button(
enabled = isEnabled,
onClick = { onClick() },
modifier = modifier.heightIn(min = 20.dp)
.widthIn(min = 20.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.button
)
}
}
Or something like
Button(modifier = Modifier.absoluteOffset((-12).dp, 0.dp)){
Text(
text = text,
style = MaterialTheme.typography.button
)
}
This question already has answers here:
Why don't Indication work for Button or Icons?
(2 answers)
Closed 6 months ago.
I have seen that we can disable the ripple effect of a view with the clickable(interactionSource, indication) inside for example a row or column but my question is that if we can disable it from a Button or FloatingActionButton
I see that FloatingActionButton has an interactionSource attribute and I have tried this
FloatingActionButton(
modifier = Modifier
.size(40.dp),
onClick = {
buttonState = when (buttonState) {
ButtonState.PRESSED -> ButtonState.UNPRESSED
ButtonState.UNPRESSED -> ButtonState.PRESSED
}
},
interactionSource = remember {
MutableInteractionSource()
})
this is not working to disable the ripple effect.
Then I have tried with the indication modifier like this
FloatingActionButton(
modifier = Modifier
.size(40.dp)
.indication(
interactionSource = remember {
MutableInteractionSource()
},
indication = null
),
onClick = {
buttonState = when (buttonState) {
ButtonState.PRESSED -> ButtonState.UNPRESSED
ButtonState.UNPRESSED -> ButtonState.PRESSED
}
})
also is not working, and then last thing I tried is adding the .clickable(...) in the modifier of the Fab button but I think that is pointless since the button has its own onClick event.
All the cases above yields to this
Is there anyway from any Button to disable its ripple effect without adding a Column or Box with a clickable attribute into its modifier ?
You can change ripple o by providing RippleTheme
private class CustomRippleTheme : RippleTheme {
#Composable
override fun defaultColor(): Color = Color.Unspecified
#Composable
override fun rippleAlpha(): RippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0f,
pressedAlpha = 0f,
)
}
Demo
#Composable
private fun RippleDemo() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(50.dp)
) {
Button(onClick = { /*TODO*/ }) {
Text("Button with ripple", fontSize = 20.sp)
}
Spacer(Modifier.height(20.dp))
FloatingActionButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
}
Spacer(Modifier.height(20.dp))
CompositionLocalProvider(LocalRippleTheme provides CustomRippleTheme()) {
Button(onClick = { /*TODO*/ }) {
Text("Button with No ripple", fontSize = 20.sp)
}
Spacer(Modifier.height(20.dp))
FloatingActionButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
}
}
}
}
Result
A Button, internally, is just a surface with modifications applied to it to make it clickable. It has a default indication set within the implementation, hence cannot be "turned off" at the calling site.
Just pull up the source code and remove the indication, storing the resultant inside a new Composable.
Just do a quick Ctrl+Left Click on the text "Button" in Studio, and it'll take you there.
In order to share settings among of compose functions, I create a class AboutState() and a compose fun rememberAboutState() to persist settings.
I don't know if I can wrap Modifier with remember in the solution.
The Code A can work well, but I don't know if it maybe cause problem when I wrap Modifier with remember, I think Modifier is special class and it's polymorphic based invoked.
Code A
#Composable
fun ScreenAbout(
aboutState: AboutState = rememberAboutState()
) {
Column() {
Hello(aboutState)
World(aboutState)
}
}
#Composable
fun Hello(
aboutState: AboutState
) {
Text("Hello",aboutState.modifier)
}
#Composable
fun World(
aboutState: AboutState
) {
Text("World",aboutState.modifier)
}
class AboutState(
val textStyle: TextStyle,
val modifier: Modifier=Modifier
) {
val rowSpace: Dp = 20.dp
}
#Composable
fun rememberAboutState(): AboutState {
val aboutState = AboutState(
textStyle = MaterialTheme.typography.body1.copy(
color=Color.Red
),
modifier=Modifier.padding(start = 80.dp)
)
return remember {
aboutState
}
}
There wouldn't be a problem passing a Modifier to a class. What you actually defined above, even if named State, is not class that acts as a State, it would me more appropriate name it as HelloStyle, HelloDefaults.style(), etc.
It would be more appropriate to name a class XState when it should have internal or public MutableState that can trigger recomposition or you can get current State of Composable or Modifier due to changes. It shouldn't contain only styling but state mechanism either to change or observe state of the Composble such as ScrollState or PagerState.
When you have a State wrapper object common way of having a stateful Modifier or Modifier with memory or Modifiers with Compose scope is using Modifier.composed{} and passing State to Modifier, not the other way around.
When do you need Modifier.composed { ... }?
fun Modifier.composedModifier(aboutState: AboutState) = composed(
factory = {
val color = remember { getRandomColor() }
aboutState.color = color
Modifier.background(aboutState.color)
}
)
In this example even if it's not practical getRandomColor is created once in recomposition and same color is used.
A zoom modifier i use for zooming in this library is as
fun Modifier.zoom(
key: Any? = Unit,
consume: Boolean = true,
clip: Boolean = true,
zoomState: ZoomState,
onGestureStart: ((ZoomData) -> Unit)? = null,
onGesture: ((ZoomData) -> Unit)? = null,
onGestureEnd: ((ZoomData) -> Unit)? = null
) = composed(
factory = {
val coroutineScope = rememberCoroutineScope()
// Current Zoom level
var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) }
// Rest of the code
},
inspectorInfo = {
name = "zoom"
properties["key"] = key
properties["clip"] = clip
properties["consume"] = consume
properties["zoomState"] = zoomState
properties["onGestureStart"] = onGestureStart
properties["onGesture"] = onGesture
properties["onGestureEnd"] = onGestureEnd
}
)
Another practical example for this is Modifier.scroll that uses rememberCoroutineScope(), you can also remember object too to not intantiate another object in recomposition
#OptIn(ExperimentalFoundationApi::class)
private fun Modifier.scroll(
state: ScrollState,
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
isScrollable: Boolean,
isVertical: Boolean
) = composed(
factory = {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val coroutineScope = rememberCoroutineScope()
// Rest of the code
},
inspectorInfo = debugInspectorInfo {
name = "scroll"
properties["state"] = state
properties["reverseScrolling"] = reverseScrolling
properties["flingBehavior"] = flingBehavior
properties["isScrollable"] = isScrollable
properties["isVertical"] = isVertical
}
)
I would like to use the TabRow, but when I click the background has a ripple effect that I do not want. Is there a way to change this? I have the Tab's selectedContectColor equal to the same background color of the page, but I still see a white ripple effect.
TabRow(
modifier = Modifier.height(20.dp),
selectedTabIndex = selectedIndex,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.customTabIndicatorOffset(
currentTabPosition = tabPositions[lazyListState.firstVisibleItemIndex]
tabWidths[lazyListState.firstVisibleItemIndex]
),
color = RED
)
},
backgroundColor = BLACK
) {
tabList.forEachIndexed{ index, tab ->
val selected = (selectedIndex == index)
Tab(
modifier = Modifier
// Have tried different solutions here where there is a .clickable
// and the indication = null, and set interactionSource = remember{
//MutableInteractionSource()}
selected = selected,
selectedContentColor = BLACK,
onClick = {
animateScrollToItem(selectedIndex)
},
text = {
Text("Text Code")
}
)
}
}
You can see in these docs that the selectedContentColor affects the ripple
The ripple is implemented in a selectable modifier defined inside the Tab.
You can't disable it but you can change the appearance of the ripple that is based on a RippleTheme. You can define a custom RippleTheme and apply to your composable with the LocalRippleTheme.
CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
//..TabRow()
}
private object NoRippleTheme : RippleTheme {
#Composable
override fun defaultColor() = Color.Unspecified
#Composable
override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f,0.0f,0.0f,0.0f)
}
The shimmer effect is handled by the indication property.
Put it inside the clickable section.
You can create an extension function
inline fun Modifier.noRippleClickable(crossinline onClick: ()->Unit): Modifier = composed {
clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}
then simply replace Modifier.clickable {} with Modifier.noRippleClickable {}
Is there a way to disable the indication animation on the Composable Checkbox?
The typical path of adding the indication = null parameter to the .clickable Modifier doesn't appear to work.
When I looked in the documentation it just directed me to the different modifiers.
Checkbox Composable Documentation
Checkbox(
checked = checkedState.value,
onCheckedChange = {vm.HandleListItemClick(optionItems, i, checkedState)},
modifier = Modifier
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = true,
onClickLabel = "${optionItems[i].label} checkbox selected status is ${checkedState.value}",
role = null,
){},
enabled = true,
)
It doesn't work since the Checkbox defines a custom indication inside the implementation.
You can provide a custom LocalRippleTheme to override the default behaviour.
Something like:
CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
val checkedState = remember { mutableStateOf(true) }
Checkbox(
checked = checkedState.value,
onCheckedChange = { checkedState.value = it }
)
}
private object NoRippleTheme : RippleTheme {
#Composable
override fun defaultColor() = Color.Unspecified
#Composable
override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f,0.0f,0.0f,0.0f)
}