From b9459d558b5d059e78629ecdf7e10b4d947d48e2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 24 Oct 2024 22:35:26 +0200 Subject: [PATCH] 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 --- .../pachli/core/ui/ClickableSpanTextView.kt | 171 ++++++++++++++---- .../kotlin/app/pachli/core/ui/LinkHelper.kt | 41 +---- 2 files changed, 136 insertions(+), 76 deletions(-) diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/ClickableSpanTextView.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/ClickableSpanTextView.kt index 21fc9db9e..b46a6cddc 100644 --- a/core/ui/src/main/kotlin/app/pachli/core/ui/ClickableSpanTextView.kt +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/ClickableSpanTextView.kt @@ -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? = 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): 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? = 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 + } } /** diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/LinkHelper.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/LinkHelper.kt index 4fcb8ea2a..5aeb22ab3 100644 --- a/core/ui/src/main/kotlin/app/pachli/core/ui/LinkHelper.kt +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/LinkHelper.kt @@ -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?, 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 -}