Skip to content
This repository has been archived by the owner on Sep 27, 2024. It is now read-only.

Commit

Permalink
[Android] Make some spans clickable (pills, links) in TextView (#860)
Browse files Browse the repository at this point in the history
* Make some spans clickable (pills, links)

* Address review comments:

- Made sure autodetected links are clickable.
- Make sure touch events are properly recognised and can't affect scrolling.
- Add missing tests.

* Simplify code by using a `GestureDetector` to detect clicks on spans instead
  • Loading branch information
jmartinesp authored Nov 9, 2023
1 parent d0ba997 commit 06463e6
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.element.wysiwyg.compose

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.BorderStroke
Expand Down Expand Up @@ -92,7 +93,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
Surface(
Expand Down Expand Up @@ -122,6 +123,9 @@ class MainActivity : ComponentActivity() {
.padding(16.dp),
resolveMentionDisplay = { _,_ -> TextDisplay.Pill },
resolveRoomMentionDisplay = { TextDisplay.Pill },
onLinkClickedListener = { url ->
Toast.makeText(this@MainActivity, "Clicked: $url", Toast.LENGTH_SHORT).show()
}
)

Spacer(modifier = Modifier.weight(1f))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ fun SuggestionView(
is Mention.SlashCommand -> {
onReplaceSuggestion(item.text)
}
is Mention.Room -> {
// TODO Handle room mention
}
else -> {
onInsertMentionAtSuggestion(item.text, item.link)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ fun EditorStyledText(
modifier: Modifier = Modifier,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay = RichTextEditorDefaults.MentionDisplay,
resolveRoomMentionDisplay: () -> TextDisplay = RichTextEditorDefaults.RoomMentionDisplay,
onLinkClickedListener: ((String) -> Unit) = {},
style: RichTextEditorStyle = RichTextEditorDefaults.style(),
) {
val typeface by style.text.rememberTypeface()
Expand All @@ -54,7 +55,7 @@ fun EditorStyledText(
// The `update` lambda is called when the view is first created, and then again whenever the actual `update` lambda changes. That is, it's replaced with
// a new lambda capturing different variables from the surrounding scope. However, there seems to be an issue that causes the `update` lambda to change
// more than it's strictly necessary. To avoid this, we can use a `remember` block to cache the `update` lambda, and only update it when needed.
update = remember(style, typeface, mentionDisplayHandler, text) {
update = remember(style, typeface, mentionDisplayHandler, text, onLinkClickedListener) {
{ view ->
view.applyStyleInCompose(style)
view.typeface = typeface
Expand All @@ -64,6 +65,7 @@ fun EditorStyledText(
} else {
view.setHtml(text.toString())
}
view.onLinkClickedListener = onLinkClickedListener
}
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
package io.element.android.wysiwyg

import android.graphics.Canvas
import android.graphics.Paint
import android.text.style.ReplacementSpan
import android.widget.TextView
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import io.element.android.wysiwyg.test.R
import io.element.android.wysiwyg.test.utils.TestActivity
import io.element.android.wysiwyg.test.utils.TextViewActions
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import io.element.android.wysiwyg.view.spans.LinkSpan
import io.element.android.wysiwyg.view.spans.PillSpan
import org.junit.Assert
import org.junit.Rule
import org.junit.Test

Expand All @@ -22,4 +33,80 @@ internal class EditorStyledTextViewTest {
.perform(TextViewActions.setText("Hello, world"))
.check(matches(withText("Hello, world")))
}
}

@Test
fun testLinkClicks() {
var pass = false
scenarioRule.scenario.onActivity {
it.findViewById<EditorStyledTextView>(R.id.styledTextView).apply {
val spanned = buildSpannedString {
inSpans(LinkSpan("")) {
append("Hello, world")
}
}
setText(spanned, TextView.BufferType.SPANNABLE)
onLinkClickedListener = {
pass = true
}
}
}
onView(ViewMatchers.withId(R.id.styledTextView))
.check(matches(withText("Hello, world")))
.perform(ViewActions.click())

Assert.assertTrue(pass)
}

@Test
fun testPillSpanClicks() {
var pass = false
scenarioRule.scenario.onActivity {
it.findViewById<EditorStyledTextView>(R.id.styledTextView).apply {
val spanned = buildSpannedString {
inSpans(PillSpan(backgroundColor = 0, url = "")) {
append("Hello, world")
}
}
setText(spanned, TextView.BufferType.SPANNABLE)
onLinkClickedListener = {
pass = true
}
}
}
onView(ViewMatchers.withId(R.id.styledTextView))
.check(matches(withText("Hello, world")))
.perform(ViewActions.click())

Assert.assertTrue(pass)
}

@Test
fun testCustomMentionSpanClicks() {
var pass = false
scenarioRule.scenario.onActivity {
it.findViewById<EditorStyledTextView>(R.id.styledTextView).apply {
val spanned = buildSpannedString {
inSpans(CustomMentionSpan(DummyReplacementSpan, url = "")) {
append("Hello, world")
}
}
setText(spanned, TextView.BufferType.SPANNABLE)
onLinkClickedListener = {
pass = true
}
}
}
onView(ViewMatchers.withId(R.id.styledTextView))
.check(matches(withText("Hello, world")))
.perform(ViewActions.click())

Assert.assertTrue(pass)
}
}

object DummyReplacementSpan : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int = 1

override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) = Unit

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import android.content.Context
import android.graphics.Canvas
import android.text.Spanned
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.graphics.withTranslation
import androidx.core.text.getSpans
import androidx.core.view.GestureDetectorCompat
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.internal.view.EditorEditTextAttributeReader
import io.element.android.wysiwyg.utils.HtmlConverter
import io.element.android.wysiwyg.view.StyleConfig
import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelper
import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelperFactory
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import io.element.android.wysiwyg.view.spans.LinkSpan
import io.element.android.wysiwyg.view.spans.PillSpan
import io.element.android.wysiwyg.view.spans.ReuseSourceSpannableFactory
import uniffi.wysiwyg_composer.MentionDetector
import uniffi.wysiwyg_composer.newMentionDetector
Expand Down Expand Up @@ -39,6 +46,33 @@ open class EditorStyledTextView : AppCompatTextView {
private var mentionDisplayHandler: MentionDisplayHandler? = null
private var htmlConverter: HtmlConverter? = null

var onLinkClickedListener: ((String) -> Unit)? = null

// This gesture detector will be used to detect clicks on spans
private val gestureDetector = GestureDetectorCompat(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
// Find any spans in the coordinates
val spans = findSpansForTouchEvent(e)

// Notify the link has been clicked
for (span in spans) {
when (span) {
is LinkSpan -> {
onLinkClickedListener?.invoke(span.url)
}
is PillSpan -> {
span.url?.let { onLinkClickedListener?.invoke(it) }
}
is CustomMentionSpan -> {
span.url?.let { onLinkClickedListener?.invoke(it) }
}
else -> Unit
}
}
return true
}
})

init {
setSpannableFactory(spannableFactory)
isInit = true
Expand Down Expand Up @@ -128,4 +162,19 @@ open class EditorStyledTextView : AppCompatTextView {
}
)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
// We pass the event to the gesture detector
event?.let { gestureDetector.onTouchEvent(it) }
// This will handle the default actions for any touch event in the TextView
super.onTouchEvent(event)
// We need to return true to be able to detect any events
return true
}

private fun findSpansForTouchEvent(event: MotionEvent): Array<out Any> {
// Find selection matching the pointer coordinates
val offset = getOffsetForPosition(event.x, event.y)
return (text as? Spanned)?.getSpans<Any>(offset, offset).orEmpty()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import io.element.android.wysiwyg.view.StyleConfig
import io.element.android.wysiwyg.view.models.InlineFormat
import io.element.android.wysiwyg.view.spans.BlockSpan
import io.element.android.wysiwyg.view.spans.CodeBlockSpan
import io.element.android.wysiwyg.view.spans.CustomReplacementSpan
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import io.element.android.wysiwyg.view.spans.ExtraCharacterSpan
import io.element.android.wysiwyg.view.spans.InlineCodeSpan
import io.element.android.wysiwyg.view.spans.LinkSpan
Expand Down Expand Up @@ -352,15 +352,15 @@ internal class HtmlToSpansParser(

when (textDisplay) {
is TextDisplay.Custom -> {
val span = CustomReplacementSpan(textDisplay.customSpan)
val span = CustomMentionSpan(textDisplay.customSpan, url)
replacePlaceholderWithPendingSpan(
last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}

TextDisplay.Pill -> {
val pillBackground = styleConfig.pill.backgroundColor
val span = PillSpan(pillBackground)
val span = PillSpan(pillBackground, url)
replacePlaceholderWithPendingSpan(
last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
Expand Down Expand Up @@ -510,7 +510,7 @@ internal class HtmlToSpansParser(
return@eachMatch
}
val span = when (display) {
is TextDisplay.Custom -> CustomReplacementSpan(display.customSpan)
is TextDisplay.Custom -> CustomMentionSpan(display.customSpan)
TextDisplay.Pill -> PillSpan(
styleConfig.pill.backgroundColor
)
Expand Down Expand Up @@ -544,7 +544,7 @@ internal class HtmlToSpansParser(
// Links
LinkSpan::class.java,
PillSpan::class.java,
CustomReplacementSpan::class.java,
CustomMentionSpan::class.java,

// Lists
UnorderedListSpan::class.java,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import android.text.style.ReplacementSpan
* It is used to allow reuse of the same underlying span across multiple ranges
* of a spanned text.
*/
internal class CustomReplacementSpan(
private val providedSpan: ReplacementSpan
internal class CustomMentionSpan(
private val providedSpan: ReplacementSpan,
val url: String? = null,
) : ReplacementSpan() {
override fun draw(
canvas: Canvas,
Expand All @@ -36,4 +37,4 @@ internal class CustomReplacementSpan(
): Int = providedSpan.getSize(
paint, text, start, end, fm
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlin.math.roundToInt
internal class PillSpan(
@ColorInt
val backgroundColor: Int,
val url: String? = null,
) : ReplacementSpan() {
override fun getSize(
paint: Paint,
Expand Down Expand Up @@ -40,4 +41,4 @@ internal class PillSpan(
paint.color = paintColor
canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint)
}
}
}

0 comments on commit 06463e6

Please sign in to comment.