Skip to content

Commit 0fbf799

Browse files
authored
Merge pull request #826 from vector-im/feature/bma/swipeAction
Improve swipe to reply rendering
2 parents 0e47fa8 + c3ddc62 commit 0fbf799

File tree

18 files changed

+205
-93
lines changed

18 files changed

+205
-93
lines changed

.idea/dictionaries/shared.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ koverMerged {
247247
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
248248
excludes += "io.element.android.features.location.impl.map.MapState"
249249
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
250+
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
250251
}
251252
bound {
252253
minValue = 90

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

Lines changed: 101 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -14,47 +14,47 @@
1414
* limitations under the License.
1515
*/
1616

17-
@file:OptIn(ExperimentalMaterial3Api::class)
18-
1917
package io.element.android.features.messages.impl.timeline.components
2018

2119
import androidx.compose.foundation.Canvas
2220
import androidx.compose.foundation.background
2321
import androidx.compose.foundation.clickable
22+
import androidx.compose.foundation.gestures.Orientation
23+
import androidx.compose.foundation.gestures.draggable
2424
import androidx.compose.foundation.interaction.MutableInteractionSource
2525
import androidx.compose.foundation.layout.Arrangement
2626
import androidx.compose.foundation.layout.Box
2727
import androidx.compose.foundation.layout.Column
2828
import androidx.compose.foundation.layout.PaddingValues
2929
import androidx.compose.foundation.layout.Row
3030
import androidx.compose.foundation.layout.Spacer
31+
import androidx.compose.foundation.layout.absoluteOffset
3132
import androidx.compose.foundation.layout.fillMaxWidth
3233
import androidx.compose.foundation.layout.height
3334
import androidx.compose.foundation.layout.padding
3435
import androidx.compose.foundation.layout.size
3536
import androidx.compose.foundation.layout.width
3637
import androidx.compose.foundation.layout.wrapContentHeight
3738
import androidx.compose.foundation.shape.RoundedCornerShape
38-
import androidx.compose.material3.DismissDirection
39-
import androidx.compose.material3.DismissState
40-
import androidx.compose.material3.DismissValue
41-
import androidx.compose.material3.ExperimentalMaterial3Api
4239
import androidx.compose.material3.MaterialTheme
43-
import androidx.compose.material3.SwipeToDismiss
44-
import androidx.compose.material3.rememberDismissState
4540
import androidx.compose.runtime.Composable
41+
import androidx.compose.runtime.CompositionLocalProvider
4642
import androidx.compose.runtime.remember
43+
import androidx.compose.runtime.rememberCoroutineScope
4744
import androidx.compose.ui.Alignment
4845
import androidx.compose.ui.Modifier
4946
import androidx.compose.ui.draw.clip
5047
import androidx.compose.ui.draw.clipToBounds
5148
import androidx.compose.ui.geometry.Offset
49+
import androidx.compose.ui.platform.LocalViewConfiguration
50+
import androidx.compose.ui.platform.ViewConfiguration
5251
import androidx.compose.ui.res.stringResource
5352
import androidx.compose.ui.text.style.TextAlign
5453
import androidx.compose.ui.text.style.TextOverflow
5554
import androidx.compose.ui.tooling.preview.Preview
5655
import androidx.compose.ui.tooling.preview.PreviewParameter
5756
import androidx.compose.ui.unit.Dp
57+
import androidx.compose.ui.unit.IntOffset
5858
import androidx.compose.ui.unit.dp
5959
import androidx.compose.ui.unit.sp
6060
import androidx.compose.ui.zIndex
@@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
6666
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
6767
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
6868
import io.element.android.features.messages.impl.timeline.model.TimelineItem
69+
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
6970
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
7071
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
7172
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
7879
import io.element.android.libraries.designsystem.components.avatar.AvatarData
7980
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
8081
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
82+
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
83+
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
84+
import io.element.android.libraries.designsystem.text.toPx
8185
import io.element.android.libraries.designsystem.theme.components.Text
8286
import io.element.android.libraries.matrix.api.core.EventId
8387
import io.element.android.libraries.matrix.api.core.UserId
@@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
9397
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
9498
import io.element.android.libraries.theme.ElementTheme
9599
import io.element.android.libraries.ui.strings.CommonStrings
100+
import kotlinx.coroutines.launch
96101
import org.jsoup.Jsoup
102+
import kotlin.math.abs
103+
import kotlin.math.roundToInt
97104

98105
@Composable
99106
fun TimelineItemEventRow(
@@ -110,6 +117,7 @@ fun TimelineItemEventRow(
110117
onSwipeToReply: () -> Unit,
111118
modifier: Modifier = Modifier
112119
) {
120+
val coroutineScope = rememberCoroutineScope()
113121
val interactionSource = remember { MutableInteractionSource() }
114122

115123
fun onUserDataClicked() {
@@ -121,56 +129,88 @@ fun TimelineItemEventRow(
121129
inReplyToClick(inReplyToEventId)
122130
}
123131

124-
if (canReply) {
125-
val dismissState = rememberDismissState(
126-
confirmValueChange = {
127-
if (it == DismissValue.DismissedToEnd) {
128-
onSwipeToReply()
132+
Column(modifier = modifier.fillMaxWidth()) {
133+
if (event.groupPosition.isNew()) {
134+
Spacer(modifier = Modifier.height(16.dp))
135+
} else {
136+
Spacer(modifier = Modifier.height(2.dp))
137+
}
138+
if (canReply) {
139+
val state: SwipeableActionsState = rememberSwipeableActionsState()
140+
val offset = state.offset.value
141+
val swipeThresholdPx = 40.dp.toPx()
142+
val thresholdCrossed = abs(offset) > swipeThresholdPx
143+
SwipeSensitivity(3f) {
144+
Box(Modifier.fillMaxWidth()) {
145+
Row(modifier = Modifier.matchParentSize()) {
146+
ReplySwipeIndicator({ offset / 120 })
147+
}
148+
TimelineItemEventRowContent(
149+
modifier = Modifier
150+
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
151+
.draggable(
152+
orientation = Orientation.Horizontal,
153+
enabled = !state.isResettingOnRelease,
154+
onDragStopped = {
155+
coroutineScope.launch {
156+
if (thresholdCrossed) {
157+
onSwipeToReply()
158+
}
159+
state.resetOffset()
160+
}
161+
},
162+
state = state.draggableState,
163+
),
164+
event = event,
165+
isHighlighted = isHighlighted,
166+
interactionSource = interactionSource,
167+
onClick = onClick,
168+
onLongClick = onLongClick,
169+
onTimestampClicked = onTimestampClicked,
170+
inReplyToClicked = ::inReplyToClicked,
171+
onUserDataClicked = ::onUserDataClicked,
172+
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
173+
onMoreReactionsClicked = { onMoreReactionsClick(event) },
174+
)
129175
}
130-
// Do not dismiss the message, return false!
131-
false
132-
}
133-
)
134-
SwipeToDismiss(
135-
state = dismissState,
136-
background = {
137-
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
138-
},
139-
directions = setOf(DismissDirection.StartToEnd),
140-
dismissContent = {
141-
TimelineItemEventRowContent(
142-
event = event,
143-
isHighlighted = isHighlighted,
144-
interactionSource = interactionSource,
145-
onClick = onClick,
146-
onLongClick = onLongClick,
147-
onTimestampClicked = onTimestampClicked,
148-
inReplyToClicked = ::inReplyToClicked,
149-
onUserDataClicked = ::onUserDataClicked,
150-
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
151-
onMoreReactionsClicked = { onMoreReactionsClick(event) },
152-
)
153176
}
154-
)
155-
} else {
156-
TimelineItemEventRowContent(
157-
event = event,
158-
isHighlighted = isHighlighted,
159-
interactionSource = interactionSource,
160-
onClick = onClick,
161-
onLongClick = onLongClick,
162-
onTimestampClicked = onTimestampClicked,
163-
inReplyToClicked = ::inReplyToClicked,
164-
onUserDataClicked = ::onUserDataClicked,
165-
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
166-
onMoreReactionsClicked = { onMoreReactionsClick(event) },
167-
)
177+
} else {
178+
TimelineItemEventRowContent(
179+
event = event,
180+
isHighlighted = isHighlighted,
181+
interactionSource = interactionSource,
182+
onClick = onClick,
183+
onLongClick = onLongClick,
184+
onTimestampClicked = onTimestampClicked,
185+
inReplyToClicked = ::inReplyToClicked,
186+
onUserDataClicked = ::onUserDataClicked,
187+
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
188+
onMoreReactionsClicked = { onMoreReactionsClick(event) },
189+
)
190+
}
168191
}
169-
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
170-
if (event.groupPosition.isNew()) {
171-
Spacer(modifier = modifier.height(16.dp))
172-
} else {
173-
Spacer(modifier = modifier.height(2.dp))
192+
}
193+
194+
/**
195+
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
196+
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
197+
* @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will
198+
* have to drag to start the drag.
199+
* @param content the content to display.
200+
*/
201+
@Composable
202+
fun SwipeSensitivity(
203+
sensitivityFactor: Float,
204+
content: @Composable () -> Unit,
205+
) {
206+
val current = LocalViewConfiguration.current
207+
CompositionLocalProvider(
208+
LocalViewConfiguration provides object : ViewConfiguration by current {
209+
override val touchSlop: Float
210+
get() = current.touchSlop * sensitivityFactor
211+
}
212+
) {
213+
content()
174214
}
175215
}
176216

@@ -266,14 +306,6 @@ private fun TimelineItemEventRowContent(
266306
}
267307
}
268308

269-
private fun DismissState.toSwipeProgress(): Float {
270-
return when (targetValue) {
271-
DismissValue.Default -> 0f
272-
DismissValue.DismissedToEnd -> progress * 3
273-
DismissValue.DismissedToStart -> progress * 3
274-
}
275-
}
276-
277309
@Composable
278310
private fun MessageSenderInformation(
279311
sender: String,
@@ -544,6 +576,7 @@ private fun ContentToPreview() {
544576
body = "A long text which will be displayed on several lines and" +
545577
" hopefully can be manually adjusted to test different behaviors."
546578
),
579+
groupPosition = TimelineItemGroupPosition.First,
547580
),
548581
isHighlighted = false,
549582
canReply = true,
@@ -562,6 +595,7 @@ private fun ContentToPreview() {
562595
content = aTimelineItemImageContent().copy(
563596
aspectRatio = 5f
564597
),
598+
groupPosition = TimelineItemGroupPosition.Last,
565599
),
566600
isHighlighted = false,
567601
canReply = true,
@@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
606640
body = "A long text which will be displayed on several lines and" +
607641
" hopefully can be manually adjusted to test different behaviors."
608642
),
609-
inReplyTo = aInReplyToReady(replyContent)
643+
inReplyTo = aInReplyToReady(replyContent),
644+
groupPosition = TimelineItemGroupPosition.First,
610645
),
611646
isHighlighted = false,
612647
canReply = true,
@@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
625660
content = aTimelineItemImageContent().copy(
626661
aspectRatio = 5f
627662
),
628-
inReplyTo = aInReplyToReady(replyContent)
663+
inReplyTo = aInReplyToReady(replyContent),
664+
groupPosition = TimelineItemGroupPosition.Last,
629665
),
630666
isHighlighted = false,
631667
canReply = true,
@@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
699735
}
700736
}
701737

702-
703738
@Preview
704739
@Composable
705740
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.libraries.designsystem.swipe
18+
19+
import androidx.compose.animation.core.Animatable
20+
import androidx.compose.animation.core.tween
21+
import androidx.compose.foundation.MutatePriority
22+
import androidx.compose.foundation.gestures.DraggableState
23+
import androidx.compose.runtime.Composable
24+
import androidx.compose.runtime.Stable
25+
import androidx.compose.runtime.State
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.setValue
30+
31+
/**
32+
* Inspired from https://github.com/bmarty/swipe/blob/trunk/swipe/src/main/kotlin/me/saket/swipe/SwipeableActionsState.kt
33+
*/
34+
@Composable
35+
fun rememberSwipeableActionsState(): SwipeableActionsState {
36+
return remember { SwipeableActionsState() }
37+
}
38+
39+
@Stable
40+
class SwipeableActionsState {
41+
/**
42+
* The current position (in pixels) of the content.
43+
*/
44+
val offset: State<Float> get() = offsetState
45+
private var offsetState = mutableStateOf(0f)
46+
47+
/**
48+
* Whether the content is currently animating to reset its offset after it was swiped.
49+
*/
50+
var isResettingOnRelease: Boolean by mutableStateOf(false)
51+
private set
52+
53+
val draggableState = DraggableState { delta ->
54+
val targetOffset = offsetState.value + delta
55+
val isAllowed = isResettingOnRelease || targetOffset > 0f
56+
57+
offsetState.value += if (isAllowed) delta else 0f
58+
}
59+
60+
suspend fun resetOffset() {
61+
draggableState.drag(MutatePriority.PreventUserInput) {
62+
isResettingOnRelease = true
63+
try {
64+
Animatable(offsetState.value).animateTo(
65+
targetValue = 0f,
66+
animationSpec = tween(durationMillis = 300),
67+
) {
68+
dragBy(value - offsetState.value)
69+
}
70+
} finally {
71+
isResettingOnRelease = false
72+
}
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)