I can't seem to find much information on touch handling in Compose.
In the specific case I'm looking at I have a list like this:
#Composable
fun MyListComposable(items: List<Item>) {
LazyColumn(
contentPadding = paddingValues(listHorizontalMargin, listVerticalMargin),
) {
// Init items emitted for brevity
}
}
This list is contained in a parent which uses the swipeable modifier, something like this.
Card(
modifier = Modifier.swipeable(
state = state,
anchors = mapOf(
0.dp.value to DrawerState.OFFSCREEN,
50.dp.value to DrawerState.PEEKING,
maxHeight.value to DrawerState.EXPANDED,
),
reverseDirection = true,
thresholds = { _, _ -> FractionalThreshold(0f) },
orientation = Orientation.Vertical
) {
MyListComposable(items)
}
My problem is the list swallows all touches, so the swipable is never invoked. So my question is, is there a way to stop lazy column swallowing touches?
Related
In the traditional Android view system most key event dispatchers in views and activities had a Boolean return type that would help the parent view figure out if the input was consumed by a child down the line or not and could handle both cases accordingly.
My question is how do you do the same thing in Jetpack Compose?
Consider the following sample code snippet:
#Composable
private fun Outer() {
Box(
Modifier
.padding(30.dp)
.height(400.dp)
.width(300.dp)
.background(Color.Green)
.pointerInput(true) {
detectDragGestures { change, dragAmount ->
// Do something only if the inner compose didn't handle it
}
},
contentAlignment = Alignment.Center
) {
Inner()
}
}
#Composable
private fun Inner() {
Box(
Modifier
.fillMaxWidth(0.5f)
.fillMaxHeight(0.5f)
.background(Color.Blue)
.pointerInput(true) {
detectDragGestures { change, dragAmount ->
// Do something if change is less than a specific amount otherwise
// tell parent I didn't consume the input
}
}
)
}
So for instance if a drag gesture happens inside the blue inner Box it will only be consumed if it is less than a specific amount other wise the outer green Box will handle the gesture.
Or is it possible so that both can consume the gestures?
I'm working on a search page made in Compose with LazyColumn, everything works fine except for the wanted behavior of LazyColumn returing to first item when data changes.
This is my actual implementation of lazy column:
#Composable
fun <DataType : Any> GenericListView(
itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
modifier: Modifier = Modifier.fillMaxSize(),
spacing: Dp = 24.dp,
padding: PaddingValues = PaddingValues(0.dp),
item: #Composable (DataType) -> Unit
) {
val listState: LazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing),
state = listState,
modifier = modifier.padding(padding)
) {
items(itemsList) {
item(it)
}
}
SideEffect {
Log.i("->->->->->->->", "side effect launched")
coroutineScope.launch {
listState.scrollToItem(0)
}
}
}
As docs says, SideEffect should be called everytime the function is recomposed,
but it appear to be working only in debug mode with breakpoints in SideEffect, otherwise, it works only when the whole page is first created.
I've already tried with LaunchedEffect instead of SideEffect, using itemsList as key, but nothing happened.
Why my code works only in debug mode ?
Or better, an already made working solution to reset position when new data are set ?
SideEffect doesn't work because Compose is not actually recomposing the whole view when the SnapshotStateList is changed: it sees that only LazyColumn is using this state value so only this function needs to be recomposed.
To make it work you can change itemsList to List<DataType> and pass plain list, like itemsList = mutableStateList.toList() - it'll force whole view recomposition.
LaunchedEffect with passed SnapshotStateList doesn't work for kind of the same reason: it compares the address of the state container, which is not changed. To compare the items itself, you again can convert it to a plain list: in this case it'll be compared by items hash.
LaunchedEffect(itemsList.toList()) {
}
You can achieve the mentioned functionality with SideEffect, remember and with some kind of identificator (listId) of the list items. If this identificator changes, the list will scroll to the top, otherwise not.
I have extended your code. (You can choose any type for listId.)
#Composable
fun <DataType : Any> GenericListView(
itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
modifier: Modifier = Modifier.fillMaxSize(),
spacing: Dp = 24.dp,
padding: PaddingValues = PaddingValues(0.dp),
listId: String? = null,
item: #Composable (DataType) -> Unit
) {
var lastListId: String? by remember {
mutableStateOf(null)
}
val listState: LazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing),
state = listState,
modifier = modifier.padding(padding)
) {
items(itemsList) {
item(it)
}
}
SideEffect {
Log.i("->->->->->->->", "side effect launched")
coroutineScope.launch {
if (lastListId != listId) {
lastListId = listId
listState.scrollToItem(0)
}
}
}
}
Currently I'm trying to develop a special kind of image carousel which basically has two rows (one at the top with the images, and another one (shorter in width) at the bottom that use names for reference)
The thing is that I need to scroll the top row faster than the bottom one to achieve this with Jetpack Compose (can be done in regular Android with just some scroll listeners)
I was able to achieve scroll the rows simultaneously but they are scrolling at the same speed. I need to scroll the first one twice as fast to achieve the effect I want.
Here's the code I tried.
val scrollState = rememberScrollState()
Row(modifier = Modifier.horizontalScroll(scrollState)) {
Images()
}
Row(modifier = Modifier.horizontalScroll(scrollState)) {
Legend()
}
If you only need one Row to be scrollable, you can create new ScrollState each time.
val scrollState = rememberScrollState()
Row(modifier = Modifier.horizontalScroll(scrollState)) {
repeat(100) {
Text(it.toString())
}
}
Row(
modifier = Modifier.horizontalScroll(
ScrollState(scrollState.value * 2),
enabled = false
)
) {
repeat(200) {
Text(it.toString())
}
}
Note that this solution may not be the best in terms of performance, as it creates a class on each pixel scrolled.
An other solution is to sync them with LaunchedEffect. It'll also allow you both way scrolling synchronization.
#Composable
fun TestScreen(
) {
val scrollState1 = rememberScrollState()
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.horizontalScroll(scrollState1)
) {
repeat(100) {
Text(it.toString())
}
}
val scrollState2 = rememberScrollState()
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.horizontalScroll(scrollState2)
) {
repeat(200) {
Text(it.toString())
}
}
SyncScrollTwoWay(scrollState1, scrollState2, 2f)
}
#Composable
fun SyncScrollTwoWay(scrollState1: ScrollState, scrollState2: ScrollState, multiplier: Float) {
SyncScroll(scrollState1, scrollState2, multiplier)
SyncScroll(scrollState2, scrollState1, 1 / multiplier)
}
#Composable
fun SyncScroll(scrollState1: ScrollState, scrollState2: ScrollState, multiplier: Float) {
if (scrollState1.isScrollInProgress) {
LaunchedEffect(scrollState1.value) {
scrollState2.scrollTo((scrollState1.value * multiplier).roundToInt())
}
}
}
But it also have one downside: there'll be little delay in scrolling, so the second scroll view will always be one frame behind the scrolling one.
Is there a way to save the UI state of a Composable so that when switching between Composable their UI state is identical to when the view was left ?
I've tried using rememberLazyListState() (which uses rememberSaveable) to save the scroll state of a LazyColumn but it doesn't seems to work when coming back to the Composable.
Any ideas ?
Edit : I am using NavControllerto handle the navigation between the Composable
I just figured out how to do it. The idea is to hoist the LazyListState to the Composable managing the view navigation.
#Composable
fun AppScreenNav(screen: Screen) {
val listState = rememberLazyListState()
when (screen) {
Screen.Home -> Home()
Screen.Favorites -> Favorites(listState)
}
}
#Composable
fun Favorites(listState: LazyListState) {
val favorites: List<String> by rememberSaveable { mutableStateOf(List(1000) { "Favorites $it" }) }
LazyColumn(
state = listState
) {
items(favorites) { item ->
Text(
color = Color.Black,
text = item,
)
}
}
}
Here we are hoisting the list state to the parent component. When switching between the Home() and Favorites() composable the list scrolling state should remains identical.
In Jetpack Compose there is a Modifier extension called selectable.
Configure component to be selectable, usually as a part of a mutually exclusive group, where
only one item can be selected at any point in time.
I'm using this for a mutually exclusive radio group inside a scrollable list. In my case a LazyColumn. This works fine, clicking on the selectable areas lights them up and results in detected clicks. However I noticed that the area also lights up while "touching" these areas while scrolling.
I made a simple example composable if you want to see what I mean, simply scroll through the list and you will see how scrolling triggers a short selected state:
#Composable
fun Example() {
LazyColumn {
item {
repeat(100){
Column(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.selectable(
selected = false,
onClick = { }
)
) {
Text("Example")
}
}
}
}
}
Has anyone figure out how to fix kind of behaviour? I tried looking for any related documentation at https://developer.android.com/jetpack/compose/gestures but nothing really explains how to "block" touch events while scrolling.
You can selectively enable Modifier.selectable(enabled) based on scroll state but even with derivedStateOf i see that there is huge performance loss.
val scrollState = rememberLazyListState()
val enableSelectable = derivedStateOf {
!scrollState.isScrollInProgress
}
Modifier
.fillMaxWidth()
.height(40.dp)
.selectable(
enabled = enableSelectable.value,
selected = false,
onClick = { }
)
I created a simple but longer example than you did, and included a video showing how it behaves with this code.
I believe what you are seeing is the ACTION_DOWN causing a ripple. It's not actually "selecting" the item because it does not change the selected state. I am not seeing the ripple when I scroll, but only when I keep my finger pressed on a specific row - the ripple disappears when my finger moves down.
I got the info about MotionEvents from this answer: https://stackoverflow.com/a/64594717/1703677
(Change the falses to true to see more info in the logs)
#Composable
fun Content() {
val selectedValue = remember { mutableStateOf("") }
LazyColumn {
item {
repeat(100) {
val label = "Item $it"
val selected = selectedValue.value == label
SingleRadioButtonWithLabel(label, selected) {
selectedValue.value = label
}
}
}
}
}
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun SingleRadioButtonWithLabel(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.selectable(
selected = selected,
onClick = {
onClick()
Log.e("TestApp", "Row onClick")
}
)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
Log.e("TestApp", "MotionEvent.ACTION_DOWN")
}
MotionEvent.ACTION_MOVE -> {
Log.e("TestApp", "MotionEvent.ACTION_MOVE")
}
MotionEvent.ACTION_UP -> {
Log.e("TestApp", "MotionEvent.ACTION_UP")
}
else -> false
}
false
}
) {
RadioButton(
selected = selected,
onClick = {
onClick()
Log.e("TestApp", "Radio Button onClick")
},
)
Text(
text = label,
modifier = Modifier.fillMaxWidth()
)
}
}