57

I have a TextView with multiple ClickableSpans in it. When a ClickableSpan is pressed, I want it to change the color of its text.

I have tried setting a color state list as the textColorLink attribute of the TextView. This does not yield the desired result because this causes all the spans to change color when the user clicks anywhere on the TextView.

Interestingly, using textColorHighlight to change the background color works as expected: Clicking on a span changes only the background color of that span and clicking anywhere else in the TextView does nothing.

I have also tried setting ForegroundColorSpans with the same boundaries as the ClickableSpans where I pass the same color state list as above as the color resource. This doesn't work either. The spans always keep the color of the default state in the color state list and never enter the pressed state.

Does anyone know how to do this?

This is the color state list I used:

<selector xmlns:android="http://schemas.android.com/apk/res/android">
  <item android:state_pressed="true" android:color="@color/pressed_color"/>
  <item android:color="@color/normal_color"/>
</selector>
blahdiblah
  • 33,069
  • 21
  • 98
  • 152
Steven Meliopoulos
  • 4,450
  • 4
  • 34
  • 36
  • You will need to use Spannable object here for the text, there is an example. API: http://developer.android.com/reference/android/text/Spannable.html http://stackoverflow.com/questions/3282940/set-color-of-textview-span-in-android. – Kanak Sony Dec 31 '13 at 11:02
  • 1
    I am of course doing all of the above on a Spannable. And the linked example only shows how to set the color of a span, which is not the problem here. I want the color of a span to change when it is pressed. – Steven Meliopoulos Dec 31 '13 at 11:11
  • 1
    afaik ClickableSpan does not support it directly – pskink Dec 31 '13 at 11:14
  • 1
    Well that's a shame then. Seems like such a simple thing to do, coming from CSS. I will probably just use textColorHighlight in that case. – Steven Meliopoulos Dec 31 '13 at 11:20
  • fortunatelly i was wrong, even more, you can change not only link color (updateDrawState) but also background color (SpanWatcher) – pskink Dec 31 '13 at 12:40
  • Could you elaborate on how to use updateDrawState and SpanWatcher for this purpose? For example how can I determine if the span is being pressed in updateDrawState? – Steven Meliopoulos Dec 31 '13 at 14:41

8 Answers8

76

I finally found a solution that does everything I wanted. It is based on this answer.

This is my modified LinkMovementMethod that marks a span as pressed on the start of a touch event (MotionEvent.ACTION_DOWN) and unmarks it when the touch ends or when the touch location moves out of the span.

public class LinkTouchMovementMethod extends LinkMovementMethod {
    private TouchableSpan mPressedSpan;

    @Override
    public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            mPressedSpan = getPressedSpan(textView, spannable, event);
            if (mPressedSpan != null) {
                mPressedSpan.setPressed(true);
                Selection.setSelection(spannable, spannable.getSpanStart(mPressedSpan),
                        spannable.getSpanEnd(mPressedSpan));
            }
        } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
            TouchableSpan touchedSpan = getPressedSpan(textView, spannable, event);
            if (mPressedSpan != null && touchedSpan != mPressedSpan) {
                mPressedSpan.setPressed(false);
                mPressedSpan = null;
                Selection.removeSelection(spannable);
            }
        } else {
            if (mPressedSpan != null) {
                mPressedSpan.setPressed(false);
                super.onTouchEvent(textView, spannable, event);
            }
            mPressedSpan = null;
            Selection.removeSelection(spannable);
        }
        return true;
    }

    private TouchableSpan getPressedSpan(
            TextView textView,
            Spannable spannable,
            MotionEvent event) {

            int x = (int) event.getX() - textView.getTotalPaddingLeft() + textView.getScrollX();
            int y = (int) event.getY() - textView.getTotalPaddingTop() + textView.getScrollY();

            Layout layout = textView.getLayout();
            int position = layout.getOffsetForHorizontal(layout.getLineForVertical(y), x);

            TouchableSpan[] link = spannable.getSpans(position, position, TouchableSpan.class);
            TouchableSpan touchedSpan = null;
            if (link.length > 0 && positionWithinTag(position, spannable, link[0])) {
                touchedSpan = link[0];
            }

            return touchedSpan;
        }

        private boolean positionWithinTag(int position, Spannable spannable, Object tag) {
            return position >= spannable.getSpanStart(tag) && position <= spannable.getSpanEnd(tag);
        }
    }

