Drag gestures inside scrollable control - kotlin

I've made a custom control based around a Canvas. It uses two .pointerInput modifiers, one to detect click and one to detect drag so the user can toggle a column of 50 buttons, either by clicking on one at a time or dragging across a number of them to set them all at once. It works well, and now I'd like to have a horizontally scrollable Row containing a number of these. The immediate problem is that the Row, when the .horizontalScroll modifier is applied, swallows vertical movement as well, and even taps, so although I can scroll through a number of controls I'm no longer able to interact with them.
The only example I can find that's similar is the nested scrolling in the Gestures documentation, but that's between two controls using scrolling, and although the outer control is clearly not preventing the inner control receiving events it's not clear how to apply it in my case.
Without pasting huge quantities of code, I'm defining the Row by
#Composable
fun ScrollBoxes() {
Row(
modifier = Modifier
.background(Color.LightGray)
.fillMaxSize()
.horizontalScroll(rememberScrollState())
) {
repeat(20) {
Column {
Text(
"Item $it", modifier = Modifier
.padding(2.dp)
.width(500.dp)
)
JetwashSlide(
model = JetwashSlideViewModel()
)
}
}
}
}
and the modifier of the Canvas is my custom control is set up as
modifier
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { ... }
},
onDragEnd = { ... },
onDrag = { change, dragAmount ->
change.consumeAllChanges();
...
}
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onPress = { it ->
...
}
)
}
A crude approach would be to have a scrollable row of labels and use the presently selected label to determine which full width custom control is presently visible. This wouldn't be as aesthetically pleasing as having the controls scrolling horizontally. Does anyone have an idea how this could be achieved?

Ok, my starting point was this tutorial on how to make a scrolling pager from scratch;
https://fvilarino.medium.com/creating-a-viewpager-in-jetpack-compose-332d6a9181a5
Using that, you can get a horizontally scrolling row of instances of a control. The problem is that I wanted horizontal swipes to be handled by the pager, and taps and vertical swipes to be handled by the custom control, and although the custom control gets the gestures if made over the control, it couldn't pass them up to the pager if they were horizontal swipes, so you could only scroll using the gaps between the custom controls. I expected that the control could use .detectVerticalDragGestures and .detectTapGestures and the pager could use .detectHorizontalDragGestures, but it wasn't that simple.
I ended up pulling code from the Jetpack source into my own code so I could modify it to produce my own gesture detector which captures vertical scroll and tap events but does not capture horizontal scroll;
suspend fun PointerInputScope.detectVerticalDragOrTapGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
onClick: ((Offset) -> Unit) = { },
model: JetwashSlideViewModel
) {
forEachGesture {
awaitPointerEventScope {
model.inhibitGesture = false
val down = awaitFirstDown(requireUnconsumed = false)
//val drag = awaitVerticalTouchSlopOrCancellation(down.id, onVerticalDrag)
val drag = awaitTouchSlopOrCancellation(
down.id
) { change: PointerInputChange, dragAmount: Offset ->
if (abs(dragAmount.y) > abs(dragAmount.x)) {
//This is a real swipe down the slide
change.consumeAllChanges()
onVerticalDrag(change, dragAmount.y)
}
}
if (model.inhibitGesture) {
onDragCancel()
} else {
if (drag != null) {
onDragStart.invoke(drag.position)
if (
verticalDrag(drag.id) {
onVerticalDrag(it, it.positionChange().y)
}
) {
onDragEnd()
} else {
onDragCancel()
}
} else {
//click.
if (!model.inhibitGesture)
onClick(down.position)
}
}
}
}
}
Now in the pager, it was using .horizontalDrag. This is fine if you can actually drag purely horizontally or purely vertically, but you can't and swipes that were intended to be vertical on the inner control often had a tiny bit of horizontal motion which caused the pager to intercept it. So in the pager, I also had to copy and modify some code to make my own .horizontalDrag;
suspend fun AwaitPointerEventScope.horizontalDrag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit
): Boolean = drag(
pointerId = pointerId,
onDrag = onDrag,
motionFromChange = { if (abs(it.positionChangeIgnoreConsumed().x) > 10) it.positionChangeIgnoreConsumed().x else 0f },
motionConsumed = { it.positionChangeConsumed() }
)
This only triggers if the horizontal component of the movement is larger than 10px.
Finally, since a horizontal scroll may also have some element of vertical which could affect the inner controls, in my onPagerScrollStart and onPagerScrollFinished callbacks I set and clear a flag in the model, inhibitGesture which causes the inner control to disregard gestures that it happens to get while the pager is in the process of being scrolled.

