23

I have a textview with multiple clickable spans in it. I want to be able to test clicking these spans.

I tried setting up a custom ViewAction that would find the clickablespans in the TextView and then match their text with the desired text and then click on the xy coordinates of that text. However, it seems the spans added to the TextView aren't of type ClickableSpan and are instead the the fragment that added the span.

Therefore, I am not able to distinguish the link spans. Is there a better way to do this?

Adding the spans:

Util.addClickableSpan(spannableString, string, linkedString, new      ClickableSpan() {
@Override
public void onClick(View textView) {}
});

tvAcceptTc.setText(spannableString);
tvAcceptTc.setMovementMethod(LinkMovementMethod.getInstance());

Utility method:

public static void addClickableSpan(SpannableString spannableString,
                              String text,
                              String subText,
                              ClickableSpan clickableSpan) {
        int start = text.indexOf(subText);
        int end = text.indexOf(subText) + subText.length();
        int flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE;

        spannableString.setSpan(clickableSpan, start, end, flags);
}

Defining the ViewAction:

@Override
        public void perform(UiController uiController, View view) {
            uiController.loopMainThreadUntilIdle();
            if (view instanceof TextView) {

                TextView textView = (TextView) view;
                Layout textViewLayout = textView.getLayout();


                SpannableString fullSpannable = new SpannableString(textView.getText());

                Object[] spans = fullSpannable.getSpans(0, fullSpannable.length(), Object.class);

                ClickableSpan span = null;
                for (Object object : spans) {
                    if (object instanceof BaseFragment) {
                        ClickableSpan foundSpan = (ClickableSpan)object;
                        int spanStart = fullSpannable.getSpanStart(foundSpan);
                        int spanEnd = fullSpannable.getSpanEnd(foundSpan);
                        if (fullSpannable.subSequence(spanStart, spanEnd).equals(aSubstring)) {
                            //Found the correct span!
                            span = foundSpan;
                        }
                    }
                } ... go on to click the xy-coordinates
Captain Hammer
  • 243
  • 2
  • 7
  • how are you adding your spans? did you try calling `TextUtils#dumpSpans`? – pskink Jul 11 '16 at 18:53
  • I added the code for adding the spans. It actually works now if I remove the instanceof check and casting, but it will find any span with the text instead of only a ClickableSpan. I looked at the spans in the debugger and none of them were of type ClickableSpan, but were instead from the fragment that added the spans. – Captain Hammer Jul 11 '16 at 20:04
  • see the last param of `getSpans` – pskink Jul 11 '16 at 20:07
  • You're saying to put ClickableSpan in there? The spans array doesn't bring back any ClickableSpans though, so won't it just return an empty array? – Captain Hammer Jul 11 '16 at 22:33
  • did you try calling dumpSpans? – pskink Jul 12 '16 at 05:33
  • I ended up just not checking if it was a ClickableSpan since I really only needed to know the coordinates of the desired text. – Captain Hammer Jul 15 '16 at 21:51

7 Answers7

53

This is my solution. It's simpler because we don't need to find the coordinates. Once we have found the ClickableSpan, we just click on it:

public static ViewAction clickClickableSpan(final CharSequence textToClick) {
    return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return Matchers.instanceOf(TextView.class);
        }

        @Override
        public String getDescription() {
            return "clicking on a ClickableSpan";
        }

        @Override
        public void perform(UiController uiController, View view) {
            TextView textView = (TextView) view;
            SpannableString spannableString = (SpannableString) textView.getText();

            if (spannableString.length() == 0) {
                // TextView is empty, nothing to do
                throw new NoMatchingViewException.Builder()
                        .includeViewHierarchy(true)
                        .withRootView(textView)
                        .build();
            }

            // Get the links inside the TextView and check if we find textToClick
            ClickableSpan[] spans = spannableString.getSpans(0, spannableString.length(), ClickableSpan.class);
            if (spans.length > 0) {
                ClickableSpan spanCandidate;
                for (ClickableSpan span : spans) {
                    spanCandidate = span;
                    int start = spannableString.getSpanStart(spanCandidate);
                    int end = spannableString.getSpanEnd(spanCandidate);
                    CharSequence sequence = spannableString.subSequence(start, end);
                    if (textToClick.toString().equals(sequence.toString())) {
                        span.onClick(textView);
                        return;
                    }
                }
            }

            // textToClick not found in TextView
            throw new NoMatchingViewException.Builder()
                    .includeViewHierarchy(true)
                    .withRootView(textView)
                    .build();

        }
    };
}

Now you can use our custom ViewAction just like that:

    onView(withId(R.id.myTextView)).perform(clickClickableSpan("myLink"));
Lavekush Agrawal
  • 6,040
  • 7
  • 52
  • 85
FOMDeveloper
  • 4,370
  • 3
  • 21
  • 20
  • 7
    You should submit this to Android Testing Support Library and get it rolled into Espresso. https://google.github.io/android-testing-support-library/contribute/index.html – tir38 Jan 02 '17 at 20:50
  • 1
    Not Working its work after changes if (textToClick.toString().equals(sequence.toString())) to if (textToClick.toString().contains(sequence.toString())). – Pinak Gauswami Aug 22 '18 at 12:26
  • 4
    Converted this to kotlin, for anyone interested: https://gist.github.com/arvere/2bbcbfa1a3be7e178aa00fbb1225a94d But it receives an ID instead of a String (due to my project's requirements) – arvere May 23 '19 at 13:59
