Regular readers of Styling Android will know that I like Spans. A lot. They are an incredibly versatile way of formatting text. However, there is one type of Span which can be problematic: ClickableSpan. The problem isn’t so much with ClickableSpan itself, which does exactly what it is designed to do, but the need to use ClickableSpan in the first place is often an indication of an underlying problem.
Before we dive in to the details, let’s first have a brief history lesson:
The first iPhone revolutionised smart phones because it did something previously unheard of in mobile UI design. Smart phones certainly existed before the iPhone, but one thing they had in common was they they had bigger, higher density displays than feature phones, and these displays were used to try and mimic desktop computer UIs. The end result was usually really frustrating to use because the buttons were difficult to click with any accuracy, and often a Smartphone came with a stylus to make clicking these tiny buttons easier. What iPhone did differently to apply Fitts’s Law to the UI design of the first iPhone. Fitts’s Law originally appeared in a paper published in 1954 by Paul Fitts and was a study of user-computer interaction. It models the time it takes for a human to point or click on a specific area based on the distance needed to travel, and the size of the target area. Apple applied this principle and effectively make all of the controls bigger which seems somewhat counter-intuitive when we consider that a smart phone display was, and still is, much smaller than a desktop display. But a simplified UI with larger controls was the result and this (in conjunction with a more responsive capacitive touch screen) improved the usability of smart phones quite dramatically.
One of the fundamental rules which is true of all smart devices is all about the touch areas of clickable controls – which should always be a minimum of 48dp (which should be around 9mm, or 3/8 inch). Any smaller than that and humans will generally have trouble clicking them accurately. Any instances where the user taps but nothing happens will result in a poorer UX, and the smaller the tap area the higher chance of missing the tappable area.
So how does this all relate to ClickableSpan? The real issue here is identifying the use-cases where we would need to use ClickableSpan: Where we have a run of text where part of it needs to be a link which will handle click events. Let’s implement a simple ClickableSpan – in this case we’ll actually use URLSpan which is a subclass of ClcikableSpan:
class MainActivity : AppCompatActivity() { val fullString by lazyString(R.string.string) val clickable by lazyString(R.string.clickable) val url by lazyString(R.string.blog_url) val backgroundColour by lazyColor(R.color.colorPrimaryDark) fun Context.lazyString(@StringRes stringResId: Int): Lazy= lazy(LazyThreadSafetyMode.NONE) { getString(stringResId) } fun Context.lazyColor(@ColorRes colorResId: Int): Lazy = lazy(LazyThreadSafetyMode.NONE) { ResourcesCompat.getColor(resources, colorResId, theme) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) clickable_span.apply { text = buildFormattedText() movementMethod = LinkMovementMethod.getInstance() } } private fun buildFormattedText() = fullString .indexOf(clickable) .takeIf { it >= 0 } ?.let { createSpans(start = it, end = it + clickable.length) } private fun createSpans(start: Int, end: Int) = SpannableStringBuilder(fullString).apply { setSpans { setSpan(it, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } } private inline fun setSpans(setSpan: (span: Any) -> Unit) { setSpan(URLSpan(url)) setSpan(BackgroundColorSpan(backgroundColour)) } }
As well as the ClickableSpan, I have also added a BackgroundColorSpan which will highlight the issue. Now let’s look at the layout:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.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" tools:context=".MainActivity"> <TextView android:id="@+id/clickable_span" android:layout_width="wrap_content" android:layout_height="wrap_content" android:maxWidth="200dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintEnd_toStartOf="@+id/block_48dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintHorizontal_chainStyle="packed" tools:text="This is some text"/> <View android:id="@+id/block_48dp" android:layout_width="48dp" android:layout_height="48dp" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:background="@color/colorPrimaryDark" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/clickable_span" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toTopOf="parent" /> </android.support.constraint.ConstraintLayout>
I have included the maxWidth
attribute on the TextView and a 48dp
square block to really highlight the issue:
The problem should be fairly clear: The area coloured dark green are the clickable areas. The 48dp
square on the right shows the size that the clickable area needs to be in order to provide a useable UI, but the clickable area in the text is only about 1/3 of the required height. This isn’t an issue with the ClickableSpan itself – it does exactly what we have asked of it – make a given section of the text clickable. However the clickable area is actually a function of the metrics of the font being used to render the text, and the size of the text – in this case just using a default font and size. We can increase the size of the clickable area by increasing the text size, but that is it.
Therein lies one of the fundamental issues with ClickableSpan – we cannot control the touch area without changing the size of the text. A Span is rather different to a View. With a View we can increase the tappable area by adding a padding which increases the View bounds, or we can set a new TouchDelegate the View with increased bounds to effectively expand the touch area. However, neither of these approaches will work for a Span. It may be possible to also apply a custom MetricAffectigsSpan to override the measured size of the span text, but this will apply physical space between the span and its surrounding text, which is probably not what is required.
But actually, if we step back there is a deeper issue here. If the design calls for the text to be clickable in this way while specifying a text size which would result in a clickable area much smaller than 48dp
then that is actually a design bug, so the need to use a ClickableSpan can often be a smell that there is an issue where the design actually needs to change.
We can actually fix this quite easily, but expanding the tappable area in a quite bold way. Rather than having just a subsection of the string clickable, we can style part of the text using a span to look like a link, but actually apply a click handler to the containing TextView itself. By doing this we actually register a click whenever the user taps anywhere on the TextView, and if (s)he aiming for the highlighted area, (s)he will actually be aiming for a much larger area than (s)he realises. This is fine if there is only a single link in the text, but if there are multiple ones then they are likely to be too close together with tappable areas which are too small. In such cases it will need to be pushed back to the designer.
So there is nothing inherently wrong with ClickableSpan because it does what it is supposed to. The issue arises when a design may show a clickable area actually needs to be larger that it appears to be. It may be the designer’s intention to make the clickable area larger, but the design tools fail to clearly communicate that. So when I see such designs I immediately speak with the designer to understand precisely what his or her intention was. Good communication between designers and developers brings huge benefits to any project.
I am deeply indebted to Eugenio Marletti for code reviewing my Kotlin and suggesting many improvements.
© 2017, Mark Allison. All rights reserved.
Copyright © 2017 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.
Hi,
Using clickablespan leads to crashes in Samsung devices mostly. Can you please look into it and share your experience? Mostly reported in Google Play crash logs were Samsung devices (even if you want I can share some stack trace sample).
awaiting for your reply.
I don’t see that there’s much I can do, particularly as I’m advocating against using ClickableSpan, anyway. Perhaps you should contact Samsung.
It would be nice if this functionality was part of TextView. When the user clicks on the TextView, it would determine whether there is 0, 1 or multiple links in the touched zone (24dp radius from click point) and then accordingly do nothing, onClick() or bring up a Chrome-style bubble magnification allowing the user to select more accurately.
Hi Mark, thank you for share your knowledge with us.
Right now I’m developing a app where I should be able to click on a word of a text and check the meaning in a dictionary. At first I tried to use ClickableSpan, iterating all words of the text, but with very long texts this produced a very poor and uneficient result. Do you know how can I resolve this ? Am I using ClickableSpan in a wrong way? What would you use in this case ?
Thank you very much !