diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt index 8ae6ed88c..fafd3e159 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt @@ -2,24 +2,34 @@ package com.swmansion.enriched.markdown.utils.text.view import android.os.Handler import android.os.Looper -import android.text.Selection import android.text.Spannable -import android.text.method.LinkMovementMethod +import android.text.method.ArrowKeyMovementMethod import android.view.MotionEvent import android.view.ViewConfiguration import android.widget.TextView import com.swmansion.enriched.markdown.spans.LinkSpan import kotlin.math.abs -class LinkLongPressMovementMethod : LinkMovementMethod() { +/** + * Movement method that adds link tap / long-press handling on top of + * [ArrowKeyMovementMethod], the method [setTextIsSelectable] installs. + * + * Must never mutate the buffer's Selection spans — the platform Editor + * manages them during long-press gestures, and removing or overwriting + * them mid-gesture crashes on some OEM skins. + * See: https://github.com/software-mansion/react-native-enriched-markdown/issues/580 + */ +class LinkLongPressMovementMethod : ArrowKeyMovementMethod() { private val handler = Handler(Looper.getMainLooper()) private var longPressRunnable: Runnable? = null private var startX = 0f private var startY = 0f + private var pressedLink: LinkSpan? = null var isLinkTouchActive: Boolean = false private set + private var isTouchWithinTextBounds: Boolean = true override fun onTouchEvent( widget: TextView, @@ -31,9 +41,10 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { startX = event.x startY = event.y - val span = findLinkSpan(widget, buffer, event) - isLinkTouchActive = span != null - span?.let { scheduleLongPress(widget, it) } + pressedLink = findLinkSpan(widget, buffer, event) + isLinkTouchActive = pressedLink != null + isTouchWithinTextBounds = charOffsetAt(widget, event) != null + pressedLink?.let { scheduleLongPress(widget, it) } } MotionEvent.ACTION_MOVE -> { @@ -43,25 +54,37 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { ) { cancelLongPress() isLinkTouchActive = false + pressedLink = null } } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { cancelLongPress() + val tappedLink = pressedLink isLinkTouchActive = false - if (widget.hasSelection()) { - Selection.removeSelection(buffer) + pressedLink = null + + // LinkSpan.onClick itself swallows the click that follows a completed + // long-press (and resets its internal flag), so it is always invoked + // for a tap that started and ended on the same link. + if (tappedLink != null && findLinkSpan(widget, buffer, event) === tappedLink) { + tappedLink.onClick(widget) + return true } } - } - val result = super.onTouchEvent(widget, buffer, event) + MotionEvent.ACTION_CANCEL -> { + cancelLongPress() + isLinkTouchActive = false + pressedLink = null + } + } - if (event.action == MotionEvent.ACTION_DOWN) { - Selection.removeSelection(buffer) + if (!isTouchWithinTextBounds) { + return false } - return result + return super.onTouchEvent(widget, buffer, event) } private fun scheduleLongPress( @@ -72,9 +95,6 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { longPressRunnable = Runnable { - if (widget.hasSelection()) { - Selection.removeSelection(widget.text as Spannable) - } if (span.onLongClick(widget)) { widget.cancelLongPress() } @@ -93,10 +113,21 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { widget: TextView, event: MotionEvent, ): Int? { - val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX - val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY + val x = event.x - widget.totalPaddingLeft + widget.scrollX + val y = event.y - widget.totalPaddingTop + widget.scrollY val layout = widget.layout ?: return null - return layout.getOffsetForHorizontal(layout.getLineForVertical(y), x.toFloat()) + + if (y < 0f || y > layout.height) { + return null + } + + val line = layout.getLineForVertical(y.toInt()) + + if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) { + return null + } + + return layout.getOffsetForHorizontal(line, x) } private fun findLinkSpan( diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt index 8fda52583..5f32dc256 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt @@ -2,9 +2,8 @@ package com.swmansion.enriched.markdown.utils.text.view import android.os.Handler import android.os.Looper -import android.text.Selection import android.text.Spannable -import android.text.method.LinkMovementMethod +import android.text.method.ArrowKeyMovementMethod import android.view.MotionEvent import android.view.ViewConfiguration import android.widget.TextView @@ -13,12 +12,22 @@ import com.swmansion.enriched.markdown.spans.SpoilerSpan import com.swmansion.enriched.markdown.spoiler.SpoilerCapable import kotlin.math.abs -class LinkLongPressMovementMethod : LinkMovementMethod() { +/** + * Movement method that adds link tap / long-press and spoiler tap handling on + * top of [ArrowKeyMovementMethod], the method [setTextIsSelectable] installs. + * + * Must never mutate the buffer's Selection spans — the platform Editor + * manages them during long-press gestures, and removing or overwriting + * them mid-gesture crashes on some OEM skins. + * See: https://github.com/software-mansion/react-native-enriched-markdown/issues/580 + */ +class LinkLongPressMovementMethod : ArrowKeyMovementMethod() { private val handler = Handler(Looper.getMainLooper()) private var longPressRunnable: Runnable? = null private var startX = 0f private var startY = 0f + private var pressedLink: LinkSpan? = null var isLinkTouchActive: Boolean = false private set @@ -34,10 +43,10 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { startX = event.x startY = event.y - val span = findLinkSpan(widget, buffer, event) - isLinkTouchActive = span != null + pressedLink = findLinkSpan(widget, buffer, event) + isLinkTouchActive = pressedLink != null isTouchWithinTextBounds = charOffsetAt(widget, event) != null - span?.let { scheduleLongPress(widget, it) } + pressedLink?.let { scheduleLongPress(widget, it) } } MotionEvent.ACTION_MOVE -> { @@ -47,17 +56,25 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { ) { cancelLongPress() isLinkTouchActive = false + pressedLink = null } } MotionEvent.ACTION_UP -> { cancelLongPress() + val tappedLink = pressedLink isLinkTouchActive = false - if (widget.hasSelection()) { - Selection.removeSelection(buffer) - } + pressedLink = null + if (handleSpoilerTap(widget, buffer, event)) { - Selection.removeSelection(buffer) + return true + } + + // LinkSpan.onClick itself swallows the click that follows a completed + // long-press (and resets its internal flag), so it is always invoked + // for a tap that started and ended on the same link. + if (tappedLink != null && findLinkSpan(widget, buffer, event) === tappedLink) { + tappedLink.onClick(widget) return true } } @@ -65,31 +82,19 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { MotionEvent.ACTION_CANCEL -> { cancelLongPress() isLinkTouchActive = false - if (widget.hasSelection()) { - Selection.removeSelection(buffer) - } + pressedLink = null } } - // LinkMovementMethod.onTouchEvent does its own getOffsetForHorizontal - // without bounds checking, so it would click the nearest link even for - // taps in empty space past the end of a line. Skip the super call when - // the ACTION_DOWN landed outside actual text content so the touch falls - // through to the parent (e.g. RNGH Pressable). + // getOffsetForHorizontal snaps to the nearest character, so without this + // guard taps in empty space past the end of a line would still be treated + // as text touches. Let them fall through to the parent (e.g. RNGH + // Pressable) instead. if (!isTouchWithinTextBounds) { return false } - val result = super.onTouchEvent(widget, buffer, event) - - // LinkMovementMethod sets a Selection highlight around the link on ACTION_DOWN, - // which causes a visible selection color on the link text while pressed. - // We remove that selection immediately so the user never sees it. - if (event.action == MotionEvent.ACTION_DOWN) { - Selection.removeSelection(buffer) - } - - return result + return super.onTouchEvent(widget, buffer, event) } private fun scheduleLongPress( @@ -100,9 +105,6 @@ class LinkLongPressMovementMethod : LinkMovementMethod() { longPressRunnable = Runnable { - if (widget.hasSelection()) { - Selection.removeSelection(widget.text as Spannable) - } // Execute the long click logic on the span if (span.onLongClick(widget)) { // If consumed, cancel the system's own long-press logic (like context menus)