pull/517/head
parent
1f3cf8c0c4
commit
e4c2cfdb92
@ -1,437 +0,0 @@ |
|||||||
package io.legado.app.ui.widget.text |
|
||||||
|
|
||||||
import android.app.Activity |
|
||||||
import android.graphics.RectF |
|
||||||
import android.text.Selection |
|
||||||
import android.text.Spannable |
|
||||||
import android.text.Spanned |
|
||||||
import android.text.method.LinkMovementMethod |
|
||||||
import android.text.style.BackgroundColorSpan |
|
||||||
import android.text.style.ClickableSpan |
|
||||||
import android.text.style.URLSpan |
|
||||||
import android.text.util.Linkify |
|
||||||
import android.view.* |
|
||||||
import android.widget.TextView |
|
||||||
import io.legado.app.R |
|
||||||
import io.legado.app.ui.widget.text.BetterLinkMovementMethod.LongPressTimer.OnTimerReachedListener |
|
||||||
|
|
||||||
class BetterLinkMovementMethod protected constructor() : LinkMovementMethod() { |
|
||||||
private var onLinkClickListener: OnLinkClickListener? = null |
|
||||||
private var onLinkLongClickListener: OnLinkLongClickListener? = null |
|
||||||
private val touchedLineBounds = RectF() |
|
||||||
private var isUrlHighlighted = false |
|
||||||
private var clickableSpanUnderTouchOnActionDown: ClickableSpan? = null |
|
||||||
private var activeTextViewHashcode = 0 |
|
||||||
private var ongoingLongPressTimer: LongPressTimer? = null |
|
||||||
private var wasLongPressRegistered = false |
|
||||||
|
|
||||||
interface OnLinkClickListener { |
|
||||||
/** |
|
||||||
* @param textView The TextView on which a click was registered. |
|
||||||
* @param url The clicked URL. |
|
||||||
* @return True if this click was handled. False to let Android handle the URL. |
|
||||||
*/ |
|
||||||
fun onClick(textView: TextView?, url: String?): Boolean |
|
||||||
} |
|
||||||
|
|
||||||
interface OnLinkLongClickListener { |
|
||||||
/** |
|
||||||
* @param textView The TextView on which a long-click was registered. |
|
||||||
* @param url The long-clicked URL. |
|
||||||
* @return True if this long-click was handled. False to let Android handle the URL (as a short-click). |
|
||||||
*/ |
|
||||||
fun onLongClick(textView: TextView?, url: String?): Boolean |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Set a listener that will get called whenever any link is clicked on the TextView. |
|
||||||
*/ |
|
||||||
fun setOnLinkClickListener(clickListener: OnLinkClickListener?): BetterLinkMovementMethod { |
|
||||||
if (this === singleInstance) { |
|
||||||
throw UnsupportedOperationException( |
|
||||||
"Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " + |
|
||||||
"leaks. Please use newInstance() or any of the linkify() methods instead." |
|
||||||
) |
|
||||||
} |
|
||||||
onLinkClickListener = clickListener |
|
||||||
return this |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Set a listener that will get called whenever any link is clicked on the TextView. |
|
||||||
*/ |
|
||||||
fun setOnLinkLongClickListener(longClickListener: OnLinkLongClickListener?): BetterLinkMovementMethod { |
|
||||||
if (this === singleInstance) { |
|
||||||
throw UnsupportedOperationException( |
|
||||||
"Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " + |
|
||||||
"memory leaks. Please use newInstance() or any of the linkify() methods instead." |
|
||||||
) |
|
||||||
} |
|
||||||
onLinkLongClickListener = longClickListener |
|
||||||
return this |
|
||||||
} |
|
||||||
|
|
||||||
override fun onTouchEvent(textView: TextView, text: Spannable, event: MotionEvent): Boolean { |
|
||||||
if (activeTextViewHashcode != textView.hashCode()) { |
|
||||||
// Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted. |
|
||||||
// A hacky solution is to reset any "autoLink" property set in XML. But we also want |
|
||||||
// to do this once per TextView. |
|
||||||
activeTextViewHashcode = textView.hashCode() |
|
||||||
textView.autoLinkMask = 0 |
|
||||||
} |
|
||||||
val clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event) |
|
||||||
if (event.action == MotionEvent.ACTION_DOWN) { |
|
||||||
clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch |
|
||||||
} |
|
||||||
val touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null |
|
||||||
return when (event.action) { |
|
||||||
MotionEvent.ACTION_DOWN -> { |
|
||||||
clickableSpanUnderTouch?.let { highlightUrl(textView, it, text) } |
|
||||||
if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) { |
|
||||||
val longClickListener: OnTimerReachedListener = |
|
||||||
object : OnTimerReachedListener { |
|
||||||
override fun onTimerReached() { |
|
||||||
wasLongPressRegistered = true |
|
||||||
textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) |
|
||||||
removeUrlHighlightColor(textView) |
|
||||||
dispatchUrlLongClick(textView, clickableSpanUnderTouch) |
|
||||||
} |
|
||||||
} |
|
||||||
startTimerForRegisteringLongClick(textView, longClickListener) |
|
||||||
} |
|
||||||
touchStartedOverAClickableSpan |
|
||||||
} |
|
||||||
MotionEvent.ACTION_UP -> { |
|
||||||
// Register a click only if the touch started and ended on the same URL. |
|
||||||
if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch === clickableSpanUnderTouchOnActionDown) { |
|
||||||
dispatchUrlClick(textView, clickableSpanUnderTouch) |
|
||||||
} |
|
||||||
cleanupOnTouchUp(textView) |
|
||||||
|
|
||||||
// Consume this event even if we could not find any spans to avoid letting Android handle this event. |
|
||||||
// Android's TextView implementation has a bug where links get clicked even when there is no more text |
|
||||||
// next to the link and the touch lies outside its bounds in the same direction. |
|
||||||
touchStartedOverAClickableSpan |
|
||||||
} |
|
||||||
MotionEvent.ACTION_CANCEL -> { |
|
||||||
cleanupOnTouchUp(textView) |
|
||||||
false |
|
||||||
} |
|
||||||
MotionEvent.ACTION_MOVE -> { |
|
||||||
// Stop listening for a long-press as soon as the user wanders off to unknown lands. |
|
||||||
if (clickableSpanUnderTouch !== clickableSpanUnderTouchOnActionDown) { |
|
||||||
removeLongPressCallback(textView) |
|
||||||
} |
|
||||||
if (!wasLongPressRegistered) { |
|
||||||
// Toggle highlight. |
|
||||||
if (clickableSpanUnderTouch != null) { |
|
||||||
highlightUrl(textView, clickableSpanUnderTouch, text) |
|
||||||
} else { |
|
||||||
removeUrlHighlightColor(textView) |
|
||||||
} |
|
||||||
} |
|
||||||
touchStartedOverAClickableSpan |
|
||||||
} |
|
||||||
else -> false |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun cleanupOnTouchUp(textView: TextView) { |
|
||||||
wasLongPressRegistered = false |
|
||||||
clickableSpanUnderTouchOnActionDown = null |
|
||||||
removeUrlHighlightColor(textView) |
|
||||||
removeLongPressCallback(textView) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any). |
|
||||||
* |
|
||||||
* @return The touched ClickableSpan or null. |
|
||||||
*/ |
|
||||||
protected fun findClickableSpanUnderTouch( |
|
||||||
textView: TextView, |
|
||||||
text: Spannable, |
|
||||||
event: MotionEvent |
|
||||||
): ClickableSpan? { |
|
||||||
// So we need to find the location in text where touch was made, regardless of whether the TextView |
|
||||||
// has scrollable text. That is, not the entire text is currently visible. |
|
||||||
var touchX = event.x.toInt() |
|
||||||
var touchY = event.y.toInt() |
|
||||||
|
|
||||||
// Ignore padding. |
|
||||||
touchX -= textView.totalPaddingLeft |
|
||||||
touchY -= textView.totalPaddingTop |
|
||||||
|
|
||||||
// Account for scrollable text. |
|
||||||
touchX += textView.scrollX |
|
||||||
touchY += textView.scrollY |
|
||||||
val layout = textView.layout |
|
||||||
val touchedLine = layout.getLineForVertical(touchY) |
|
||||||
val touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX.toFloat()) |
|
||||||
touchedLineBounds.left = layout.getLineLeft(touchedLine) |
|
||||||
touchedLineBounds.top = layout.getLineTop(touchedLine).toFloat() |
|
||||||
touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left |
|
||||||
touchedLineBounds.bottom = layout.getLineBottom(touchedLine).toFloat() |
|
||||||
return if (touchedLineBounds.contains(touchX.toFloat(), touchY.toFloat())) { |
|
||||||
// Find a ClickableSpan that lies under the touched area. |
|
||||||
val spans = text.getSpans(touchOffset, touchOffset, ClickableSpan::class.java) |
|
||||||
for (span in spans) { |
|
||||||
if (span is ClickableSpan) { |
|
||||||
return span |
|
||||||
} |
|
||||||
} |
|
||||||
// No ClickableSpan found under the touched location. |
|
||||||
null |
|
||||||
} else { |
|
||||||
// Touch lies outside the line's horizontal bounds where no spans should exist. |
|
||||||
null |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Adds a background color span at <var>clickableSpan</var>'s location. |
|
||||||
*/ |
|
||||||
protected fun highlightUrl(textView: TextView, clickableSpan: ClickableSpan?, text: Spannable) { |
|
||||||
if (isUrlHighlighted) { |
|
||||||
return |
|
||||||
} |
|
||||||
isUrlHighlighted = true |
|
||||||
val spanStart = text.getSpanStart(clickableSpan) |
|
||||||
val spanEnd = text.getSpanEnd(clickableSpan) |
|
||||||
val highlightSpan = BackgroundColorSpan(textView.highlightColor) |
|
||||||
text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE) |
|
||||||
textView.setTag(R.id.bettermovementmethod_highlight_background_span, highlightSpan) |
|
||||||
Selection.setSelection(text, spanStart, spanEnd) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes the highlight color under the Url. |
|
||||||
*/ |
|
||||||
protected fun removeUrlHighlightColor(textView: TextView) { |
|
||||||
if (!isUrlHighlighted) { |
|
||||||
return |
|
||||||
} |
|
||||||
isUrlHighlighted = false |
|
||||||
val text = textView.text as Spannable |
|
||||||
val highlightSpan = |
|
||||||
textView.getTag(R.id.bettermovementmethod_highlight_background_span) as BackgroundColorSpan |
|
||||||
text.removeSpan(highlightSpan) |
|
||||||
Selection.removeSelection(text) |
|
||||||
} |
|
||||||
|
|
||||||
protected fun startTimerForRegisteringLongClick( |
|
||||||
textView: TextView, |
|
||||||
longClickListener: OnTimerReachedListener? |
|
||||||
) { |
|
||||||
ongoingLongPressTimer = LongPressTimer() |
|
||||||
ongoingLongPressTimer!!.setOnTimerReachedListener(longClickListener) |
|
||||||
textView.postDelayed( |
|
||||||
ongoingLongPressTimer, |
|
||||||
ViewConfiguration.getLongPressTimeout().toLong() |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Remove the long-press detection timer. |
|
||||||
*/ |
|
||||||
protected fun removeLongPressCallback(textView: TextView) { |
|
||||||
if (ongoingLongPressTimer != null) { |
|
||||||
textView.removeCallbacks(ongoingLongPressTimer) |
|
||||||
ongoingLongPressTimer = null |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
protected fun dispatchUrlClick(textView: TextView, clickableSpan: ClickableSpan?) { |
|
||||||
val clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan) |
|
||||||
val handled = onLinkClickListener != null && onLinkClickListener!!.onClick( |
|
||||||
textView, |
|
||||||
clickableSpanWithText.text() |
|
||||||
) |
|
||||||
if (!handled) { |
|
||||||
// Let Android handle this click. |
|
||||||
clickableSpanWithText.span()!!.onClick(textView) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
protected fun dispatchUrlLongClick(textView: TextView, clickableSpan: ClickableSpan?) { |
|
||||||
val clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan) |
|
||||||
val handled = onLinkLongClickListener != null && onLinkLongClickListener!!.onLongClick( |
|
||||||
textView, |
|
||||||
clickableSpanWithText.text() |
|
||||||
) |
|
||||||
if (!handled) { |
|
||||||
// Let Android handle this long click as a short-click. |
|
||||||
clickableSpanWithText.span()!!.onClick(textView) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
protected class LongPressTimer : Runnable { |
|
||||||
private var onTimerReachedListener: OnTimerReachedListener? = null |
|
||||||
|
|
||||||
interface OnTimerReachedListener { |
|
||||||
fun onTimerReached() |
|
||||||
} |
|
||||||
|
|
||||||
override fun run() { |
|
||||||
onTimerReachedListener!!.onTimerReached() |
|
||||||
} |
|
||||||
|
|
||||||
fun setOnTimerReachedListener(listener: OnTimerReachedListener?) { |
|
||||||
onTimerReachedListener = listener |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* A wrapper to support all [ClickableSpan]s that may or may not provide URLs. |
|
||||||
*/ |
|
||||||
protected class ClickableSpanWithText protected constructor( |
|
||||||
private val span: ClickableSpan?, |
|
||||||
private val text: String |
|
||||||
) { |
|
||||||
fun span(): ClickableSpan? { |
|
||||||
return span |
|
||||||
} |
|
||||||
|
|
||||||
fun text(): String { |
|
||||||
return text |
|
||||||
} |
|
||||||
|
|
||||||
companion object { |
|
||||||
fun ofSpan(textView: TextView, span: ClickableSpan?): ClickableSpanWithText { |
|
||||||
val s = textView.text as Spanned |
|
||||||
val text: String |
|
||||||
text = if (span is URLSpan) { |
|
||||||
span.url |
|
||||||
} else { |
|
||||||
val start = s.getSpanStart(span) |
|
||||||
val end = s.getSpanEnd(span) |
|
||||||
s.subSequence(start, end).toString() |
|
||||||
} |
|
||||||
return ClickableSpanWithText(span, text) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
companion object { |
|
||||||
private var singleInstance: BetterLinkMovementMethod? = null |
|
||||||
private const val LINKIFY_NONE = -2 |
|
||||||
|
|
||||||
/** |
|
||||||
* Return a new instance of BetterLinkMovementMethod. |
|
||||||
*/ |
|
||||||
fun newInstance(): BetterLinkMovementMethod { |
|
||||||
return BetterLinkMovementMethod() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], |
|
||||||
* [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. |
|
||||||
* @param textViews The TextViews on which a [BetterLinkMovementMethod] should be registered. |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkify(linkifyMask: Int, vararg textViews: TextView): BetterLinkMovementMethod { |
|
||||||
val movementMethod = newInstance() |
|
||||||
for (textView in textViews) { |
|
||||||
addLinks(linkifyMask, movementMethod, textView) |
|
||||||
} |
|
||||||
return movementMethod |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Like [.linkify], but can be used for TextViews with HTML links. |
|
||||||
* |
|
||||||
* @param textViews The TextViews on which a [BetterLinkMovementMethod] should be registered. |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkifyHtml(vararg textViews: TextView): BetterLinkMovementMethod { |
|
||||||
return linkify(LINKIFY_NONE, *textViews) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Recursively register a [BetterLinkMovementMethod] on every TextView inside a layout. |
|
||||||
* |
|
||||||
* @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], |
|
||||||
* [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkify(linkifyMask: Int, viewGroup: ViewGroup): BetterLinkMovementMethod { |
|
||||||
val movementMethod = newInstance() |
|
||||||
rAddLinks(linkifyMask, viewGroup, movementMethod) |
|
||||||
return movementMethod |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Like [.linkify], but can be used for TextViews with HTML links. |
|
||||||
* |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkifyHtml(viewGroup: ViewGroup): BetterLinkMovementMethod { |
|
||||||
return linkify(LINKIFY_NONE, viewGroup) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Recursively register a [BetterLinkMovementMethod] on every TextView inside a layout. |
|
||||||
* |
|
||||||
* @param linkifyMask One of [Linkify.ALL], [Linkify.PHONE_NUMBERS], [Linkify.MAP_ADDRESSES], |
|
||||||
* [Linkify.WEB_URLS] and [Linkify.EMAIL_ADDRESSES]. |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkify(linkifyMask: Int, activity: Activity): BetterLinkMovementMethod { |
|
||||||
// Find the layout passed to setContentView(). |
|
||||||
val activityLayout = |
|
||||||
(activity.findViewById<View>(Window.ID_ANDROID_CONTENT) as ViewGroup).getChildAt(0) as ViewGroup |
|
||||||
val movementMethod = newInstance() |
|
||||||
rAddLinks(linkifyMask, activityLayout, movementMethod) |
|
||||||
return movementMethod |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Like [.linkify], but can be used for TextViews with HTML links. |
|
||||||
* |
|
||||||
* @return The registered [BetterLinkMovementMethod] on the TextViews. |
|
||||||
*/ |
|
||||||
fun linkifyHtml(activity: Activity): BetterLinkMovementMethod { |
|
||||||
return linkify(LINKIFY_NONE, activity) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned |
|
||||||
* instance is not supported because it will potentially be shared on multiple TextViews. |
|
||||||
*/ |
|
||||||
val instance: BetterLinkMovementMethod? |
|
||||||
get() { |
|
||||||
if (singleInstance == null) { |
|
||||||
singleInstance = BetterLinkMovementMethod() |
|
||||||
} |
|
||||||
return singleInstance |
|
||||||
} |
|
||||||
|
|
||||||
// ======== PUBLIC APIs END ======== // |
|
||||||
private fun rAddLinks( |
|
||||||
linkifyMask: Int, |
|
||||||
viewGroup: ViewGroup, |
|
||||||
movementMethod: BetterLinkMovementMethod |
|
||||||
) { |
|
||||||
for (i in 0 until viewGroup.childCount) { |
|
||||||
val child = viewGroup.getChildAt(i) |
|
||||||
if (child is ViewGroup) { |
|
||||||
// Recursively find child TextViews. |
|
||||||
rAddLinks(linkifyMask, child, movementMethod) |
|
||||||
} else if (child is TextView) { |
|
||||||
addLinks(linkifyMask, movementMethod, child) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun addLinks( |
|
||||||
linkifyMask: Int, |
|
||||||
movementMethod: BetterLinkMovementMethod, |
|
||||||
textView: TextView |
|
||||||
) { |
|
||||||
textView.movementMethod = movementMethod |
|
||||||
if (linkifyMask != LINKIFY_NONE) { |
|
||||||
Linkify.addLinks(textView, linkifyMask) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
Loading…
Reference in new issue