This needs to be applied to the TextView like so:

    yourTextView.setMovementMethod(new LinkTouchMovementMethod());

And this is the modified ClickableSpan that edits the draw state based on the pressed state set by the LinkTouchMovementMethod: (it also removes the underline from the links)

public abstract class TouchableSpan extends ClickableSpan {
    private boolean mIsPressed;
    private int mPressedBackgroundColor;
    private int mNormalTextColor;
    private int mPressedTextColor;

    public TouchableSpan(int normalTextColor, int pressedTextColor, int pressedBackgroundColor) {
        mNormalTextColor = normalTextColor;
        mPressedTextColor = pressedTextColor;
        mPressedBackgroundColor = pressedBackgroundColor;
    }

    public void setPressed(boolean isSelected) {
        mIsPressed = isSelected;
    }

    @Override
    public void updateDrawState(TextPaint ds) {
        super.updateDrawState(ds);
        ds.setColor(mIsPressed ? mPressedTextColor : mNormalTextColor);
        ds.bgColor = mIsPressed ? mPressedBackgroundColor : 0xffeeeeee;
        ds.setUnderlineText(false);
    }
}
Stanley Ko
  • 3,383
  • 3
  • 34
  • 60
Steven Meliopoulos
  • 4,450
  • 4
  • 34
  • 36
  • nice, one thing: setting ds.bgColor to 0xffffffff completly hides the default selecrion (useful when using dpad arrows to navigate over the links) – pskink Jan 03 '14 at 15:26
  • Thanks for the hint. I changed it to 0xffeeeeee like in your example now. Why does that make a difference though? Aren't they both completely transparent? – Steven Meliopoulos Jan 03 '14 at 15:51
  • well they are copletly opaque since 0xff******, it should be something like 0xaa****** – pskink Jan 03 '14 at 16:49
  • 5
    Great solution! To avoid the creation of countless `LinkTouchMovementMethod` objects, I'd override the static `LinkMovementMethod.getInstance()` method to return a single instance at any time. – jenzz Aug 18 '14 at 11:11
  • I followed this example and it worked very well. However the touch feedback coloured area is off centre on the text. It seems to be lower vertically than the middle of the text. When I choose show layout bounds in the developer settings, there is no box around each span. Anyone know how this can be altered? – Russ Wheeler Apr 09 '15 at 13:30
  • 1
    @steven Meliopoulos, thanks for your beautiful code but it is not working on android M, please look into the issue, i had put a question on stackoverflow, whose linke is as follows,http://stackoverflow.com/questions/34737514/selector-with-spanable-not-working-on-android-m-but-working-fine-on-below-m – Reprator Jan 12 '16 at 07:08
  • @Steven Meliopoulos, i had solved my issue by converting the textview to button, but please assist me as i am unable to know why it is now working with textview on android M. – Reprator Jan 13 '16 at 07:28
  • @VikramSingh I tested it in on M and works fine for me. – wrozwad Feb 04 '16 at 13:31
  • Take my upvote good sir. God dang, such a simple thing, like wanting a link to work like a link. Tested working on Android O. – Jeffrey Blattman Jul 03 '20 at 19:29
30

Much simpler solution, IMO:

final int colorForThisClickableSpan = Color.RED; //Set your own conditional logic here.

