Search code examples
androidonclicklistenerandroid-buttonontouchlistener

Button left in highlighted state with touchListener and clickListener


I'm having a problem with my Button staying in a highlighted state, after doing the following:

public class MainActivity extends AppCompatActivity {

    @SuppressLint("ClickableViewAccessibility")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AppCompatButton button = (AppCompatButton) findViewById(R.id.mybutton);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d("Test", "calling onClick");
            }
        });
        button.setOnTouchListener(new View.OnTouchListener() {

            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN: {
                        v.getBackground().setColorFilter(0xe0f47521,PorterDuff.Mode.SRC_ATOP);
                        v.invalidate();
                        break;
                    }
                    case MotionEvent.ACTION_UP: {
                        v.getBackground().clearColorFilter();
                        v.invalidate();
                        v.performClick();
                        Log.d("Test", "Performing click");
                        return true;
                    }
                }
                return false;
            }
        });

    }
}

Concerning the code above, when using it, I'm expecting the button click to be handled by the touch, and by returning "true" the handling should stop at the touchListener.

But this is not the case. The button stays in a highlighted state, even though the click is being called.

What I get is:

Test - calling onClick
Test - Performing click

on the other hand, if I'm using the following code, the button is clicked, same prints, but the button doesn't end up stuck in a highlighted state:

public class MainActivity extends AppCompatActivity {

    @SuppressLint("ClickableViewAccessibility")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AppCompatButton button = (AppCompatButton) findViewById(R.id.mybutton);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d("Test", "calling onClick");
            }
        });
        button.setOnTouchListener(new View.OnTouchListener() {

            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN: {
                        v.getBackground().setColorFilter(0xe0f47521,PorterDuff.Mode.SRC_ATOP);
                        v.invalidate();
                        break;
                    }
                    case MotionEvent.ACTION_UP: {
                        v.getBackground().clearColorFilter();
                        v.invalidate();
                        // v.performClick();
                        Log.d("Test", "Performing click");
                        return false;
                    }
                }
                return false;
            }
        });

    }
}

I'm a bit confused as to what's the responder chain to the touch event. My guess is that it's:

1) TouchListener

2) ClickListener

3) ParentViews

Can someone confirm this as well?


Solution

  • Such customizations need no programmatically modifications. You can do it simply in xml files. First of all, delete the setOnTouchListener method that you provide in the onCreate entirely. Next, define a selector color in the res/color directory like the following. (if the directory doesn't exist, create it)

    res/color/button_tint_color.xml

    <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:color="#e0f47521" android:state_pressed="true" />
        <item android:color="?attr/colorButtonNormal" android:state_pressed="false" />
    </selector>
    

    Now, set it to the button's app:backgroundTint attribute:

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/mybutton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Button"
        app:backgroundTint="@color/button_tint_color" />
    


    Visual Result:

    enter image description here



    EDITED: (to address touch event issue)

    From an overall point of view, the flow of the touch event starts from the Activity, then flows down to the layout (from the parent to the child layouts), and then to the views. (LTR flow in the following picture)

    enter image description here

    When the touch event reaches the target view, the view can handle the event then decide to pass it to the prior layouts/activity or not (returning false of true in onTouch method). (RTL flow in the above picture)

    Now let's take a look at the View's source code to gain a deeper insight into the touch event flows. By taking a look at the implementation of the dispatchTouchEvent, we'd see that if you set an OnTouchListener to the view and then return true in its onTouch method, the onTouchEvent of the view won't be called.

    public boolean dispatchTouchEvent(MotionEvent event) {
        // removed lines for conciseness...
        boolean result = false;    
        // removed lines for conciseness...
        if (onFilterTouchEventForSecurity(event)) {
            // removed lines for conciseness...
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) { // <== right here!
                result = true;
            }
            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }
        // removed lines for conciseness...
        return result;
    }
    

    Now, look at the onTouchEvent method where the event action is MotionEvent.ACTION_UP. We see that perform-click action happens there. So, returning true in the OnTouchListener's onTouch and consequently not calling the onTouchEvent, causes not calling the OnClickListener's onClick.

    There is another issue with not calling the onTouchEvent, which is related to the pressed-state and you mentioned in the question. As we can see in the below code block, there is an instance of UnsetPressedState that calls setPressed(false) when it runs. The result of not calling setPressed(false) is that the view gets stuck in the pressed state and its drawable state doesn't change.

    public boolean onTouchEvent(MotionEvent event) {
        // removed lines for conciseness...
        if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    // removed lines for conciseness...
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                        // removed lines for conciseness...
                        if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                            // removed lines for conciseness...
                            if (!focusTaken) {
                                // Use a Runnable and post this rather than calling
                                // performClick directly. This lets other visual state
                                // of the view update before click actions start.
                                if (mPerformClick == null) {
                                    mPerformClick = new PerformClick();
                                }
                                if (!post(mPerformClick)) {
                                    performClickInternal();
                                }
                            }
                        }
                        if (mUnsetPressedState == null) {
                            mUnsetPressedState = new UnsetPressedState();
                        }
                        if (prepressed) {
                            postDelayed(mUnsetPressedState,
                                    ViewConfiguration.getPressedStateDuration());
                        } else if (!post(mUnsetPressedState)) {
                            // If the post failed, unpress right now
                            mUnsetPressedState.run();
                        }
                        // removed lines for conciseness...
                    }
                    // removed lines for conciseness...
                    break;
                // removed lines for conciseness...
            }
            return true;
        }
        return false;
    }
    

    UnsetPressedState:

    private final class UnsetPressedState implements Runnable {
        @Override
        public void run() {
            setPressed(false);
        }
    }
    


    Regarding the above descriptions, you can change the code by calling setPressed(false) yourself to change the drawable state where the event action is MotionEvent.ACTION_UP:

    button.setOnTouchListener(new View.OnTouchListener() {
    
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN: {
                    v.getBackground().setColorFilter(0xe0f47521,PorterDuff.Mode.SRC_ATOP);
                    v.invalidate();
                    break;
                }
                case MotionEvent.ACTION_UP: {
                    v.getBackground().clearColorFilter();
                    // v.invalidate();
                    v.setPressed(false);
                    v.performClick();
                    Log.d("Test", "Performing click");
                    return true;
                }
            }
            return false;
        }
    });