Removed gesture interception while long clicking on a chat bubble (#871)

* Removed gesture interception while long clicking on a chat bubble with a link
- allowed to skip motion event consuming based on touch offset
- long clicking on a link copies it to a clipboard

* Long click on a link shows menu instead of copying to clipboard

* EOLs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2022-08-03 16:33:19 +03:00
committed by GitHub
parent 9e210256d2
commit 4c6ee95eb7
5 changed files with 259 additions and 13 deletions

View File

@@ -80,6 +80,7 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui-util:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
implementation 'androidx.webkit:webkit:1.4.0'

View File

@@ -1,6 +1,6 @@
package chat.simplex.app.views.chat.item
import android.content.Context
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,8 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -62,7 +61,8 @@ fun ChatItemView(
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
}
DropdownMenu(
expanded = showMenu.value,

View File

@@ -39,7 +39,8 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {}
) {
val sent = ci.chatDir.sent
@@ -136,9 +137,9 @@ fun FramedItemView(
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, uriHandler)
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
}
}
@@ -151,11 +152,17 @@ fun FramedItemView(
}
@Composable
fun CIMarkdownText(ci: ChatItem, showMember: Boolean, uriHandler: UriHandler?) {
fun CIMarkdownText(
ci: ChatItem,
showMember: Boolean,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
metaText = ci.timestampText, edited = ci.meta.itemEdited,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
}

View File

@@ -1,17 +1,19 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -45,7 +47,8 @@ fun MarkdownText (
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onLinkLongClick: (link: String) -> Unit = {}
) {
val reserve = if (edited) " " else " "
if (formattedText == null) {
@@ -77,9 +80,16 @@ fun MarkdownText (
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
}
)
} else {
@@ -87,3 +97,53 @@ fun MarkdownText (
}
}
}
@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit = {},
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
detectGesture(onLongPress = { pos ->
layoutResult.value?.let { layoutResult ->
onLongClick(layoutResult.getOffsetForPosition(pos))
}
}, onPress = { pos ->
layoutResult.value?.let { layoutResult ->
val res = tryAwaitRelease()
if (res) {
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
}
consume
}
)
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package chat.simplex.app.views.helpers
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
* */
interface PressGestureScope : Density {
suspend fun tryAwaitRelease(): Boolean
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
suspend fun PointerInputScope.detectGesture(
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
shouldConsumeEvent: (Offset) -> Boolean
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectGesture)
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown()
// If shouldConsumeEvent == false, all touches will be propagated to parent
val shouldConsume = shouldConsumeEvent(down.position)
if (shouldConsume)
down.consumeDownChange()
pressScope.reset()
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
try {
val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
pressScope.cancel()
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
// If onLongPress event is needed, cancel short press event
if (onLongPress != null)
pressScope.cancel()
else
pressScope.release()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
if (shouldConsume)
consumeUntilUp()
pressScope.release()
}
}
}
}
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consumeAllChanges() }
} while (event.changes.fastAny { it.pressed })
}
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true
): PointerInputChange =
awaitFirstDownOnPass(pass = PointerEventPass.Main, requireUnconsumed = requireUnconsumed)
internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
pass: PointerEventPass,
requireUnconsumed: Boolean
): PointerInputChange {
var event: PointerEvent
do {
event = awaitPointerEvent(pass)
} while (
!event.changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
)
return event.changes[0]
}
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.fastAll { it.changedToUp() }) {
return event.changes[0]
}
if (event.changes.fastAny {
it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding)
}
) {
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
}
}
}
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
fun cancel() {
isCanceled = true
mutex.unlock()
}
fun release() {
isReleased = true
mutex.unlock()
}
fun reset() {
mutex.tryLock()
isReleased = false
isCanceled = false
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
}
return isCanceled
}
}