final ClickableSpan link = new ClickableSpan() {
    @Override
    public void onClick(final View view) {
        //Do something here!
    }

    @Override
    public void updateDrawState(TextPaint ds) {
        super.updateDrawState(ds);
        ds.setColor(colorForThisClickableSpan);
    }
};
zundi
  • 2,361
  • 1
  • 28
  • 45
12

All these solutions are too much work.

Just set android:textColorLink in your TextView to some selector. Then create a clickableSpan with no need to override updateDrawState(...). All done.

here a quick example:

In your strings.xml have a declared string like this:

<string name="mystring">This is my message%1$s these words are highlighted%2$s and awesome. </string>

then in your activity:

private void createMySpan(){
    final String token = "#";
    String myString = getString(R.string.mystring,token,token);

    int start = myString.toString().indexOf(token);
    //we do -1 since we are about to remove the tokens afterwards so it shifts
    int finish = myString.toString().indexOf(token, start+1)-1;

    myString = myString.replaceAll(token, "");

    //create your spannable
    final SpannableString spannable = new SpannableString(myString);
    final ClickableSpan clickableSpan = new ClickableSpan() {
            @Override
            public void onClick(final View view) {
                doSomethingOnClick();
            }
        };

    spannable.setSpan(clickableSpan, start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

    mTextView.setMovementMethod(LinkMovementMethod.getInstance());
    mTextView.setText(spannable);
}

and heres the important parts ..declare a selector like this calling it myselector.xml:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_pressed="true" android:color="@color/gold"/>
    <item android:color="@color/pink"/>

</selector>

And last in your TextView in xml do this:

 <TextView
     android:id="@+id/mytextview"
     android:background="@android:color/transparent"
     android:text="@string/mystring"
     android:textColorLink="@drawable/myselector" />

Now you can have a pressed state on your clickableSpan.

Mike
  • 4,550
  • 4
  • 33
  • 47
j2emanue
  • 60,549
  • 65
  • 286
  • 456
  • 5
    `selector` color only work well if we only have 1 link, if we have 2 link, both link we highlight same time when we pressed – Linh Apr 02 '19 at 08:17
10

legr3c's answer helped me a lot. And I'd like to add a few remarks.

Remark #1.

TextView myTextView = (TextView) findViewById(R.id.my_textview);
myTextView.setMovementMethod(new LinkTouchMovementMethod());
myTextView.setHighlightColor(getResources().getColor(android.R.color.transparent));
SpannableString mySpannable = new SpannableString(text);
mySpannable.setSpan(new TouchableSpan(), 0, 7, 0);
mySpannable.setSpan(new TouchableSpan(), 15, 18, 0);
myTextView.setText(mySpannable, BufferType.SPANNABLE);

I applied LinkTouchMovementMethod to a TextView with two spans. The spans were highlighted with blue when clicked them. myTextView.setHighlightColor(getResources().getColor(android.R.color.transparent)); fixed the bug.

Remark #2.

Don't forget to get colors from resources when passing normalTextColor, pressedTextColor, and pressedBackgroundColor.

Should pass resolved color instead of resource id here

Community
  • 1
  • 1
Maksim Dmitriev
  • 5,985
  • 12
  • 73
  • 138
2

try this custom ClickableSpan:

class MyClickableSpan extends ClickableSpan {
    private String action;
    private int fg;
    private int bg;
    private boolean selected;

    public MyClickableSpan(String action, int fg, int bg) {
        this.action = action;
        this.fg = fg;
        this.bg = bg;
    }

    @Override
    public void onClick(View widget) {
        Log.d(TAG, "onClick " + action);
    }

    @Override
    public void updateDrawState(TextPaint ds) {
        ds.linkColor = selected? fg : 0xffeeeeee;
        super.updateDrawState(ds);
    }
}

and this SpanWatcher:

class Watcher implements SpanWatcher {
    private TextView tv;
    private MyClickableSpan selectedSpan = null;

    public Watcher(TextView tv) {
        this.tv = tv;
    }

    private void changeColor(Spannable text, Object what, int start, int end) {
//        Log.d(TAG, "changeFgColor " + what);
        if (what == Selection.SELECTION_END) {
            MyClickableSpan[] spans = text.getSpans(start, end, MyClickableSpan.class);
            if (spans != null) {
                tv.setHighlightColor(spans[0].bg);
                if (selectedSpan != null) {
                    selectedSpan.selected = false;
                }
                selectedSpan = spans[0];
                selectedSpan.selected = true;
            }
        }
    }

    @Override
    public void onSpanAdded(Spannable text, Object what, int start, int end) {
        changeColor(text, what, start, end);
    }

    @Override
    public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
        changeColor(text, what, nstart, nend);
    }

    @Override
    public void onSpanRemoved(Spannable text, Object what, int start, int end) {
    }
}