Related

How to detect focus move direction on focus change?

I have a Composable Row that has some click listeners:
val action = { ... }
Row(Modifier.clickable(action) {
IconButton({ /* other, unrelated action */}) {}
Text("This isn't clickable")
Checkbox({ /* something that calls action() on toggle */ })
}
When tabbing through this UI, the focus goes to the IconButton, then the Checkbox, then the Row. I want it to skip the row. I've implemented that by adding to the Row modifier:
val manager = LocalFocusManager.current
Row(Modifier.clickable(action).onFocusChanged {
if (it.isFocused) manager.moveFocus(FocusDirection.Next)
}) { /* same content */ }
... which works when moving forward, but not when moving backward (using Shift-Tab). And of course that's because of the FocusDirection.Next, which should instead be Previous when moving backward. But how do I detect that? The focus event doesn't have a direction property.
Update
I tried doing this by manually detecting if shift is pressed, which feels more like a hack than a solution:
val keys = LocalWindowInfo.current.keyboardModifiers
/* in onFocusChanged */
manager.moveFocus(if (keys.isShiftPressed) FocusDirection.Previous else FocusDirection.Next)
.. and also, it doesn't work. Calling manager.moveFocus(FocusDirection.Previous) if shift is pressed causes an infinite loop and application crash, presumably because it's setting the focus back to where it came from.

Composable Focus

I have a project where we display a graph, this graph is in an Box which is scrollable.
By opening the view, we need to center the root node which causes the problem currently.
Determining the position and setting the values of the states is currently done the following way:
.onGloballyPositioned { coordinates -> scrollBy = coordinates.positionInParent().y - dpstate!!.scrollState.firstVisibleItemScrollOffset
}
.onFocusChanged {
if(it.isFocused ){
print("is focused")
scope.launch { dpstate!!.scrollState.animateScrollBy(scrollBy)
dpstate!!.offsetState.value = Offset(leftX.toFloat(),dpstate!!.offsetState.value.y)
}
}
}
in the modifier of the box.
The state dpstate is an instance of the following:
data class DisplayState(
val scrollState: LazyListState,
val scaleState: MutableState<Float>,
val offsetState: MutableState<Offset>,
val editState: MutableState<Boolean>,
val showInfo:Map<Int, MutableState<Boolean>>,)
Important is, that I need to center by opening it, not by clicking a button.
My try was in the calling code of all of this the following code:
DisposableEffect(Unit){
com.github.tukcps.appel.ui.rendering.focusRequester!!.requestFocus()
onDispose { }
}
There is no exception, it just don't do anything, thanks for your help.

Optimise Kotlin code for button clicked state

I want to change the button background when the button clicked, the function is work by using this code
bank1.setOnClickListener {
bank1.setBackgroundResource(R.drawable.selected_btn_border_blue_bg);
bank2.setBackgroundResource(R.drawable.default_option_border_bg);
bank3.setBackgroundResource(R.drawable.default_option_border_bg);
bank4.setBackgroundResource(R.drawable.default_option_border_bg);
}
bank2.setOnClickListener {
bank2.setBackgroundResource(R.drawable.selected_btn_border_blue_bg);
bank1.setBackgroundResource(R.drawable.default_option_border_bg);
bank3.setBackgroundResource(R.drawable.default_option_border_bg);
bank4.setBackgroundResource(R.drawable.default_option_border_bg);
}
bank3.setOnClickListener {
bank3.setBackgroundResource(R.drawable.selected_btn_border_blue_bg);
bank2.setBackgroundResource(R.drawable.default_option_border_bg);
bank1.setBackgroundResource(R.drawable.default_option_border_bg);
bank4.setBackgroundResource(R.drawable.default_option_border_bg);
}
bank4.setOnClickListener {
bank4.setBackgroundResource(R.drawable.selected_btn_border_blue_bg);
bank2.setBackgroundResource(R.drawable.default_option_border_bg);
bank3.setBackgroundResource(R.drawable.default_option_border_bg);
bank1.setBackgroundResource(R.drawable.default_option_border_bg);
}
But it kinda hardcoded, and make it to so many lines, any way to make the code shorter?
I would keep a variable that keeps track of the selected one like
private var selectedBank: View? = null
And then do
arrayOf(bank1, bank2, bank3, bank4).forEach {
it.setOnClickListener {
selectedBank?.setBackgroundResource(R.drawable.default_option_border_bg)
selectedBank = it
it.setBackgroundResource(R.drawable.selected_btn_border_blue_bg)
}
}
you only need to deselected the previous selected one

Swipe to Recycler View Item until displayed

I have a nested Recycler lists which don't have ~~scrolling~~ & nestedScrollingEnabled=false. I'm attempting to swipe up and click on the recycler item by it's text. Having issues with determining when to swipe and how far.
UPDATE: This may have scrolling, I may need to specify the ViewHolder of the Item with text instead of the view with text... Experimenting...
parent_recycler_list
recycler_list
List item A
List item B
recycler_list
List item A
List item B
So far I am able to find the item and try to click on it:
Espresso.onView(
CoreMatchers.allOf(
ViewMatchers.withId(R.id.recycler_list),
ViewMatchers.hasDescendant(recyclerViewItemWithText(text))
)
).perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
recyclerViewItemWithText(text),
ViewActions.click()
)
)
fun recyclerViewItemWithText(text: String) = object : BoundedMatcher<View, View>(View::class.java) {
override fun describeTo(description: Description?) {
description?.appendText("Searching for text with: $text")
}
override fun matchesSafely(item: View?): Boolean {
val views = ArrayList<View>()
item?.findViewsWithText(views, text, View.FIND_VIEWS_WITH_TEXT)
return when (views.size) {
1 -> true
else -> false
}
}
}
This only works by it self when the list item is displayed.
I have tried to swipe until the view item is displayed:
Espresso.onView(ViewMatchers.withId(R.id.parent_recycler_list)).perform(
ViewActions.repeatedlyUntil(
ViewActions.swipeUp(),
Matchers.allOf(
ViewMatchers.hasDescendant(ViewMatchers.withText(text)),
isCompletelyDisplayed()
), 10
)
)
This will always swipe at least once... and can swipe past the view item I'm looking for.
Is there a way I can be more precise in when and how far to swipe?
I'm a bit of a novice still and don't know much about custom swipe actions on view holders. Thanks
When trying to use nestedScrollTo()
java.lang.RuntimeException: Action will not be performed because the
target view does not match one or more of the following constraints:
(view has effective visibility=VISIBLE and is descendant of a: (is
assignable from class: class androidx.core.widget.NestedScrollView))
You can simply use ViewActions.scrollTo() if your nested recycler views do not have nested scrolling enabled, but you'll need to tweak the action first because it does not support NestedScrollView:
fun nestedScrollTo(): ViewAction = object : ViewAction {
private val scrollTo = ViewActions.scrollTo()
override fun getConstraints(): Matcher<View> {
return Matchers.allOf(
ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE),
ViewMatchers.isDescendantOfA(Matchers.anyOf(ViewMatchers.isAssignableFrom(NestedScrollView::class.java))))
}
override fun getDescription(): String = scrollTo.description
override fun perform(uiController: UiController, view: View) = scrollTo.perform(uiController, view)
}
Then use the new custom action to scroll, for example:
onView(withText("query")).perform(nestedScrollTo(), click())
Avoid using swipe in this use case if possible, they can be unreliable at times.
6 months later I figured out a bit more about Recycler lists and that I was trying to use them wrong, or at least figured out which Recycler actions work (some don't seem to work at all). This had nothing to do with Nested scrolling even though I have nested recycler lists.
Needed to use a swipe up action as a back up for when the list doesn't exist in the hierarchy.
Also there is a Potential infinite loop.
fun tapRecyclerItem(titleText: String) {
val parentList by lazy { onView(withId(R.id.parent_recycler_list)) }
try {
//Try to scroll to the item in the child list
onView(allOf(
withId(R.id.recycler_list),
hasDescendantWithText(titleText)
)).perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendantWithText(titleText),
ViewActions.scrollTo()
)
)
// Tap the title
onView(allOf(withId(R.id.title), withText(titleText))).tap()
} catch (ex: NoMatchingViewException) {
// Swipe up and try again
parentList.perform(swipeCenterUp())
tapRecyclerItem(titleText)
}
}
fun hasDescendantWithText(text: String): Matcher<View> {
return Matchers.allOf(
hasDescendant(withText(text)),
withEffectiveVisibility(VISIBLE)
)
}
fun swipeCenterUp(): ViewAction? {
return ViewActions.actionWithAssertions(
GeneralSwipeAction(
Swipe.FAST,
GeneralLocation.CENTER,
GeneralLocation.TOP_CENTER,
Press.FINGER
)
)
}

