How To Use Multiple TouchDelegate

Viewed 8418

i have two ImageButtons, each inside a RelativeLayout and these two RelativeLayouts are in another RelativeLayout, i want to set TouchDelegate for each ImageButton. If normally i add TouchDelegate to each ImageButton and it's parent RelativeLayout then just one ImageButton works properly, Another one doesn't extend it's clicking area. So PLease help me on how to use TouchDelegate in both ImageButtons. If it's not possible then what can be a effective way to extend the clicking area of a view? Thanks in advance ........

Here is my xml code:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout android:id="@+id/FrameContainer"
android:layout_width="fill_parent" android:layout_height="fill_parent"
xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout android:layout_alignParentLeft="true"
    android:id="@+id/relativeLayout3" android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <RelativeLayout android:layout_alignParentLeft="true"
        android:id="@+id/relativeLayout1" android:layout_width="113dip"
        android:layout_height="25dip">
        <ImageButton android:id="@+id/tutorial1"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:background="@null" android:src="@drawable/tutorial" />
    </RelativeLayout>
    <RelativeLayout android:layout_alignParentLeft="true"
        android:id="@+id/relativeLayout2" android:layout_width="113dip"
        android:layout_height="25dip" android:layout_marginLeft="100dip">
        <ImageButton android:id="@+id/tutorial2"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:background="@null" android:src="@drawable/tutorial"
            android:layout_marginLeft="50dip" />
    </RelativeLayout>
</RelativeLayout>

My Activity class :

import android.app.Activity;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.TouchDelegate;
import android.view.View;
import android.widget.ImageButton;
import android.widget.Toast;

public class TestTouchDelegate extends Activity {
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    View mParent1 = findViewById(R.id.relativeLayout1);
    mParent1.post(new Runnable() {
        @Override
        public void run() {
            Rect bounds1 = new Rect();
            ImageButton mTutorialButton1 = (ImageButton) findViewById(R.id.tutorial1);
            mTutorialButton1.setEnabled(true);
            mTutorialButton1.setOnClickListener(new View.OnClickListener() {
                public void onClick(View view) {
                    Toast.makeText(TestTouchDelegate.this, "Test TouchDelegate 1", Toast.LENGTH_SHORT).show();
                }
            });

            mTutorialButton1.getHitRect(bounds1);
            bounds1.right += 50;
            TouchDelegate touchDelegate1 = new TouchDelegate(bounds1, mTutorialButton1);

            if (View.class.isInstance(mTutorialButton1.getParent())) {
                ((View) mTutorialButton1.getParent()).setTouchDelegate(touchDelegate1);
            }
        }
    });

    //View mParent = findViewById(R.id.FrameContainer);
    View mParent2 = findViewById(R.id.relativeLayout2);
    mParent2.post(new Runnable() {
        @Override
        public void run() {
            Rect bounds2 = new Rect();
            ImageButton mTutorialButton2 = (ImageButton) findViewById(R.id.tutorial2);
            mTutorialButton2.setEnabled(true);
            mTutorialButton2.setOnClickListener(new View.OnClickListener() {
                public void onClick(View view) {
                    Toast.makeText(TestTouchDelegate.this, "Test TouchDelegate 2", Toast.LENGTH_SHORT).show();
                }
            });

            mTutorialButton2.getHitRect(bounds2);
            bounds2.left += 50;
            TouchDelegate touchDelegate2 = new TouchDelegate(bounds2, mTutorialButton2);

            if (View.class.isInstance(mTutorialButton2.getParent())) {
                ((View) mTutorialButton2.getParent()).setTouchDelegate(touchDelegate2);
            }
        }
    });

}

}

10 Answers

You can use composite pattern to be able to add more than one TouchDelegate to the View. Steps:

  1. Create TouchDelegateComposite (no matter what view you'll pass as an argument, it's used just to get the Context)
  2. Create necessary TouchDelegates and add them to composite
  3. Add composite to view as they recommend here (via view.post(new Runnable))

    public class TouchDelegateComposite extends TouchDelegate {
    
        private final List<TouchDelegate> delegates = new ArrayList<TouchDelegate>();
        private static final Rect emptyRect = new Rect();
    
        public TouchDelegateComposite(View view) {
            super(emptyRect, view);
        }
    
        public void addDelegate(TouchDelegate delegate) {
            if (delegate != null) {
                delegates.add(delegate);
            }
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            boolean res = false;
            float x = event.getX();
            float y = event.getY();
            for (TouchDelegate delegate : delegates) {
                event.setLocation(x, y);
                res = delegate.onTouchEvent(event) || res;
            }
            return res;
        }
    
    }
    

Kotlin version of @need1milliondollars's answer:

class TouchDelegateComposite(view: View) : TouchDelegate(Rect(), view) {

    private val delegates: MutableList<TouchDelegate> = ArrayList()

    fun addDelegate(delegate: TouchDelegate) {
        delegates.add(delegate)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        var res = false
        val x = event.x
        val y = event.y
        for (delegate in delegates) {
            event.setLocation(x, y)
            res = delegate.onTouchEvent(event) || res
        }
        return res
    }
}

Fully working Kotlin extension function which allows for multiple views to increase their touch target by the same amount:

// Example of usage
parentLayout.increaseHitAreaForViews(views = *arrayOf(myFirstView, mySecondView, myThirdView))

/*
 * Use this function if a parent view contains more than one view that
 * needs to increase its touch target hit area.
 *
 * Call this on the parent view
 */