test it in onCreate:

    TextView tv = new TextView(this);
    tv.setTextSize(40);
    tv.setMovementMethod(LinkMovementMethod.getInstance());

    SpannableStringBuilder b = new SpannableStringBuilder();
    b.setSpan(new Watcher(tv), 0, 0, Spanned.SPAN_INCLUSIVE_INCLUSIVE);

    b.append("this is ");
    int start = b.length();
    MyClickableSpan link = new MyClickableSpan("link0 action", 0xffff0000, 0x88ff0000);
    b.append("link 0");
    b.setSpan(link, start, b.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    b.append("\nthis is ");
    start = b.length();
    b.append("link 1");
    link = new MyClickableSpan("link1 action", 0xff00ff00, 0x8800ff00);
    b.setSpan(link, start, b.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    b.append("\nthis is ");
    start = b.length();
    b.append("link 2");
    link = new MyClickableSpan("link2 action", 0xff0000ff, 0x880000ff);
    b.setSpan(link, start, b.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

    tv.setText(b);
    setContentView(tv);
pskink
  • 23,874
  • 6
  • 66
  • 77
  • Thanks for the answer. I tried it out but it didn't work exactly as I expected. Specifically, the links didn't go back to their original state when they were released or when the touch position moved out of the link so in the end I went with the version I posted above. – Steven Meliopoulos Jan 03 '14 at 14:29
2

This is my solution if you got many click elements (we need an interface): The Interface:

public interface IClickSpannableListener{
  void onClickSpannText(String text,int starts,int ends);
}

The class who manage the event:

public class SpecialClickableSpan extends ClickableSpan{
  private IClickSpannableListener listener;
  private String text;
  private int starts, ends;

  public SpecialClickableSpan(String text,IClickSpannableListener who,int starts, int ends){
    super();
    this.text = text;
    this.starts=starts;
    this.ends=ends;
    listener = who;
  }

  @Override
  public void onClick(View widget) {
     listener.onClickSpannText(text,starts,ends);
  }
}

In main class:

class Main extends Activity  implements IClickSpannableListener{
  //Global
  SpannableString _spannableString;
  Object _backGroundColorSpan=new BackgroundColorSpan(Color.BLUE); 

  private void setTextViewSpannable(){
    _spannableString= new SpannableString("You can click «here» or click «in this position»");
    _spannableString.setSpan(new SpecialClickableSpan("here",this,15,18),15,19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 
    _spannableString.setSpan(new SpecialClickableSpan("in this position",this,70,86),70,86, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    TextView tv = (TextView)findViewBy(R.id.textView1);
    tv.setMovementMethod(LinkMovementMethod.getInstance());
    tv.setText(spannableString);
  }

  @Override
  public void onClickSpannText(String text, int inicio, int fin) {
    System.out.println("click on "+ text);
    _spannableString.removeSpan(_backGroundColorSpan);
    _spannableString.setSpan(_backGroundColorSpan, inicio, fin, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    ((TextView)findViewById(R.id.textView1)).setText(_spannableString);
  }
}
Carlos Gómez
  • 357
  • 4
  • 9
0

King regards to the Steven's answer, if someone needs, here it's implementation in Kotlin:

    abstract class TouchableSpan(
        private val mNormalTextColor: Int,
        private val mPressedTextColor: Int
    ) : ClickableSpan() {
    
        private var isPressed = false
    
        fun setPressed(isSelected: Boolean) {
            isPressed = isSelected
        }
    
        override fun updateDrawState(ds: TextPaint) {
            super.updateDrawState(ds)
            ds.color = if (isPressed) mPressedTextColor else mNormalTextColor
            ds.isUnderlineText = false
        }
    }

    class LinkTouchMovementMethod : LinkMovementMethod() {
    
        private var pressedSpan: TouchableSpan? = null
    
        override fun onTouchEvent(
            textView: TextView,
            spannable: Spannable,
            event: MotionEvent
        ): Boolean {
            if (event.action == MotionEvent.ACTION_DOWN) {
                pressedSpan = getPressedSpan(textView, spannable, event)
                pressedSpan?.setPressed(true)
                Selection.setSelection(
                    spannable, spannable.getSpanStart(pressedSpan),
                    spannable.getSpanEnd(pressedSpan)
                )
            } else if (event.action == MotionEvent.ACTION_MOVE) {
                val touchedSpan = getPressedSpan(textView, spannable, event)
                if (touchedSpan !== pressedSpan) {
                    pressedSpan?.setPressed(false)
                    pressedSpan = null
                    Selection.removeSelection(spannable)
                }
            } else {
                pressedSpan?.setPressed(false)
                super.onTouchEvent(textView, spannable, event)
                pressedSpan = null
                Selection.removeSelection(spannable)
            }
            return true
        }
    
        private fun getPressedSpan(
            textView: TextView,
            spannable: Spannable,
            event: MotionEvent
        ): TouchableSpan? {
            val x = event.x.toInt() - textView.totalPaddingLeft + textView.scrollX
            val y = event.y.toInt() - textView.totalPaddingTop + textView.scrollY
            val layout = textView.layout
            val position = layout.getOffsetForHorizontal(layout.getLineForVertical(y), x.toFloat())
            val link = spannable.getSpans(position, position, TouchableSpan::class.java)
            if (link.isNotEmpty() && positionWithinTag(position, spannable, link[0])) {
                return link[0]
            }
            return null
        }
    
        private fun positionWithinTag(position: Int, spannable: Spannable, tag: Any) =
            position >= spannable.getSpanStart(tag)
                    && position <= spannable.getSpanEnd(tag)
    
        companion object {
            val instance by lazy {
                LinkTouchMovementMethod()
            }
        }
    }
Sergei Mikhailovskii
  • 2,100
  • 2
  • 21
  • 43
-1

Place the java code as below :

package com.synamegames.orbs;

import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;

public class CustomTouchListener implements View.OnTouchListener {     
public boolean onTouch(View view, MotionEvent motionEvent) {

    switch(motionEvent.getAction()){            
        case MotionEvent.ACTION_DOWN:
         ((TextView) view).setTextColor(0x4F4F4F); 
            break;          
        case MotionEvent.ACTION_CANCEL:             
        case MotionEvent.ACTION_UP:
        ((TextView) view).setTextColor(0xCDCDCD);
            break;
    } 

    return false;   
} 
}

In the above code specify wat color you want .

Change the style .xml as you want.

<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="MenuFont">
    <item name="android:textSize">20sp</item>
    <item name="android:textColor">#CDCDCD</item>
    <item name="android:textStyle">normal</item>
    <item name="android:clickable">true</item>
    <item name="android:layout_weight">1</item>
    <item name="android:gravity">left|center</item>
    <item name="android:paddingLeft">35dp</item>
    <item name="android:layout_width">175dp</item> 
    <item name="android:layout_height">fill_parent</item>
</style>

Try it out and say is this you want or something else . update me dude.

kathir
  • 489
  • 2
  • 11