How to trigger PC Keyboard inputs in Kotlin Desktop Compose

I am going to develop a POS system using Kotlin Jetpack Compose and I wanna know how to trigger keyboard input events inside my project.
In Compose Desktop You can listen for key events using onKeyEvent Window parameter:
Window(
onCloseRequest = ::exitApplication,
visible = visible,
onKeyEvent = {
if (it.isCtrlPressed && it.key == Key.A) {
println("Ctrl + A is pressed")
true
} else {
// let other handlers receive this event
false
}
}
) {
App()
}
An other options, which will also work for Compose in Android, is using Modifier.onKeyEvent. As documentation says:
will allow it to intercept hardware key events when it (or one of its children) is focused.
So you need to make an item or one of its children focusable and focused. Check out more about focus in compose in this article
To do this you need a FocusRequester, in my example I'm asking focus when view renders using LaunchedEffect.
For the future note, that if user taps on a text field, or an other focusable element will gain focus, your view will loose it. If this focused view is inside your view with onKeyEvent handler, it still gonna work.
An empty box cannot become focused, so you need to add some size with a modifier. It still will be invisible:
val requester = remember { FocusRequester() }
Box(
Modifier
.onKeyEvent {
if (it.isCtrlPressed && it.key == Key.A) {
println("Ctrl + A is pressed")
true
} else {
// let other handlers receive this event
false
}
}
.focusRequester(requester)
.focusable()
.size(10.dp)
)
LaunchedEffect(Unit) {
requester.requestFocus()
}
Alternatively just add content to Box so it will stretch and .size modifier won't be needed anymore
Following the second option of the Philip answer is possible to get a strange behavior when you set the focus and, for some reason, click inside application window. Doing this, is possible "lost" the focus and the key events are not propper handled.
In order to avoid this the suggestion is manually handle this problem by adding a click/tap modifier, which just specifies that when detect a click/tap the requester requests the focus again. See below:
val requester = FocusRequester()
Box(
Modifier
//pointer input handles [onPress] to force focus to the [requester]
.pointerInput(key1 = true) {
detectTapGestures(onPress = {
requester.requestFocus()
})
}
.onKeyEvent {
if (it.isCtrlPressed && it.key == Key.A) {
println("Ctrl + A is pressed")
true
} else {
// let other handlers receive this event
false
}
}
.focusRequester(requester)
.focusable()
.fillMaxSize()
.background(Color.Cyan)
)
LaunchedEffect(Unit) {
requester.requestFocus()
}