fun View.increaseHitAreaForViews(@DimenRes radiusIncreaseDpRes: Int = R.dimen.touch_target_default_radius_increase, vararg views: View) {
    val increasedRadiusPixels = resources.getDimensionPixelSize(radiusIncreaseDpRes)
    val touchDelegateComposite = TouchDelegateComposite(this)
    post {
        views.forEach { view ->
            val rect = Rect()
            view.getHitRect(rect)
            rect.top -= increasedRadiusPixels
            rect.left -= increasedRadiusPixels
            rect.bottom += increasedRadiusPixels
            rect.right += increasedRadiusPixels
            touchDelegateComposite.addDelegate(TouchDelegate(rect, view))
        }
        touchDelegate = touchDelegateComposite
    }
}

class TouchDelegateComposite(view: View) : TouchDelegate(Rect(), view) {
    private val delegates = mutableListOf<TouchDelegate>()

    fun addDelegate(delegate: TouchDelegate) {
        delegates.add(delegate)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        var res = false
        for (delegate in delegates) {
            event.setLocation(event.x, event.y)
            res = delegate.onTouchEvent(event) || res
        }
        return res
    }
}

I copy the code of TouchDelegate and made some alter. Now it can support multi Views regardless of whether thoese views had common parents

class MyTouchDelegate: TouchDelegate {
private var mDelegateViews = ArrayList<View>()

private var mBoundses = ArrayList<Rect>()

private var mSlopBoundses = ArrayList<Rect>()

private var mDelegateTargeted: Boolean = false

val ABOVE = 1
val BELOW = 2
val TO_LEFT = 4
val TO_RIGHT = 8

private var mSlop: Int = 0

constructor(context: Context): super(Rect(), View(context)) {
    mSlop = ViewConfiguration.get(context).scaledTouchSlop
}

fun addTouchDelegate(delegateView: View, bounds: Rect) {
    val slopBounds = Rect(bounds)
    slopBounds.inset(-mSlop, -mSlop)
    mDelegateViews.add(delegateView)
    mSlopBoundses.add(slopBounds)
    mBoundses.add(Rect(bounds))
}

var targetIndex = -1

override fun onTouchEvent(event: MotionEvent): Boolean {
    val x = event.x.toInt()
    val y = event.y.toInt()
    var sendToDelegate = false
    var hit = true
    var handled = false


    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            targetIndex = -1

            for ((index, item) in mBoundses.withIndex()) {
                if (item.contains(x, y)) {
                    mDelegateTargeted = true
                    targetIndex = index
                    sendToDelegate = true
                }
            }
        }
        MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE -> {
            sendToDelegate = mDelegateTargeted
            if (sendToDelegate) {
                val slopBounds = mSlopBoundses[targetIndex]
                if (!slopBounds.contains(x, y)) {
                    hit = false
                }
            }
        }
        MotionEvent.ACTION_CANCEL -> {
            sendToDelegate = mDelegateTargeted
            mDelegateTargeted = false
        }
    }
    if (sendToDelegate) {
        val delegateView = mDelegateViews[targetIndex]

        if (hit) {
            // Offset event coordinates to be inside the target view
            event.setLocation((delegateView.width / 2).toFloat(), (delegateView.height / 2).toFloat())
        } else {
            // Offset event coordinates to be outside the target view (in case it does
            // something like tracking pressed state)
            val slop = mSlop
            event.setLocation((-(slop * 2)).toFloat(), (-(slop * 2)).toFloat())
        }
        handled = delegateView.dispatchTouchEvent(event)
    }
    return handled
}

}

use it like this

  fun expandTouchArea(viewList: List<View>, touchSlop: Int) {


    val rect = Rect()

    viewList.forEach {
        it.getHitRect(rect)
        if (rect.left == rect.right && rect.top == rect.bottom) {
            postDelay(Runnable { expandTouchArea(viewList, touchSlop) }, 200)
            return
        }
        rect.top -= touchSlop
        rect.left -= touchSlop
        rect.right += touchSlop
        rect.bottom += touchSlop

        val parent = it.parent as? View
        if (parent != null) {
            val parentDelegate = parent.touchDelegate
            if (parentDelegate != null) {
                (parentDelegate as? MyTouchDelegate)?.addTouchDelegate(it, rect)
            } else {
                val touchDelegate = MyTouchDelegate(this)
                touchDelegate.addTouchDelegate(it, rect)
                parent.touchDelegate = touchDelegate
            }
        }
    }
}

I implemented a simple solution from the link that Brendan Weinstein listed above - the static method was incredibly clean and tidy compared to all other solutions. Padding for me simply doesnt work.

My use case was increasing the touch area of 2 small buttons to improve UX.

I have a MyUserInterfaceManager class, where i insert the static function from the youtube video;

public static void changeViewsTouchArea(final View newTouchArea, 
                                        final View viewToChange) {
    newTouchArea.post(new Runnable() {
        @Override
        public void run() {
            Rect rect = new Rect(0,0, newTouchArea.getWidth(), newTouchArea.getHeight());       
            newTouchArea.setTouchDelegate(new TouchDelegate(rect, viewToChange));
        }
    });
}

Then in XML i have the following code (per imagebutton);

<!-- constrain touch area to button / view!-->
<FrameLayout
     android:id="@+id/btn_a_touch_area"
     android:layout_width="@dimen/large_touch_area"
     android:layout_height="@dimen/large_touch_area"
/>

<ImageButton
     android:id="@+id/btn_id_here"
     android:layout_width="@dimen/button_size"
     android:layout_height="@dimen/button_size" />

The in the onCreateView(...) method of my fragment i called the method per View that needs the touch area modified (after binding both Views!);

MyUserInterfaceManager.changeViewsTouchArea(buttonATouchArea, buttonA);

buttonA.setOnClickListener(v -> {
   // add event code here
});

This solution means i can explicitly design and see the touch area in layout files - a must have to ensure im not getting too close to other View touch areas (compared to the "calculate and add pixels" methods recommended by google and others).

Related