Swipe to reply feature (#936)

* Swipe to reply feature
- ability to reply by swiping on a message from right-to-left or left-to-right
- keyboard will be open automatically

* Only one direction for swipe to reply action
This commit is contained in:
Stanislav Dmitrenko
2022-08-16 15:08:15 +03:00
committed by GitHub
parent 3776e1c29c
commit 0a2f7681d8
3 changed files with 93 additions and 12 deletions

View File

@@ -241,12 +241,18 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = floatingButton.value,
) { contentPadding ->
BoxWithConstraints(Modifier.padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems,
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, floatingButton
)
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) {
BoxWithConstraints(Modifier.padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems,
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, floatingButton
)
}
}
}
}
@@ -390,12 +396,33 @@ fun BoxWithConstraintsScope.ChatItemsList(
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
val directions = setOf(DismissDirection.EndToStart)
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = directions,
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
LaunchedEffect(Unit) {
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
}
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
@@ -420,7 +447,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
@@ -430,7 +457,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
Modifier.padding(
start = if (sent) 76.dp else 12.dp,
end = if (sent) 12.dp else 76.dp,
)
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
@@ -634,6 +661,19 @@ private fun bottomEndFloatingButton(
}
}
private fun ViewConfiguration.bigTouchSlop(slop: Float = 80f) = object: ViewConfiguration {
override val longPressTimeoutMillis
get() =
this@bigTouchSlop.longPressTimeoutMillis
override val doubleTapTimeoutMillis
get() =
this@bigTouchSlop.doubleTapTimeoutMillis
override val doubleTapMinTimeMillis
get() =
this@bigTouchSlop.doubleTapMinTimeMillis
override val touchSlop: Float get() = slop
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -26,7 +28,9 @@ import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
@@ -35,6 +39,16 @@ fun SendMsgView(
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
focusRequester.requestFocus()
delay(50)
keyboard?.show()
}
BasicTextField(
value = cs.message,
onValueChange = onMessageChange,
@@ -44,7 +58,7 @@ fun SendMsgView(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(vertical = 8.dp),
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(

View File

@@ -1,7 +1,15 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.offset
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
@@ -15,3 +23,22 @@ fun Modifier.badgeLayout() =
placeable.place((width - placeable.width) / 2, 0)
}
}
@Composable
fun SwipeToDismissModifier(
state: DismissState,
directions: Set<DismissDirection> = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd),
swipeDistance: Float,
): Modifier {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val anchors = mutableMapOf(0f to DismissValue.Default)
if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd
if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart
return Modifier.swipeable(
state = state,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal,
reverseDirection = isRtl,
).offset { IntOffset(state.offset.value.roundToInt(), 0) }
}