1414 * limitations under the License.
1515 */
1616
17- @file:OptIn(ExperimentalMaterial3Api ::class )
18-
1917package io.element.android.features.messages.impl.timeline.components
2018
2119import androidx.compose.foundation.Canvas
2220import androidx.compose.foundation.background
2321import androidx.compose.foundation.clickable
22+ import androidx.compose.foundation.gestures.Orientation
23+ import androidx.compose.foundation.gestures.draggable
2424import androidx.compose.foundation.interaction.MutableInteractionSource
2525import androidx.compose.foundation.layout.Arrangement
2626import androidx.compose.foundation.layout.Box
2727import androidx.compose.foundation.layout.Column
2828import androidx.compose.foundation.layout.PaddingValues
2929import androidx.compose.foundation.layout.Row
3030import androidx.compose.foundation.layout.Spacer
31+ import androidx.compose.foundation.layout.absoluteOffset
3132import androidx.compose.foundation.layout.fillMaxWidth
3233import androidx.compose.foundation.layout.height
3334import androidx.compose.foundation.layout.padding
3435import androidx.compose.foundation.layout.size
3536import androidx.compose.foundation.layout.width
3637import androidx.compose.foundation.layout.wrapContentHeight
3738import 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
4239import androidx.compose.material3.MaterialTheme
43- import androidx.compose.material3.SwipeToDismiss
44- import androidx.compose.material3.rememberDismissState
4540import androidx.compose.runtime.Composable
41+ import androidx.compose.runtime.CompositionLocalProvider
4642import androidx.compose.runtime.remember
43+ import androidx.compose.runtime.rememberCoroutineScope
4744import androidx.compose.ui.Alignment
4845import androidx.compose.ui.Modifier
4946import androidx.compose.ui.draw.clip
5047import androidx.compose.ui.draw.clipToBounds
5148import androidx.compose.ui.geometry.Offset
49+ import androidx.compose.ui.platform.LocalViewConfiguration
50+ import androidx.compose.ui.platform.ViewConfiguration
5251import androidx.compose.ui.res.stringResource
5352import androidx.compose.ui.text.style.TextAlign
5453import androidx.compose.ui.text.style.TextOverflow
5554import androidx.compose.ui.tooling.preview.Preview
5655import androidx.compose.ui.tooling.preview.PreviewParameter
5756import androidx.compose.ui.unit.Dp
57+ import androidx.compose.ui.unit.IntOffset
5858import androidx.compose.ui.unit.dp
5959import androidx.compose.ui.unit.sp
6060import androidx.compose.ui.zIndex
@@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
6666import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
6767import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
6868import io.element.android.features.messages.impl.timeline.model.TimelineItem
69+ import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
6970import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
7071import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
7172import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
7879import io.element.android.libraries.designsystem.components.avatar.AvatarData
7980import io.element.android.libraries.designsystem.preview.ElementPreviewDark
8081import 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
8185import io.element.android.libraries.designsystem.theme.components.Text
8286import io.element.android.libraries.matrix.api.core.EventId
8387import io.element.android.libraries.matrix.api.core.UserId
@@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
9397import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
9498import io.element.android.libraries.theme.ElementTheme
9599import io.element.android.libraries.ui.strings.CommonStrings
100+ import kotlinx.coroutines.launch
96101import org.jsoup.Jsoup
102+ import kotlin.math.abs
103+ import kotlin.math.roundToInt
97104
98105@Composable
99106fun 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
278310private 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
705740internal fun TimelineItemEventRowWithManyReactionsLightPreview () =
0 commit comments