How to disable and enable scrolling in LazyColumn/LazyRow in Jetpack Compose? - android

I want to dynamically enable and disable scrolling programmatically in a LazyColumn.
There don't seem to be any relevant functions on LazyListState or relevant parameters on LazyColumn itself. How can I achieve this in Compose?

There's not (currently) a built-in way to do this, which is a reasonable feature request.
However, the scroll API is flexible enough that we can add it ourselves. Basically, we create a never-ending fake scroll at MutatePriority.PreventUserInput to prevent scrolling, and then use a do-nothing scroll at the same priority to cancel the first "scroll" and re-enable scrolling.
Here are two utility functions on LazyListState to disable/re-enable scrolling, and a demo of them both in action (some imports will be required, but Android Studio should suggest them for you).
Note that because we're taking control of scrolling to do this, calling reenableScrolling will also cancel any ongoing scrolls or flings (that is, you should only call it when scrolling is disabled and you want to re-enable it, not just to confirm that it's enabled).
fun LazyListState.disableScrolling(scope: CoroutineScope) {
scope.launch {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
// Await indefinitely, blocking scrolls
awaitCancellation()
}
}
}
fun LazyListState.reenableScrolling(scope: CoroutineScope) {
scope.launch {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
// Do nothing, just cancel the previous indefinite "scroll"
}
}
}
#Composable
fun StopScrollDemo() {
val scope = rememberCoroutineScope()
val state = rememberLazyListState()
Column {
Row {
Button(onClick = { state.disableScrolling(scope) }) { Text("Disable") }
Button(onClick = { state.reenableScrolling(scope) }) { Text("Re-enable") }
}
LazyColumn(Modifier.fillMaxWidth(), state = state) {
items((1..100).toList()) {
Text("$it", fontSize = 24.sp)
}
}
}
}

Since 1.2.0-alpha01 userScrollEnabled was added to LazyColumn, LazyRow, and LazyVerticalGrid
Answer for 1.1.0 and earlier versions:
#Ryan's solution will also disable programmatically-called scrolling.
Here's a solution proposed by a maintainer in this feature request. It'll disable scrolling, allow programmatic scrolling as well as children view touches.
private val VerticalScrollConsumer = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(x = 0f)
override suspend fun onPreFling(available: Velocity) = available.copy(x = 0f)
}
private val HorizontalScrollConsumer = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(y = 0f)
override suspend fun onPreFling(available: Velocity) = available.copy(y = 0f)
}
fun Modifier.disabledVerticalPointerInputScroll(disabled: Boolean = true) =
if (disabled) this.nestedScroll(VerticalScrollConsumer) else this
fun Modifier.disabledHorizontalPointerInputScroll(disabled: Boolean = true) =
if (disabled) this.nestedScroll(HorizontalScrollConsumer) else this
Usage:
LazyColumn(
modifier = Modifier.disabledVerticalPointerInputScroll()
) {
// ...
}

NestedScrollConnection allows you to consume any scroll applied to a lazy column or row. When true, all of the available scroll is consumed. If false, none is consumed and scrolling happens normally. With this information, you can see how this can be extended for slow/fast scrolls by returning the offset multiple by some factor.
fun Modifier.scrollEnabled(
enabled: Boolean,
) = nestedScroll(
connection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = if(enabled) Offset.Zero else available
}
)
it can be used like this:
LazyColumn(
modifier = Modifier.scrollEnabled(
enabled = enabled, //provide a mutable state boolean here
)
){
...
However, this does block programmatic scrolls.

Related

Sticky Headers with paging 3 jetpack compose

I am trying to implement sticky headers as shown in the example here, with paging using Jetpack compose LazyColumn as shown bellow :
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// TODO (HADI) enhance later
notifications.itemSnapshotList.items.groupBy { extractNotificationHeader(it) }
.forEach { (header, messages) ->
stickyHeader {
NotificationHeader(
modifier = Modifier.fillMaxWidth(),
value = header
)
}
items(
items = messages,
key = { message -> message.notificationId },
) {
NotificationMessage(
notification = it,
onNotificationClick = { onNotificationClick(it) }
)
}
}
}
but I get requests from back-end with only 2 pages as I am using code like :
// API service method
#GET("notifications")
suspend fun getNotifications(
#Query("page") page: Int,
#Query("page_size") pageSize: Int = 30
): GenericResponse<NotificationsJson>
// data source impl
class NotificationDataSourceImpl #Inject constructor(private val notificationApiService: NotificationApiService) : NotificationDataSource {
override val invalidatingFactory = InvalidatingPagingSourceFactory {
NotificationMediator(notificationApiService)
}
override fun loadNotifications(): Flow<PagingData<NotificationCenterMessage>> {
return Pager(
config = PagingConfig(
pageSize = 30,
enablePlaceholders = false,
),
pagingSourceFactory = invalidatingFactory
).flow
}
override fun invalidateNotifications() {
invalidatingFactory.invalidate()
}
}
the problem is I have 7 more pages, but they are not loaded when I reach the bottom position of the lazy column items, I've tried to use only items without looping and sticky headers and it's working fine.
I've looked up for many solutions like here, but nothing worked as required.
Also I am trying to mark notification at specific position when the user click it to be read, but when I use invalidateNotifications()
the page reload and I lose the position even when I use animateScrollToItem() with rememberLazyListState()
Loads are triggered by the LazyPagingItems.get method and you are not calling it anywhere. Modify your extractNotificationHeader function to return indices of the header and messages instead of the models and then inside stickyHeader and items, use them like notifications.get(headerIndex).

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)

