Browse Source

Improve animation of Swipe to reply: replace usage of SwipeToDismiss by Modifier.draggable

pull/826/head
Benoit Marty 1 year ago committed by Benoit Marty
parent
commit
1bc13d16d9
  1. 78
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
  2. 75
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt

78
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt

@ -14,13 +14,13 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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( @@ -110,6 +113,7 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() {
@ -123,38 +127,30 @@ fun TimelineItemEventRow( @@ -123,38 +127,30 @@ fun TimelineItemEventRow(
Column(modifier = modifier.fillMaxWidth()) {
if (canReply) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToEnd) {
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 })
}
TimelineItemEventRowContent(
modifier = Modifier
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
.draggable(
orientation = Orientation.Horizontal,
enabled = !state.isResettingOnRelease,
onDragStopped = {
coroutineScope.launch {
if (thresholdCrossed) {
onSwipeToReply()
}
// Do not dismiss the message, return false!
false
state.resetOffset()
}
)
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(
state = state.draggableState,
),
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
@ -174,6 +170,7 @@ fun TimelineItemEventRow( @@ -174,6 +170,7 @@ fun TimelineItemEventRow(
}
}
}
}
@Composable
private fun TimelineItemEventRowContent(
@ -267,14 +264,6 @@ private fun TimelineItemEventRowContent( @@ -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) { @@ -700,7 +689,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
}
}
@Preview
@Composable
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =

75
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt

@ -0,0 +1,75 @@ @@ -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
}
}
}
}
Loading…
Cancel
Save