'MotionLayout prevents ClickListener on all Views
I am using a MotionLayout with a scene-xml:
<Transition
motion:constraintSetStart="@+id/start"
motion:constraintSetEnd="@+id/end"
>
<OnSwipe
motion:touchAnchorId="@+id/v_top_sheet"
motion:touchRegionId="@+id/v_top_sheet_touch_region"
motion:touchAnchorSide="bottom"
motion:dragDirection="dragDown" />
</Transition>
The 2 ConstraintSets
are referencing only 2 View IDs: v_notifications_container
and v_top_sheet
.
In my Activity I want to set a normal ClickListener to one of the other Views in this MotionLayout:
iv_notification_status.setOnClickListener { Timber.d("Hello") }
This line is executed, but the ClickListener is never triggered. I searched other posts, but most of them deal with setting a ClickListener on the same View that is the motion:touchAnchorId
. This is not the case here. The ClickListener is set to a View that is not once mentioned in the MotionLayout setup. If I remove the app:layoutDescription
attribute, the click works.
I also tried to use setOnTouchListener
, but it is also never called.
How can I set a click listener within a MotionLayout?
Solution 1:[1]
small modification of @TimonNetherlands code that works on the pixel4 aswell
class ClickableMotionLayout: MotionLayout {
private var mStartTime: Long = 0
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
if ( event?.action == MotionEvent.ACTION_DOWN ) {
mStartTime = event.eventTime;
}
if ((event?.eventTime?.minus(mStartTime)!! >= ViewConfiguration.getTapTimeout()) && event.action == MotionEvent.ACTION_MOVE) {
return super.onInterceptTouchEvent(event)
}
return false;
}
}
Solution 2:[2]
With the help of this great medium article I figured out that MotionLayout is intercepting click events even though the motion scene only contains an OnSwipe transition.
So I wrote a customized MotionLayout to only handle ACTION_MOVE
and pass all other touch events down the View tree. Works like a charm:
/**
* MotionLayout will intercept all touch events and take control over them.
* That means that View on top of MotionLayout (i.e. children of MotionLayout) will not
* receive touch events.
*
* If the motion scene uses only a onSwipe transition, all click events are intercepted nevertheless.
* This is why we override onInterceptTouchEvent in this class and only let swipe actions be handled
* by MotionLayout. All other actions are passed down the View tree so that possible ClickListener can
* receive the touch/click events.
*/
class ClickableMotionLayout: MotionLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
if (event?.action == MotionEvent.ACTION_MOVE) {
return super.onInterceptTouchEvent(event)
}
return false
}
}
Solution 3:[3]
@muetzenflo's response is the most efficient solution I've seen so far for this problem.
However, only checking the Event.Action
for MotionEvent.ACTION_MOVE
causes the MotionLayout
to respond poorly. It is better to differentiate between movement and a single click by the use of ViewConfiguration.TapTimeout
as the example below demonstrates.
public class MotionSubLayout extends MotionLayout {
private long mStartTime = 0;
public MotionSubLayout(@NonNull Context context) {
super(context);
}
public MotionSubLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MotionSubLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if ( event.getAction() == MotionEvent.ACTION_DOWN ) {
mStartTime = event.getEventTime();
} else if ( event.getAction() == MotionEvent.ACTION_UP ) {
if ( event.getEventTime() - mStartTime <= ViewConfiguration.getTapTimeout() ) {
return false;
}
}
return super.onInterceptTouchEvent(event);
}
}
Solution 4:[4]
@Finn Marquardt's solution is efficient, but making a check only on ViewConfiguration.getTapTimeout()
is not 100% reliable in my opinion and for me, sometimes the click event won't trigger because the duration of the tap is greater than getTapTimeout()
(which is only 100ms). Also Long press is not handled.
Here is my solution, using GestureDetector
:
class ClickableMotionLayout : MotionLayout {
private var isLongPressing = false
private var compatGestureDetector : GestureDetectorCompat? = null
var gestureListener : GestureDetector.SimpleOnGestureListener? = null
init {
setupGestureListener()
setOnTouchListener { v, event ->
if(isLongPressing && event.action == MotionEvent.ACTION_UP){
isPressed = false
isLongPressing = false
v.performClick()
} else {
isPressed = false
isLongPressing = false
compatGestureDetector?.onTouchEvent(event) ?: false
}
}
}
Where setupGestureListener()
is implemented like this:
fun setupGestureListener(){
gestureListener = object : GestureDetector.SimpleOnGestureListener(){
override fun onLongPress(e: MotionEvent?) {
isPressed = progress == 0f
isLongPressing = progress == 0f
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
isPressed = true
performClick()
return true
}
}
compatGestureDetector = GestureDetectorCompat(context, gestureListener)
}
The GestureDetector
handles the touch event only if it's a tap or if it's a long press (and it will manually trigger the "pressed" state). Once the user lifts the finger and the touch event is actually a long press, then a click event is triggered. In any other cases, MotionLayout
will handle the event.
Solution 5:[5]
I'm afraid none of the other answers worked for me, I don't know it's because of an update in the libarary, but I don't get an ACTION_MOVE event on the region set in onSwipe
.
Instead, this is what worked for me in the end:
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.children
/**
* This MotionLayout allows handling of clicks that clash with onSwipe areas.
*/
class ClickableMotionLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr) {
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
// Take all child views that are clickable,
// then see if any of those have just been clicked, and intercept the touch.
// Otherwise, let the MotionLayout handle the touch event.
if (children.filter { it.isClickable }.any {
it.x < event.x && it.x + it.width > event.x &&
it.y < event.y && it.y + it.height > event.y
}) {
return false
}
return super.onInterceptTouchEvent(event)
}
}
Basically, when we get a touch event, we iterate over all children of the MotionLayout and see if any of them (that are clickable) were the target of the event. If so, we intercept the touch event, and otherwise we let the MotionLayout do its thing.
This allows the user to click on clickable views that clashes with the onSwipe area, while also allowing swiping even if the swipe starts on the clickable view.
Solution 6:[6]
To setup onClick action on the view, use:
android:onClick="handleAction"
inside the MotionLayout file, and define "handleAction" in your class.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | Finn Marquardt |
Solution 2 | muetzenflo |
Solution 3 | |
Solution 4 | Alessandro Sperotti |
Solution 5 | Chrisser000 |
Solution 6 | Karan Dhillon |