I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope
fun Modifier.disableSplitMotionEvents() =
pointerInput(Unit) {
coroutineScope {
var currentId: Long = -1L
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
when {
pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
else -> Unit
}
}
}
}
}
}
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable, use onClick instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.
NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.
Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.
for example:
#Composable
fun MyList() {
// Save the click state in a MutableState
val isClicked = remember {
mutableStateOf(false)
}
LazyColumn {
items(10) {
ListItem(index = "$it", state = isClicked) {
// Handle the click
}
}
}
}
ListItem Composable:
#Composable
fun ListItem(
index: String,
state: MutableState<Boolean>,
onClick: () -> Unit
) {
Text(
text = "Item $index",
modifier = Modifier
.clickable {
// If the state is true, escape the function
if (state.value)
return#clickable
// else, call onClick block
onClick()
state.value = true
}
)
}
Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from #Johann‘s code. Instead of disabling at the app level, I can call it only when I need to disable it.
Here is an Alternative solution:
class ClickHelper private constructor() {
private val now: Long
get() = System.currentTimeMillis()
private var lastEventTimeMs: Long = 0
fun clickOnce(event: () -> Unit) {
if (now - lastEventTimeMs >= 300L) {
event.invoke()
}
lastEventTimeMs = now
}
companion object {
#Volatile
private var instance: ClickHelper? = null
fun getInstance() =
instance ?: synchronized(this) {
instance ?: ClickHelper().also { instance = it }
}
}
}
then you can use it anywhere you want:
Button(onClick = { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
or:
Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
fun singleClick(onClick: () -> Unit): () -> Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= 300) {
onClick()
latest = now
}
}
}
Then you can use
Button(onClick = singleClick {
// TODO
})
Here is my solution.
It's based on https://stackoverflow.com/a/69914674/7011814
by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).
Here is a head stone of the solution:
#OptIn(FlowPreview::class)
#Composable
fun <T>multipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> T
) : T {
val debounceState = remember {
MutableSharedFlow<() -> Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
val result = content(
object : MultipleEventsCutterManager {
override fun processEvent(event: () -> Unit) {
debounceState.tryEmit(event)
}
}
)
LaunchedEffect(true) {
debounceState
.debounce(CLICK_COLLAPSING_INTERVAL)
.collect { onClick ->
onClick.invoke()
}
}
return result
}
#OptIn(FlowPreview::class)
#Composable
fun MultipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> Unit
) {
multipleEventsCutter(content)
}
The first function can be used as a wrapper around your code like this:
MultipleEventsCutter { multipleEventsCutterManager ->
Button(
onClick = { multipleClicksCutter.processEvent(onClick) },
...
) {
...
}
}
And you can use the second one to create your own modifier, like next one:
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
multipleEventsCutter { manager ->
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { manager.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
}
Just add two lines in your styles. This will disable multitouch in whole application:
<style name="AppTheme" parent="...">
...
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
</style>

Is there a Jetpack Compose equivalent for android:keepScreenOn to keep screen alive?

I have a Composable that uses a Handler to slowly update the alpha of an image inside a composable.
However, I'm seeing that the screen turns off before the animation could complete.
In XML layouts, we could keep it alive using
android:keepScreenOn
or
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
Is there a way to do this using compose without using the wake lock permission?
You can use LocalContext to get activity, and it has a window on which you can apply needed flags.
In such cases, when you need to run some code on both view appearance and disappearance, DisposableEffect can be used:
#Composable
fun KeepScreenOn() {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = context.findActivity()?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
fun Context.findActivity(): Activity? {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}
Usage: when screen appears flag is set to on, and when disappears - it's cleared.
#Composable
fun Screen() {
KeepScreenOn()
}
As #Louis CAD correctly pointed out, you can have problems if you use this "view" in many views: if one view appears that uses it, and then disappears previous views that also used it, it will reset the flag.
I haven't found a way of tracking flags state to update the view, I think #Louis CAD solution is OK until Compose have some system support.
This one should be safe from any interference if you have multiple usages in the same composition:
#Composable
fun KeepScreenOn() = AndroidView({ View(it).apply { keepScreenOn = true } })
Usage is then as simple as that:
if (screenShallBeKeptOn) {
KeepScreenOn()
}
In a more Compose way:
#Composable
fun KeepScreenOn() {
val currentView = LocalView.current
DisposableEffect(Unit) {
currentView.keepScreenOn = true
onDispose {
currentView.keepScreenOn = false
}
}
}
This will be disposed of as soon as views disappear from the composition.
Usage is as simple as:
#Composable
fun Screen() {
KeepScreenOn()
}
This is how I implemented mine
In my Composable function I have a button to activate the FLAG_KEEP_SCREEN_ON or clear FLAG_KEEP_SCREEN_ON
#Composable
fun MyButton() {
var state by rememberSaveable {
mutableStateOf(false)
}
val context = LocalContext.current
Button(
...
modifier = Modifier
.clickable {
state = !state
keepScreen(state, context)
}
...
)
}
fun keepScreen(state: Boolean, context : Context) {
val activity = context as Activity
if(state) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
The code below works best for me.
#Composable
fun ScreenOnKeeper() {
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
The code below didn't work when I toggle its presence (conditionally add, remove, add the component).
#Composable
fun ScreenOnKeeper() {
val view = LocalView.current
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose {
view.keepScreenOn = false
}
}
}

Remember scroll position in lazycolumn?

I am aware of the remember lazy list state and it works fine
setContent {
Test(myList) // Call Test with a dummy list
}
#Composable
fun Test(data: List<Int>){
val state = rememberLazyListState()
LazyColumn(state = state) {
items(data){ item ->Text("$item")}
}
}
It will remember scroll position and after every rotation and change configuration it will be the same
But whenever I try to catch data from database and use some method like collectAsState
it doesn't work and it seem an issue
setContent{
val myList by viewModel.getList.collectAsState(initial = listOf())
Test(myList)
}
Unfortunately for now there's not a native way to do so, but you can use this code:
val listState = rememberLazyListState()
listState has 3 methods:
firstVisibleItemIndex
firstVisibleItemScrollOffset
isScrollInProgress
All of them are State() so you will always get the data as it updates. For example, if you start scrolling the list, isScrollInProgress will change from false to true.
SAVE AND RESTORE STATE
val listState: LazyListState = rememberLazyListState(viewModel.index, viewModel.offset)
LaunchedEffect(key1 = listState.isScrollInProgress) {
if (!listState.isScrollInProgress) {
viewModel.index = listState.firstVisibleItemIndex
viewModel.offset = listState.firstVisibleItemScrollOffset
}
}

How can I get onTouchEvent in Jetpack Compose?

In normal view, we can have onTouchEvent
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {}
MotionEvent.ACTION_MOVE -> {}
MotionEvent.ACTION_UP -> {}
else -> return false
}
invalidate()
return true
}
In Jetpack Compose, I can only find we have the tapGestureFilter in the modifier, which only takes the action from the ACTION_UP only.
Modifier
.tapGestureFilter { Log.d("Track", "Tap ${it.x} | ${it.y}") }
.doubleTapGestureFilter { Log.d("Track", "DoubleTap ${it.x} | ${it.y}") }
Is there an equivalent onTouchEvent for Jetpack Compose?
We have a separate package for that, which is pretty useful.
There are two main extension functions that would be suitable for you:
pointerInput - docs
pointerInteropFilter - docs
If you want to handle and process the event I recommend using pointerInteropFilter which is the analogue of View.onTouchEvent. It's used along with modifier:
Column(modifier = Modifier.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {}
MotionEvent.ACTION_MOVE -> {}
MotionEvent.ACTION_UP -> {}
else -> false
}
true
})
That will be Compose adjusted code to your specified View.onTouchEvent sample.
P.S. Don't forget about #ExperimentalPointerInput annotation.
pointerInteropFilter is not described as preferred way to use if you are not using touch api with interoperation with existing View code.
A special PointerInputModifier that provides access to the underlying
MotionEvents originally dispatched to Compose. Prefer pointerInput and
use this only for interoperation with existing code that consumes
MotionEvents. While the main intent of this Modifier is to allow
arbitrary code to access the original MotionEvent dispatched to
Compose, for completeness, analogs are provided to allow arbitrary
code to interact with the system as if it were an Android View.
You can use pointerInput , awaitTouchDown for MotionEvent.ACTION_DOWN, and awaitPointerEvent for MotionEvent.ACTION_MOVE and MotionEvent.ACTION_UP
val pointerModifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
// ACTION_DOWN here
do {
//This PointerEvent contains details including
// event, id, position and more
val event: PointerEvent = awaitPointerEvent()
// ACTION_MOVE loop
// Consuming event prevents other gestures or scroll to intercept
event.changes.forEach { pointerInputChange: PointerInputChange ->
pointerInputChange.consumePositionChange()
}
} while (event.changes.any { it.pressed })
// ACTION_UP is here
}
}
}
Some key notes about gestures
pointerInput propagation is when you have more than one goes from
bottom one to top one.
If children and parent are listening for input changes it propagates
from inner children to outer then parent. Unlike touch events from
parent to children
If you don't consume events other events like scroll drag can
interfere or consume events, most of the events check if it's
consumed before propagating to them
detectDragGestures source code for instance
val down = awaitFirstDown(requireUnconsumed = false)
var drag: PointerInputChange?
var overSlop = Offset.Zero
do {
drag = awaitPointerSlopOrCancellation(
down.id,
down.type
) { change, over ->
change.consumePositionChange()
overSlop = over
}
} while (drag != null && !drag.positionChangeConsumed())
So when you need to prevent other events to intercept
call pointerInputChange.consumeDown() after awaitFirstDown, call
pointerInputChange.consumePositionChange() after awaitPointerEvent
and awaitFirstDown() has requireUnconsumed parameter which is
true by default. If you set it to false even if a pointerInput consumes
down before your gesture you still get it. This is also how events like drag use it to get first down no matter what.
Every available event you see detectDragGestures,
detectTapGestures even awaitFirstDown use awaitPointerEvent
for implementation, so using awaitFirstDown, awaitPointerEvent
and consuming changes you can configure your own gestures.
For instance, this is a function i customized from original detectTransformGestures only to be invoked with specific number of pointers down.
suspend fun PointerInputScope.detectMultiplePointerTransformGestures(
panZoomLock: Boolean = false,
numberOfPointersRequired: Int = 2,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
) {
forEachGesture {
awaitPointerEventScope {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val downPointerCount = event.changes.size
// If any position change is consumed from another pointer or pointer
// count that is pressed is not equal to pointerCount cancel this gesture
val canceled = event.changes.any { it.positionChangeConsumed() } || (
downPointerCount != numberOfPointersRequired)
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
rotation += rotationChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion =
abs(rotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onGesture(centroid, panChange, zoomChange, effectiveRotation)
}
event.changes.forEach {
if (it.positionChanged()) {
it.consumeAllChanges()
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
}
}
}
Edit
As of 1.2.0-beta01, partial consumes like
PointerInputChange.consemePositionChange(),
PointerInputChange.consumeDownChange(), and one for consuming all changes PointerInputChange.consumeAllChanges() are deprecated
PointerInputChange.consume()
is the only one to be used preventing other gestures/event.
Also i have a tutorial here that covers gestures in detail
Maybe a bit late, but since compose is constantly updating, this is how I do it as of today:
Modifier
.pointerInput(Unit) {
detectTapGestures {...}
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount -> ...}
})
We also have detectHorizontalDragGestures and detectVerticalDragGestures among others to help us.
ps: 1.0.0-beta03
After did some research, looks like can use dragGestureFilter, mixed with tapGestureFilter
Modifier
.dragGestureFilter(object: DragObserver {
override fun onDrag(dragDistance: Offset): Offset {
Log.d("Track", "onActionMove ${dragDistance.x} | ${dragDistance.y}")
return super.onDrag(dragDistance)
}
override fun onStart(downPosition: Offset) {
Log.d("Track", "onActionDown ${downPosition.x} | ${downPosition.y}")
super.onStart(downPosition)
}
override fun onStop(velocity: Offset) {
Log.d("Track", "onStop ${velocity.x} | ${velocity.y}")
super.onStop(velocity)
}
}, { true })
.tapGestureFilter {
Log.d("NGVL", "onActionUp ${it.x} | ${it.y}")
}
The reason still use tagGestureFilter, is because the onStop doesn't provide the position, but just the velocity, hence the tapGestureFilter does help provide the last position (if needed)

Categories

Resources