7

Here is the Kotlin version of accepted answer

fun clickClickableSpan(textToClick: CharSequence): ViewAction {
    return object : ViewAction {

        override fun getConstraints(): Matcher<View> {
            return Matchers.instanceOf(TextView::class.java)
        }

        override fun getDescription(): String {
            return "clicking on a ClickableSpan";
        }

        override fun perform(uiController: UiController, view: View) {
            val textView = view as TextView
            val spannableString = textView.text as SpannableString

            if (spannableString.isEmpty()) {
                // TextView is empty, nothing to do
                throw NoMatchingViewException.Builder()
                        .includeViewHierarchy(true)
                        .withRootView(textView)
                        .build();
            }

            // Get the links inside the TextView and check if we find textToClick
            val spans = spannableString.getSpans(0, spannableString.length, ClickableSpan::class.java)
            if (spans.isNotEmpty()) {
                var spanCandidate: ClickableSpan
                for (span: ClickableSpan in spans) {
                    spanCandidate = span
                    val start = spannableString.getSpanStart(spanCandidate)
                    val end = spannableString.getSpanEnd(spanCandidate)
                    val sequence = spannableString.subSequence(start, end)
                    if (textToClick.toString().equals(sequence.toString())) {
                        span.onClick(textView)
                        return;
                    }
                }
            }

            // textToClick not found in TextView
            throw NoMatchingViewException.Builder()
                    .includeViewHierarchy(true)
                    .withRootView(textView)
                    .build()

        }
    }
} 
hakamairi
  • 4,464
  • 4
  • 30
  • 53
Rupak Samant
  • 79
  • 1
  • 1
2

The best option would be to subclass a ViewAction. Here is the way of doing it in Kotlin:

class SpannableTextClickAction(val text: String) : ViewAction {
    override fun getDescription(): String = "SpannableText click action"

    override fun getConstraints(): Matcher<View> =
            isAssignableFrom(TextView::class.java)

    override fun perform(uiController: UiController?, view: View?) {
        val textView = view as TextView
        val spannableString = textView.text as SpannableString
        val spans = spannableString.getSpans(0, spannableString.count(), ClickableSpan::class.java)
        val spanToLocate = spans.firstOrNull { span: ClickableSpan ->
            val start = spannableString.getSpanStart(span)
            val end = spannableString.getSpanEnd(span)
            val spanText = spannableString.subSequence(start, end).toString()
            spanText == text
        }
        if (spanToLocate != null) {
            spanToLocate.onClick(textView)
            return
        }
        // textToClick not found in TextView
        throw NoMatchingViewException.Builder()
                .includeViewHierarchy(true)
                .withRootView(textView)
                .build()
    }
}

and use it as:

onView(withId(<view_id>)).perform(scrollTo(), SpannableTextClickAction(text))
sgl0v
  • 1,357
  • 11
  • 13
1

It worked with a minor change.
just recheck the "textToClick" and the variable "sequence" in:

CharSequence sequence = spannableString.subSequence(start, end);

are exactly same.

I have to use trim() like this:

textToClick.toString() == sequence.trim().toString()

because my textToClick value is "click here" and sequence value that I got " click here"

Note: The space before the "click".

I hope this is useful for someone.

סטנלי גרונן
  • 2,917
  • 23
  • 46
  • 68
0

This works for me:

/**
 * Clicks the first ClickableSpan in the TextView
 */
