diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 815c3a753c..6152a752cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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,15 +36,10 @@ 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.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,6 +51,7 @@ 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 @@ -78,6 +75,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 +93,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 +113,7 @@ fun TimelineItemEventRow( onSwipeToReply: () -> Unit, modifier: Modifier = Modifier ) { + val coroutineScope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } fun onUserDataClicked() { @@ -123,54 +127,47 @@ fun TimelineItemEventRow( Column(modifier = modifier.fillMaxWidth()) { if (canReply) { - val dismissState = rememberDismissState( - confirmValueChange = { - if (it == DismissValue.DismissedToEnd) { - onSwipeToReply() - } - // 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) }, - ) + val state: SwipeableActionsState = rememberSwipeableActionsState() + val offset = state.offset.value + val swipeThresholdPx = 40.dp.toPx() + val thresholdCrossed = abs(offset) > swipeThresholdPx + Box(Modifier.fillMaxWidth()) { + Row(modifier = Modifier.matchParentSize()) { + ReplySwipeIndicator({ offset / 120 }) } - ) - } 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) }, - ) - } - if (event.groupPosition.isNew()) { - Spacer(modifier = Modifier.height(16.dp)) - } else { - Spacer(modifier = Modifier.height(2.dp)) + 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) }, + ) + } + if (event.groupPosition.isNew()) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(2.dp)) + } } } } @@ -267,14 +264,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, @@ -700,7 +689,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) { } } - @Preview @Composable internal fun TimelineItemEventRowWithManyReactionsLightPreview() = diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt new file mode 100644 index 0000000000..77d80cdde5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt @@ -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 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 + } + } + } +}