5

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?

muetzenflo
  • 5,653
  • 4
  • 41
  • 82

7 Answers7

8

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
    }

}
muetzenflo
  • 5,653
  • 4
  • 41
  • 82
2

@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);
    }
}
TimonNetherlands
  • 1,033
  • 1
  • 6
  • 6
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;
    }
}
Finn Marquardt
  • 512
  • 3
  • 9
  • This didn't seem to work for me. Nor did the below answers. However, this solution posted [here](https://stackoverflow.com/a/72445613/1463656) did work. – Jarrett R Oct 04 '22 at 15:04
0

@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.

Alessandro Sperotti
  • 183
  • 1
  • 1
  • 12
0

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.

Chrisser000
  • 71
  • 2
  • 2
0

I have a tricky solution that we do not have to override and modify the onInterceptTouchEvent of motion layout. You simply modify that in the xml file. Hopefully it will complete @muetzenflo's solution.

If you have the motionLayout as your activity root component, then you can follow these steps.

enter image description here

this is the xml code solution

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/activity_main_scene"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="32dp"
        android:alpha="0"
        android:backgroundTint="@color/navi"
        android:text="@string/learn_now"
        app:layout_constraintBottom_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <androidx.constraintlayout.motion.widget.MotionLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/activity_main_scene">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="0dp"
            android:layout_height="100dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/your_own_image" />

    </androidx.constraintlayout.motion.widget.MotionLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
Marfin. F
  • 434
  • 4
  • 16
  • What happens if the button needs to have an animation as well? – cutiko Jul 20 '23 at 12:47
  • @cutiko If the button is animated and put it onto your xml scene, then it will work as expected as well. You can try for imageView click listener above, it will work as well. – Marfin. F Jul 21 '23 at 03:14
-1

To setup onClick action on the view, use:

android:onClick="handleAction"

inside the MotionLayout file, and define "handleAction" in your class.

Karan Dhillon
  • 1,186
  • 1
  • 6
  • 14
  • The `android:onClick` attribute does nothing else than setting a ClickListener the way I did it. Nevertheless: I tried it and got the same result. I also tried directly binding a method in the VM `android:onClick="@{(view) -> myViewModel.test()}"`. Also no reaction – muetzenflo Apr 21 '20 at 15:50