Last active
March 7, 2020 23:17
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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