Improve animation of Swipe to reply: replace usage of SwipeToDismiss by Modifier.draggable
This commit is contained in:
parent
6e65b7db86
commit
1d6bfd19f1
2 changed files with 127 additions and 64 deletions
|
|
@ -14,13 +14,13 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.SwipeToDismiss
|
|
||||||
import androidx.compose.material3.rememberDismissState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
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.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
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.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.matrix.ui.components.AttachmentThumbnailType
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemEventRow(
|
fun TimelineItemEventRow(
|
||||||
|
|
@ -110,6 +113,7 @@ fun TimelineItemEventRow(
|
||||||
onSwipeToReply: () -> Unit,
|
onSwipeToReply: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
fun onUserDataClicked() {
|
fun onUserDataClicked() {
|
||||||
|
|
@ -123,54 +127,47 @@ fun TimelineItemEventRow(
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
if (canReply) {
|
if (canReply) {
|
||||||
val dismissState = rememberDismissState(
|
val state: SwipeableActionsState = rememberSwipeableActionsState()
|
||||||
confirmValueChange = {
|
val offset = state.offset.value
|
||||||
if (it == DismissValue.DismissedToEnd) {
|
val swipeThresholdPx = 40.dp.toPx()
|
||||||
onSwipeToReply()
|
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
||||||
}
|
Box(Modifier.fillMaxWidth()) {
|
||||||
// Do not dismiss the message, return false!
|
Row(modifier = Modifier.matchParentSize()) {
|
||||||
false
|
ReplySwipeIndicator({ offset / 120 })
|
||||||
}
|
}
|
||||||
)
|
TimelineItemEventRowContent(
|
||||||
SwipeToDismiss(
|
modifier = Modifier
|
||||||
state = dismissState,
|
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
||||||
background = {
|
.draggable(
|
||||||
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
orientation = Orientation.Horizontal,
|
||||||
},
|
enabled = !state.isResettingOnRelease,
|
||||||
directions = setOf(DismissDirection.StartToEnd),
|
onDragStopped = {
|
||||||
dismissContent = {
|
coroutineScope.launch {
|
||||||
TimelineItemEventRowContent(
|
if (thresholdCrossed) {
|
||||||
event = event,
|
onSwipeToReply()
|
||||||
isHighlighted = isHighlighted,
|
}
|
||||||
interactionSource = interactionSource,
|
state.resetOffset()
|
||||||
onClick = onClick,
|
}
|
||||||
onLongClick = onLongClick,
|
},
|
||||||
onTimestampClicked = onTimestampClicked,
|
state = state.draggableState,
|
||||||
inReplyToClicked = ::inReplyToClicked,
|
),
|
||||||
onUserDataClicked = ::onUserDataClicked,
|
event = event,
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
isHighlighted = isHighlighted,
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
interactionSource = interactionSource,
|
||||||
)
|
onClick = onClick,
|
||||||
}
|
onLongClick = onLongClick,
|
||||||
)
|
onTimestampClicked = onTimestampClicked,
|
||||||
} else {
|
inReplyToClicked = ::inReplyToClicked,
|
||||||
TimelineItemEventRowContent(
|
onUserDataClicked = ::onUserDataClicked,
|
||||||
event = event,
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
isHighlighted = isHighlighted,
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
interactionSource = interactionSource,
|
)
|
||||||
onClick = onClick,
|
}
|
||||||
onLongClick = onLongClick,
|
if (event.groupPosition.isNew()) {
|
||||||
onTimestampClicked = onTimestampClicked,
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
inReplyToClicked = ::inReplyToClicked,
|
} else {
|
||||||
onUserDataClicked = ::onUserDataClicked,
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
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
|
@Composable
|
||||||
private fun MessageSenderInformation(
|
private fun MessageSenderInformation(
|
||||||
sender: String,
|
sender: String,
|
||||||
|
|
@ -700,7 +689,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue