Merge pull request #826 from vector-im/feature/bma/swipeAction
Improve swipe to reply rendering
This commit is contained in:
1
.idea/dictionaries/shared.xml
generated
1
.idea/dictionaries/shared.xml
generated
@@ -9,6 +9,7 @@
|
||||
<w>placeables</w>
|
||||
<w>showkase</w>
|
||||
<w>snackbar</w>
|
||||
<w>swipeable</w>
|
||||
<w>textfields</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
||||
@@ -247,6 +247,7 @@ koverMerged {
|
||||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
excludes += "io.element.android.features.location.impl.map.MapState"
|
||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
|
||||
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -35,26 +36,25 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DismissDirection
|
||||
import androidx.compose.material3.DismissState
|
||||
import androidx.compose.material3.DismissValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismiss
|
||||
import androidx.compose.material3.rememberDismissState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
@@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
|
||||
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventRow(
|
||||
@@ -110,6 +117,7 @@ fun TimelineItemEventRow(
|
||||
onSwipeToReply: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
fun onUserDataClicked() {
|
||||
@@ -121,56 +129,88 @@ fun TimelineItemEventRow(
|
||||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
if (canReply) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
onSwipeToReply()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
if (canReply) {
|
||||
val state: SwipeableActionsState = rememberSwipeableActionsState()
|
||||
val offset = state.offset.value
|
||||
val swipeThresholdPx = 40.dp.toPx()
|
||||
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
||||
SwipeSensitivity(3f) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.matchParentSize()) {
|
||||
ReplySwipeIndicator({ offset / 120 })
|
||||
}
|
||||
TimelineItemEventRowContent(
|
||||
modifier = Modifier
|
||||
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
enabled = !state.isResettingOnRelease,
|
||||
onDragStopped = {
|
||||
coroutineScope.launch {
|
||||
if (thresholdCrossed) {
|
||||
onSwipeToReply()
|
||||
}
|
||||
state.resetOffset()
|
||||
}
|
||||
},
|
||||
state = state.draggableState,
|
||||
),
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
// Do not dismiss the message, return false!
|
||||
false
|
||||
}
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {
|
||||
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
||||
},
|
||||
directions = setOf(DismissDirection.StartToEnd),
|
||||
dismissContent = {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
} else {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
|
||||
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
|
||||
* @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will
|
||||
* have to drag to start the drag.
|
||||
* @param content the content to display.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeSensitivity(
|
||||
sensitivityFactor: Float,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val current = LocalViewConfiguration.current
|
||||
CompositionLocalProvider(
|
||||
LocalViewConfiguration provides object : ViewConfiguration by current {
|
||||
override val touchSlop: Float
|
||||
get() = current.touchSlop * sensitivityFactor
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,14 +306,6 @@ private fun TimelineItemEventRowContent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DismissState.toSwipeProgress(): Float {
|
||||
return when (targetValue) {
|
||||
DismissValue.Default -> 0f
|
||||
DismissValue.DismissedToEnd -> progress * 3
|
||||
DismissValue.DismissedToStart -> progress * 3
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
@@ -544,6 +576,7 @@ private fun ContentToPreview() {
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
@@ -562,6 +595,7 @@ private fun ContentToPreview() {
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
@@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
@@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
@@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* 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 io.element.android.libraries.designsystem.swipe
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Inspired from https://github.com/bmarty/swipe/blob/trunk/swipe/src/main/kotlin/me/saket/swipe/SwipeableActionsState.kt
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSwipeableActionsState(): SwipeableActionsState {
|
||||
return remember { SwipeableActionsState() }
|
||||
}
|
||||
|
||||
@Stable
|
||||
class SwipeableActionsState {
|
||||
/**
|
||||
* The current position (in pixels) of the content.
|
||||
*/
|
||||
val offset: State<Float> get() = offsetState
|
||||
private var offsetState = mutableStateOf(0f)
|
||||
|
||||
/**
|
||||
* Whether the content is currently animating to reset its offset after it was swiped.
|
||||
*/
|
||||
var isResettingOnRelease: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val draggableState = DraggableState { delta ->
|
||||
val targetOffset = offsetState.value + delta
|
||||
val isAllowed = isResettingOnRelease || targetOffset > 0f
|
||||
|
||||
offsetState.value += if (isAllowed) delta else 0f
|
||||
}
|
||||
|
||||
suspend fun resetOffset() {
|
||||
draggableState.drag(MutatePriority.PreventUserInput) {
|
||||
isResettingOnRelease = true
|
||||
try {
|
||||
Animatable(offsetState.value).animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
) {
|
||||
dragBy(value - offsetState.value)
|
||||
}
|
||||
} finally {
|
||||
isResettingOnRelease = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user