Skip to content

Instantly share code, notes, and snippets.

@sQu1rr
Last active March 7, 2020 23:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sQu1rr/210f7e08dd939fa30dcd2209177ba875 to your computer and use it in GitHub Desktop.
Save sQu1rr/210f7e08dd939fa30dcd2209177ba875 to your computer and use it in GitHub Desktop.
Smart Movement Method for Android TextView that supports ClickableSpan and custom spans, that receive Spannable buffer and the tap coordinates
package uk.co.belks.smartmovementmethod
import android.graphics.Color
import android.graphics.Point
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.text.Selection
import android.text.SpanWatcher
import android.text.Spannable
import android.text.SpannableString
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.text.style.ClickableSpan
import android.text.style.UpdateAppearance
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.core.text.getSpans
import androidx.core.text.set
import androidx.databinding.DataBindingUtil
import uk.co.belks.smartmovementmethod.databinding.ActivityMainBinding
import java.util.regex.Pattern
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).apply {
// Optional: set text selectable if it needs to be
textViewLorem.setTextIsSelectable(true)
// Set the SmartMovementMethod
// NOTE: the movement method must be set *after* the setTextIsSelectable(true)
textViewLorem.movementMethod = SmartMovementMethod
textViewIpsum.movementMethod = SmartMovementMethod
}.also {
decorateText(it.textViewLorem)
decorateText(it.textViewIpsum)
}
}
}
/** Decorate every fifth word with one of four examples of spans */
private fun decorateText(textView: TextView) {
textView.text = SpannableString(textView.text).also { span ->
val words = mutableListOf<Pair<Int, Int>>()
// Get all words and their positions
Pattern.compile("\\w+").matcher(span).also {
while (it.find()) words.add(Pair(it.start(), it.end()))
}
// Create spans for every 5th word
words.filterIndexed { index, _ -> index % 5 == 0 }.forEachIndexed { index, (start, end) ->
span[start..end] = when (index % 4) {
0 -> createClickableSpan()
1 -> createSmartSpan()
2 -> createSmartClickableSpan(start, end)
else -> createSmartHighlightSpan()
}
}
}
}
/**
* The clickable span is supported
*/
private fun createClickableSpan() = object : ClickableSpan() {
override fun updateDrawState(ds: TextPaint) {
ds.color = Color.parseColor("#ee9944")
}
override fun onClick(widget: View) {
Toast.makeText(widget.context, R.string.span_clickable, Toast.LENGTH_SHORT).show()
}
}
/**
* The simple span that just displays toast
*/
private fun createSmartSpan(): SmartSpan = object : CharacterStyle(), SmartSpan {
override fun updateDrawState(ds: TextPaint) {
ds.color = Color.parseColor("#007aff")
}
override fun onClick(widget: TextView, buffer: Spannable, target: Point) {
Toast.makeText(widget.context, R.string.span_smart, Toast.LENGTH_SHORT).show()
}
}
/**
* The span that simulates [ClickableSpan]
*/
private fun createSmartClickableSpan(begin: Int, end: Int): SmartSpan =
object : CharacterStyle(), SmartSpan {
override fun updateDrawState(ds: TextPaint) {
ds.color = Color.parseColor("#204496")
}
override fun onClick(widget: TextView, buffer: Spannable, target: Point) {
// Select the text of the span (in UI thread)
Handler().post { Selection.setSelection(buffer, begin, end) }
// Quickly de-select (in UI thread)
Handler().postDelayed({ Selection.removeSelection(buffer) }, 100L)
Toast.makeText(widget.context, R.string.span_smart_clickable, Toast.LENGTH_SHORT).show()
}
}
/**
* The span that highlights its text for one second with different colour
*/
private fun createSmartHighlightSpan(): SmartSpan = object : CharacterStyle(), SmartSpan {
private val defaultColor get() = Color.parseColor("#c3272f")
private var color = defaultColor
override fun updateDrawState(ds: TextPaint) {
ds.color = color
}
override fun onClick(widget: TextView, buffer: Spannable, target: Point) {
// Change the colour and redraw
setColor(Color.parseColor("#ee9944"), widget, buffer)
// Change the colour back in one second and redraw
Handler().postDelayed({ setColor(defaultColor, widget, buffer) }, 1000L)
Toast.makeText(widget.context, R.string.span_smart_highlight, Toast.LENGTH_SHORT).show()
}
/**
* Sets the new color and invalidates the view
*
* For some reason, when setTextIsSelectable is set to true, the usual [TextView.invalidate]
* method doesn't update the UI. So to work around that we will notify the span listener
* that new span was added that changes appearance. That causes TextView to redraw its
* content
*
* Tested on API 19, API 26 and API 29 and it seems to consistently work
*/
private fun setColor(newColor: Int, widget: TextView, buffer: Spannable) {
color = newColor
widget.post { // Post on UI thread
// Get span watchers and send UpdateAppearance object to them so the UI
// can get invalidated
val updateAppearance = object : UpdateAppearance {}
buffer.getSpans<SpanWatcher>(0, buffer.length).forEach {
it.onSpanAdded(buffer, updateAppearance, 0, buffer.length)
}
}
}
}
package uk.co.belks.smartmovementmethod
import android.graphics.Point
import android.text.Selection
import android.text.Spannable
import android.text.method.ArrowKeyMovementMethod
import android.text.style.ClickableSpan
import android.view.MotionEvent
import android.widget.TextView
import androidx.core.text.getSpans
/**
* Smart span receives [TextView], [Spannable] buffer and target [Point] of click when user
* clicks on it
* - Buffer is useful for text selection, or span modification
* - Target is useful for e.g. displaying tooltips at the point of the tap
*/
interface SmartSpan {
/**
* Called when clickable action is invoked in span
* @param widget the text view that contains the span
* @param buffer the spannable buffer of the text view
* @param target the point where user has clicked, relative to the text view origin
*/
fun onClick(widget: TextView, buffer: Spannable, target: Point)
}
/**
* Handles user interaction with the text in the [TextView]
* For the [SmartSpan] and [ClickableSpan] objects their onClick method is called with the tap
* coordinates
*
* Loosely based on https://stackoverflow.com/a/51018959/1842900
*/
object SmartMovementMethod : ArrowKeyMovementMethod() {
override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean {
// First make sure we are making a safe call
if (event != null && widget != null && buffer != null) {
// Return true only if event is handled
if (handleMotion(event, widget, buffer)) {
return true
}
}
return super.onTouchEvent(widget, buffer, event)
}
/** Handle the motion action event for the widget and its text buffer */
private fun handleMotion(event: MotionEvent, widget: TextView, buffer: Spannable): Boolean {
var handled = false
if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_UP) {
// Get click position
val target = Point().apply {
x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
}
// Get span line and offset
val line = widget.layout.getLineForVertical(target.y)
val offset = widget.layout.getOffsetForHorizontal(line, target.x.toFloat())
if (event.action == MotionEvent.ACTION_DOWN) {
// Highlight clickable span and return true if handled
handled = handled || buffer.execute<ClickableSpan>(offset) {
Selection.setSelection(buffer, buffer.getSpanStart(it), buffer.getSpanEnd(it))
}
}
if (event.action == MotionEvent.ACTION_UP) {
// Handle clickable callbacks and return true if handled
handled = handled || buffer.execute<ClickableSpan>(offset) { it.onClick(widget) }
// Handle smart callbacks and return true if handled
handled = handled || buffer.execute<SmartSpan>(offset) {
it.onClick(widget, buffer, target)
}
}
}
return handled
}
}
/** Execute span callbacks at the given offset, returns false if no callbacks invoked */
private inline fun <reified T : Any> Spannable.execute(offset: Int, fn: (T) -> Unit): Boolean {
val spans = getSpans<T>(offset, offset)
if (spans.isNotEmpty()) {
spans.forEach(fn)
return true
}
return false
}
package uk.co.belks.smartmovementmethod
import android.graphics.Point
import android.text.Spannable
import android.text.method.ArrowKeyMovementMethod
import android.text.style.ClickableSpan
import android.view.MotionEvent
import android.widget.TextView
import androidx.core.text.getSpans
/** Minimal version of Smart Movement that only has limited support of [ClickableSpan] */
object SmartMovementMethodMinimal : ArrowKeyMovementMethod() {
override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?) =
handleMotion(event!!, widget!!, buffer!!) || super.onTouchEvent(widget, buffer, event)
private fun handleMotion(event: MotionEvent, widget: TextView, buffer: Spannable): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
// Get click position
val target = Point().apply {
x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
}
// Get span line and offset
val line = widget.layout.getLineForVertical(target.y)
val offset = widget.layout.getOffsetForHorizontal(line, target.x.toFloat())
if (event.action == MotionEvent.ACTION_UP) {
val spans = buffer.getSpans<ClickableSpan>(offset, offset)
if (spans.isNotEmpty()) {
spans.forEach { it.onClick(widget) }
return true
}
}
}
return false
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment