feat: Show share sheet when long clicking links/hashtags/mentions (#1047)

This makes it easier to share links from a regular timeline (where text
is not selectable).

Listen for long clicks on spans by launching a delayed runnable that
waits `getLongPressTimeout()` ms before sending an intent that shows the
share sheet for the clicked span.

If the span's link is not the same as the text (e.g., it's a mention or
a hashtag) then the text of the span is also sent as the title to show
in the share sheet.

While I'm here, the official LinkMovementMethodCompat does the same job
as NoTrailingSpaceLinkMovementMethod, so delete the latter code in
favour of the former.

Fixes #695
This commit is contained in:
Nik Clayton 2024-10-24 22:35:26 +02:00 committed by GitHub
parent e7068da7e5
commit b9459d558b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 136 additions and 76 deletions

View File

@ -19,6 +19,7 @@ package app.pachli.core.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
@ -29,19 +30,38 @@ import android.text.Spanned
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_MOVE
import android.view.MotionEvent.ACTION_UP
import android.view.ViewConfiguration
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat.startActivity
import androidx.core.view.doOnLayout
import app.pachli.core.designsystem.R as DR
import java.lang.Float.max
import java.lang.Float.min
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
import kotlin.math.abs
import timber.log.Timber
/**
* Represents the action to take when the user long-presses on a link.
*
* @param action The action to take.
*/
private class LongPressRunnable(val action: Action) : Runnable {
fun interface Action {
operator fun invoke()
}
override fun run() = action()
}
/**
* Displays text to the user with optional [ClickableSpan]s. Extends the touchable area of the spans
* to ensure they meet the minimum size of 48dp x 48dp for accessibility requirements.
@ -92,6 +112,12 @@ class ClickableSpanTextView @JvmOverloads constructor(
*/
private lateinit var paddingDebugPaint: Paint
/** Runnable to trigger when the user long-presses on a link. */
private var longPressRunnable: LongPressRunnable? = null
/** True if [longPressRunnable] has triggered, false otherwise. */
private var longPressTriggered = false
init {
// Initialise debugging paints, if appropriate. Only ever present in debug builds, and
// is optimised out if showSpanBoundaries is false.
@ -194,9 +220,11 @@ class ClickableSpanTextView @JvmOverloads constructor(
/**
* Handle some touch events.
*
* - [ACTION_DOWN]: Determine which, if any span, has been clicked, and save in clickedSpan
* - [ACTION_UP]: If a span was saved then dispatch the click to that span
* - [ACTION_CANCEL]: Clear the saved span
* - [ACTION_DOWN]: Determine which, if any span, has been clicked, and save in clickedSpan.
* Launch the runnable that will either be cancelled, or act as if this was a long press.
* - [ACTION_UP]: If a span was saved then dispatch the click to that span.
* - [ACTION_CANCEL]: Clear the saved span.
* - [ACTION_MOVE]: Cancel if the user has moved off the span they original touched.
*
* Defer to the parent class for other touches.
*/
@ -207,57 +235,116 @@ class ClickableSpanTextView @JvmOverloads constructor(
when (event.action) {
ACTION_DOWN -> {
// Clear any existing touch actions
removeLongPressRunnable()
longPressTriggered = false
clickedSpan = null
val x = event.x
val y = event.y
// If the user has clicked directly on a span then use it, ignoring any overlap
for ((rect, span) in spanRects) {
if (!rect.contains(x, y)) continue
clickedSpan = span
Timber.v("span click: %s", (clickedSpan as URLSpan).url)
return super.onTouchEvent(event)
}
// Determine the span the user touched. If no span then nothing to do.
val span = getTouchedSpan(event, spanRects) ?: return super.onTouchEvent(event)
// Otherwise, check to see if it's in a touchable area
var activeEntry: MutableMap.MutableEntry<RectF, ClickableSpan>? = null
clickedSpan = span
val url = (span as URLSpan).url
val spanStart = (text as Spanned).getSpanStart(span)
val spanEnd = (text as Spanned).getSpanEnd(span)
val title = text.subSequence(spanStart + 1, spanEnd).toString()
for (entry in delegateRects) {
if (entry == activeEntry) continue
if (!entry.key.contains(x, y)) continue
if (activeEntry == null) {
activeEntry = entry
continue
}
Timber.v("Overlap: %s %s", (entry.value as URLSpan).url, (activeEntry.value as URLSpan).url)
if (isClickOnFirst(entry.key, activeEntry.key, x, y)) {
activeEntry = entry
}
}
clickedSpan = activeEntry?.value
clickedSpan?.let { Timber.v("padding click: %s", (clickedSpan as URLSpan).url) }
return super.onTouchEvent(event)
}
ACTION_UP -> {
clickedSpan?.let {
// Configure and launch the runnable that will act if this is a long-press.
// Opens the chooser with the link the user touched. If the text of the span
// is not the same as the URL it's sent as the title.
longPressRunnable = LongPressRunnable {
longPressTriggered = true
clickedSpan = null
val duration = event.eventTime - event.downTime
if (duration <= ViewConfiguration.getLongPressTimeout()) {
it.onClick(this)
return true
}
this@ClickableSpanTextView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
val shareIntent = Intent.createChooser(
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url)
if (title != url) putExtra(Intent.EXTRA_TITLE, title)
type = "text/plain"
},
null,
)
startActivity(context, shareIntent, null)
}
this@ClickableSpanTextView.postDelayed(
longPressRunnable,
ViewConfiguration.getLongPressTimeout().toLong(),
)
return super.onTouchEvent(event)
}
ACTION_UP -> {
removeLongPressRunnable()
if (longPressTriggered) return true
return clickedSpan?.let {
// If the user released under a different span there's nothing to do.
if (getTouchedSpan(event, spanRects) != it) {
return super.onTouchEvent(event)
}
it.onClick(this@ClickableSpanTextView)
clickedSpan = null
return true
} ?: super.onTouchEvent(event)
}
ACTION_CANCEL -> {
// Clear any existing touch actions
removeLongPressRunnable()
clickedSpan = null
return super.onTouchEvent(event)
}
ACTION_MOVE -> {
if (getTouchedSpan(event, spanRects) != clickedSpan) {
removeLongPressRunnable()
clickedSpan = null
}
return super.onTouchEvent(event)
}
else -> return super.onTouchEvent(event)
}
}
/**
* Returns the span the user clicked on, or null if the click was not over a span.
*/
private fun getTouchedSpan(event: MotionEvent, spanRects: MutableMap<RectF, ClickableSpan>): ClickableSpan? {
val x = event.x
val y = event.y
// If the user has clicked directly on a span then use it, ignoring any overlap
for ((rect, span) in spanRects) {
if (!rect.contains(x, y)) continue
Timber.v("span click: %s", (span as URLSpan).url)
return span
}
// Otherwise, check to see if it's in a touchable area
var activeEntry: MutableMap.MutableEntry<RectF, ClickableSpan>? = null
for (entry in delegateRects) {
if (entry == activeEntry) continue
if (!entry.key.contains(x, y)) continue
if (activeEntry == null) {
activeEntry = entry
continue
}
Timber.v("Overlap: %s %s", (entry.value as URLSpan).url, (activeEntry.value as URLSpan).url)
if (isClickOnFirst(entry.key, activeEntry.key, x, y)) {
activeEntry = entry
}
}
activeEntry?.let { Timber.v("padding click: %s", (activeEntry.value as URLSpan).url) }
return activeEntry?.value
}
/**
* Determine whether a click on overlapping rectangles should be attributed to the first or the
* second rectangle.
@ -363,6 +450,14 @@ class ClickableSpanTextView @JvmOverloads constructor(
canvas.restore()
}
}
/**
* Removes the long-press runnable that may have been added in [onTouchEvent].
*/
private fun removeLongPressRunnable() = longPressRunnable?.let {
this.removeCallbacks(it)
longPressRunnable = null
}
}
/**

View File

@ -16,18 +16,15 @@
package app.pachli.core.ui
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_UP
import android.view.View
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import androidx.core.text.method.LinkMovementMethodCompat
import app.pachli.core.activity.EmojiSpan
import app.pachli.core.common.string.unicodeWrap
import app.pachli.core.network.model.HashTag
@ -71,7 +68,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List<Menti
setClickableText(it, this, mentions, tags, listener)
}
}
view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance()
view.movementMethod = LinkMovementMethodCompat.getInstance()
}
@VisibleForTesting
@ -230,7 +227,7 @@ fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: Lin
start = end
}
}
view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance()
view.movementMethod = LinkMovementMethodCompat.getInstance()
}
fun createClickableText(text: String, link: String): CharSequence {
@ -238,35 +235,3 @@ fun createClickableText(text: String, link: String): CharSequence {
setSpan(NoUnderlineURLSpan(link), 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
/**
* [LinkMovementMethod] that doesn't add a leading/trailing clickable area.
*
* [LinkMovementMethod] has a bug in its calculation of the clickable width of a span on a line. If
* the span is the last thing on the line the clickable area extends to the end of the view. So the
* user can tap what appears to be whitespace and open a link.
*
* Fix this by overriding ACTION_UP touch events and calculating the true start and end of the
* content on the line that was tapped. Then ignore clicks that are outside this area.
*
* See https://github.com/tuskyapp/Tusky/issues/1567.
*/
object NoTrailingSpaceLinkMovementMethod : LinkMovementMethod() {
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.action
if (action != ACTION_UP) return super.onTouchEvent(widget, buffer, event)
val x = event.x.toInt()
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
val line = widget.layout.getLineForVertical(y)
val lineLeft = widget.layout.getLineLeft(line)
val lineRight = widget.layout.getLineRight(line)
if (x > lineRight || x >= 0 && x < lineLeft) {
return true
}
return super.onTouchEvent(widget, buffer, event)
}
fun getInstance() = NoTrailingSpaceLinkMovementMethod
}