public static ViewAction clickFirstClickableSpan() {
    return new GeneralClickAction(
            Tap.SINGLE,
            new CoordinatesProvider() {
                @Override
                public float[] calculateCoordinates(View view) {
                    //https://leons.im/posts/how-to-get-coordinate-of-a-clickablespan-inside-a-textview/
                    TextView textView = (TextView) view;
                    Rect parentTextViewRect = new Rect();

                    SpannableString spannableString = (SpannableString) textView.getText();
                    Layout textViewLayout = textView.getLayout();
                    ClickableSpan spanToLocate = null;

                    if (spannableString.length() == 0) {
                        return new float[2];
                    }

                    ClickableSpan[] spans = spannableString.getSpans(0, spannableString.length(), ClickableSpan.class);
                    if (spans.length > 0) {
                        spanToLocate = spans[0];
                    }

                    if (spanToLocate == null) {
                        // no specific view found
                        throw new NoMatchingViewException.Builder()
                                .includeViewHierarchy(true)
                                .withRootView(textView)
                                .build();
                    }

                    double startOffsetOfClickedText = spannableString.getSpanStart(spanToLocate);
                    double endOffsetOfClickedText = spannableString.getSpanEnd(spanToLocate);
                    double startXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal((int) startOffsetOfClickedText);
                    double endXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal((int) endOffsetOfClickedText);

                    // Get the rectangle of the clicked text
                    int currentLineStartOffset = textViewLayout.getLineForOffset((int) startOffsetOfClickedText);
                    int currentLineEndOffset = textViewLayout.getLineForOffset((int) endOffsetOfClickedText);
                    boolean keywordIsInMultiLine = currentLineStartOffset != currentLineEndOffset;
                    textViewLayout.getLineBounds(currentLineStartOffset, parentTextViewRect);

                    // Update the rectangle position to his real position on screen
                    int[] parentTextViewLocation = {0, 0};
                    textView.getLocationOnScreen(parentTextViewLocation);

                    double parentTextViewTopAndBottomOffset = (
                            parentTextViewLocation[1] -
                                    textView.getScrollY() +
                                    textView.getCompoundPaddingTop()
                    );
                    parentTextViewRect.top += parentTextViewTopAndBottomOffset;
                    parentTextViewRect.bottom += parentTextViewTopAndBottomOffset;
                    parentTextViewRect.left += (
                            parentTextViewLocation[0] +
                                    startXCoordinatesOfClickedText +
                                    textView.getCompoundPaddingLeft() -
                                    textView.getScrollX()
                    );
                    parentTextViewRect.right = (int) (
                            parentTextViewRect.left +
                                    endXCoordinatesOfClickedText -
                                    startXCoordinatesOfClickedText
                    );

                    int screenX = (parentTextViewRect.left + parentTextViewRect.right) / 2;
                    int screenY = (parentTextViewRect.top + parentTextViewRect.bottom) / 2;
                    if (keywordIsInMultiLine) {
                        screenX = parentTextViewRect.left;
                        screenY = parentTextViewRect.top;
                    }
                    return new float[]{screenX, screenY};
                }
            },
            Press.FINGER);
}
Jiechao Wang
  • 922
  • 1
  • 15
  • 32
0

you may use Spannable instead of SpannableString compatible with SpannableStringBuilder.

sorry, i am a new man , have only 1 Reputation , can not add a comment.Even my English is very poor.....

i suggest to use:

Spannable spannableString = (Spannable) textView.getText();

instead of :

SpannableString spannableString = (SpannableString) textView.getText();

post all the code below:

public class CustomViewActions {

    /**
     * click specific spannableString
     */
    public static ViewAction clickClickableSpan(final CharSequence textToClick) {
        return clickClickableSpan(-1, textToClick);
    }

    /**
     * click the first spannableString
     */
    public static ViewAction clickClickableSpan() {
        return clickClickableSpan(0, null);
    }

    /**
     * click the nth spannableString
     */
    public static ViewAction clickClickableSpan(final int index) {
        return clickClickableSpan(index, null);
    }

    public static ViewAction clickClickableSpan(final int index,final CharSequence textToClick) {
        return new ViewAction() {
            @Override
            public Matcher<View> getConstraints() {
                return instanceOf(TextView.class);
            }

            @Override
            public String getDescription() {
                return "clicking on a ClickableSpan";
            }

            @Override
            public void perform(UiController uiController, View view) {
                TextView textView = (TextView) view;
                Spannable spannableString = (Spannable) textView.getText();
                ClickableSpan spanToLocate = null;
                if (spannableString.length() == 0) {
                    // TextView is empty, nothing to do
                    throw new NoMatchingViewException.Builder()
                            .includeViewHierarchy(true)
                            .withRootView(textView)
                            .build();
                }

                // Get the links inside the TextView and check if we find textToClick
                ClickableSpan[] spans = spannableString.getSpans(0, spannableString.length(), ClickableSpan.class);

                if (spans.length > 0) {
                    if(index >=spans.length){
                        throw new NoMatchingViewException.Builder()
                            .includeViewHierarchy(true)
                            .withRootView(textView)
                            .build();
                    }else if (index >= 0) {
                        spanToLocate = spans[index];
                        spanToLocate.onClick(textView);
                        return;
                    }
                    for (int i = 0; i < spans.length; i++) {
                        int start = spannableString.getSpanStart(spans[i]);
                        int end = spannableString.getSpanEnd(spans[i]);
                        CharSequence sequence = spannableString.subSequence(start, end);
                        if (textToClick.toString().equals(sequence.toString())) {
                            spanToLocate = spans[i];
                            spanToLocate.onClick(textView);
                            return;
                        }
                    }
                }

                // textToClick not found in TextView
                throw new NoMatchingViewException.Builder()
                        .includeViewHierarchy(true)
                        .withRootView(textView)
                        .build();

            }
        };
    }

}
  • While this might be a valuable hint to solve the problem, a good answer also demonstrates the solution. Please [EDIT](http://stackoverflow.com/posts/5419867/edit) to provide example code to show what you mean. Alternatively, consider writing this as a comment instead – ρяσѕρєя K Jan 12 '17 at 09:32
  • btw, the direct `onClick` may miss some bug. For example, textView is masked by other clickable areas . this is my guess without test. – Smart.Chen Jan 13 '17 at 03:27
-2

Espresso has a one-liner for this:

onView(withId(R.id.textView)).perform(openLinkWithText("..."))
Saket
  • 2,945
  • 1
  • 29
  • 31