Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crash forensics are already well documented in the PR description and commit. Let's replace with something like this:

/**
 * 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,
Expand All @@ -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 -> {
Expand All @@ -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(
Expand All @@ -72,9 +95,6 @@ class LinkLongPressMovementMethod : LinkMovementMethod() {

longPressRunnable =
Runnable {
if (widget.hasSelection()) {
Selection.removeSelection(widget.text as Spannable)
}
if (span.onLongClick(widget)) {
widget.cancelLongPress()
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as above 🔝

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
Expand All @@ -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 -> {
Expand All @@ -47,49 +56,45 @@ 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
}
}

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(
Expand All @@ -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)
Expand Down
Loading