77package com.nextcloud.talk.conversationlist.ui
88
99import androidx.compose.animation.core.Animatable
10+ import androidx.compose.animation.core.AnimationVector1D
1011import androidx.compose.animation.core.Spring
12+ import androidx.compose.animation.core.SpringSpec
1113import androidx.compose.animation.core.spring
1214import androidx.compose.foundation.background
1315import androidx.compose.foundation.clickable
@@ -40,19 +42,23 @@ import androidx.compose.runtime.LaunchedEffect
4042import androidx.compose.runtime.derivedStateOf
4143import androidx.compose.runtime.getValue
4244import androidx.compose.runtime.mutableIntStateOf
43- import androidx.compose.runtime.mutableStateOf
4445import androidx.compose.runtime.remember
4546import androidx.compose.runtime.rememberCoroutineScope
4647import androidx.compose.runtime.rememberUpdatedState
4748import androidx.compose.runtime.setValue
4849import androidx.compose.runtime.snapshotFlow
50+ import kotlinx.coroutines.CoroutineScope
4951import kotlinx.coroutines.flow.first
5052import kotlinx.coroutines.launch
5153import androidx.compose.ui.Alignment
5254import androidx.compose.ui.Modifier
5355import androidx.compose.ui.draw.clip
5456import androidx.compose.ui.graphics.Color
57+ import androidx.compose.ui.hapticfeedback.HapticFeedback
5558import androidx.compose.ui.hapticfeedback.HapticFeedbackType
59+ import androidx.compose.ui.input.pointer.AwaitPointerEventScope
60+ import androidx.compose.ui.input.pointer.PointerId
61+ import androidx.compose.ui.input.pointer.PointerInputScope
5662import androidx.compose.ui.input.pointer.pointerInput
5763import androidx.compose.ui.layout.onSizeChanged
5864import androidx.compose.ui.platform.LocalContext
@@ -109,7 +115,8 @@ fun ConversationList(
109115 listState : LazyListState = rememberLazyListState(),
110116 /* * Extra bottom padding added as LazyColumn contentPadding so the last item is reachable above the nav bar. */
111117 contentBottomPadding : Dp = 0.dp,
112- onSwipeConversation : (ConversationOpsAction , ConversationModel ) -> Unit = { _, _ -> }
118+ onSwipeConversation : (ConversationOpsAction , ConversationModel ) -> Unit = { _, _ -> },
119+ isOnline : Boolean = true
113120) {
114121 var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) }
115122 var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) }
@@ -185,7 +192,8 @@ fun ConversationList(
185192 is ConversationListEntry .ConversationEntry ->
186193 SwipeableConversationItem (
187194 model = entry.model,
188- onSwipe = { action -> onSwipeConversation(action, entry.model) }
195+ onSwipe = { action -> onSwipeConversation(action, entry.model) },
196+ enabled = isOnline
189197 ) {
190198 ConversationListItem (
191199 model = entry.model,
@@ -233,11 +241,11 @@ private const val NON_DESTRUCTIVE_SWIPE_THRESHOLD = 0.20f
233241
234242private const val NON_DESTRUCTIVE_SWIPE_END_LIMIT = 0.3f
235243
236- @Suppress(" LongMethod" )
237244@Composable
238245private fun SwipeableConversationItem (
239246 model : ConversationModel ,
240247 onSwipe : (ConversationOpsAction ) -> Unit ,
248+ enabled : Boolean = true,
241249 content : @Composable () -> Unit
242250) {
243251 val currentModel by rememberUpdatedState(model)
@@ -251,8 +259,6 @@ private fun SwipeableConversationItem(
251259 spring<Float >(dampingRatio = Spring .DampingRatioMediumBouncy , stiffness = Spring .StiffnessMedium )
252260 }
253261 var itemWidth by remember { mutableIntStateOf(0 ) }
254- var hapticFiredRight by remember { mutableStateOf(false ) }
255- var hapticFiredLeft by remember { mutableStateOf(false ) }
256262
257263 val swipeDirection by remember {
258264 derivedStateOf {
@@ -280,63 +286,18 @@ private fun SwipeableConversationItem(
280286 modifier = Modifier
281287 .fillMaxWidth()
282288 .onSizeChanged { size -> itemWidth = size.width }
283- .pointerInput(Unit ) {
284- awaitEachGesture {
285- val startToEndThreshold = itemWidth * NON_DESTRUCTIVE_SWIPE_THRESHOLD
286- val startToEndLimit = itemWidth * NON_DESTRUCTIVE_SWIPE_END_LIMIT
287- val endToStartThreshold = - itemWidth * DESTRUCTIVE_SWIPE_THRESHOLD
288- val endToStartLimit = - itemWidth.toFloat()
289-
290- val down = awaitFirstDown(requireUnconsumed = false )
291- var horizontalStarted = false
292- val dragStart = awaitHorizontalTouchSlopOrCancellation(down.id) { change, _ ->
293- val dx = abs(change.position.x - change.previousPosition.x)
294- val dy = abs(change.position.y - change.previousPosition.y)
295- if (dx > dy) {
296- change.consume()
297- horizontalStarted = true
298- }
299- }
300- if (dragStart != null && horizontalStarted) {
301- hapticFiredRight = false
302- hapticFiredLeft = false
303- coroutineScope.launch { popScale.snapTo(1f ) }
304- horizontalDrag(dragStart.id) { change ->
305- val delta = change.position.x - change.previousPosition.x
306- val newOffset = (offsetX.value + delta).coerceIn(endToStartLimit, startToEndLimit)
307- coroutineScope.launch { offsetX.snapTo(newOffset) }
308- if (! hapticFiredRight && startToEndThreshold > 0 && newOffset >= startToEndThreshold) {
309- haptic.performHapticFeedback(HapticFeedbackType .LongPress )
310- hapticFiredRight = true
311- coroutineScope.launch {
312- popScale.snapTo(POP_SCALE_PEAK )
313- popScale.animateTo(1f , popAnimationSpec)
314- }
315- }
316- if (! hapticFiredLeft && endToStartThreshold < 0 && newOffset <= endToStartThreshold) {
317- haptic.performHapticFeedback(HapticFeedbackType .LongPress )
318- hapticFiredLeft = true
319- coroutineScope.launch {
320- popScale.snapTo(POP_SCALE_PEAK )
321- popScale.animateTo(1f , popAnimationSpec)
322- }
323- }
324- change.consume()
325- }
326- val finalOffset = offsetX.value
327- if (hapticFiredRight && finalOffset >= startToEndThreshold) {
328- val action = if (currentModel.unreadMessages > 0 ) {
329- ConversationOpsAction .MarkAsRead
330- } else {
331- ConversationOpsAction .MarkAsUnread
332- }
333- currentOnSwipe(action)
334- } else if (hapticFiredLeft && finalOffset <= endToStartThreshold) {
335- currentOnSwipe(ConversationOpsAction .Leave )
336- }
337- coroutineScope.launch { offsetX.animateTo(0f , spring()) }
338- }
339- }
289+ .pointerInput(enabled) {
290+ if (! enabled) return @pointerInput
291+ awaitSwipeGesture(
292+ getItemWidth = { itemWidth },
293+ coroutineScope = coroutineScope,
294+ haptic = haptic,
295+ offsetX = offsetX,
296+ popScale = popScale,
297+ popAnimationSpec = popAnimationSpec,
298+ getModel = { currentModel },
299+ onSwipe = { currentOnSwipe(it) }
300+ )
340301 }
341302 ) {
342303 SwipeBackground (
@@ -357,6 +318,95 @@ private fun SwipeableConversationItem(
357318 }
358319}
359320
321+ private suspend fun PointerInputScope.awaitSwipeGesture (
322+ getItemWidth : () -> Int ,
323+ coroutineScope : CoroutineScope ,
324+ haptic : HapticFeedback ,
325+ offsetX : Animatable <Float , AnimationVector1D >,
326+ popScale : Animatable <Float , AnimationVector1D >,
327+ popAnimationSpec : SpringSpec <Float >,
328+ getModel : () -> ConversationModel ,
329+ onSwipe : (ConversationOpsAction ) -> Unit
330+ ) {
331+ awaitEachGesture {
332+ val width = getItemWidth()
333+ val startToEndThreshold = width * NON_DESTRUCTIVE_SWIPE_THRESHOLD
334+ val startToEndLimit = width * NON_DESTRUCTIVE_SWIPE_END_LIMIT
335+ val endToStartThreshold = - width * DESTRUCTIVE_SWIPE_THRESHOLD
336+ val endToStartLimit = - width.toFloat()
337+
338+ val down = awaitFirstDown(requireUnconsumed = false )
339+ var horizontalStarted = false
340+ val dragStart = awaitHorizontalTouchSlopOrCancellation(down.id) { change, _ ->
341+ val dx = abs(change.position.x - change.previousPosition.x)
342+ val dy = abs(change.position.y - change.previousPosition.y)
343+ if (dx > dy) {
344+ change.consume()
345+ horizontalStarted = true
346+ }
347+ }
348+ if (dragStart == null || ! horizontalStarted) return @awaitEachGesture
349+ handleConfirmedSwipeDrag(
350+ dragId = dragStart.id,
351+ coroutineScope = coroutineScope,
352+ haptic = haptic,
353+ offsetX = offsetX,
354+ popScale = popScale,
355+ popAnimationSpec = popAnimationSpec,
356+ startToEndThreshold = startToEndThreshold,
357+ startToEndLimit = startToEndLimit,
358+ endToStartThreshold = endToStartThreshold,
359+ endToStartLimit = endToStartLimit,
360+ getModel = getModel,
361+ onSwipe = onSwipe
362+ )
363+ }
364+ }
365+
366+ private suspend fun AwaitPointerEventScope.handleConfirmedSwipeDrag (
367+ dragId : PointerId ,
368+ coroutineScope : CoroutineScope ,
369+ haptic : HapticFeedback ,
370+ offsetX : Animatable <Float , AnimationVector1D >,
371+ popScale : Animatable <Float , AnimationVector1D >,
372+ popAnimationSpec : SpringSpec <Float >,
373+ startToEndThreshold : Float ,
374+ startToEndLimit : Float ,
375+ endToStartThreshold : Float ,
376+ endToStartLimit : Float ,
377+ getModel : () -> ConversationModel ,
378+ onSwipe : (ConversationOpsAction ) -> Unit
379+ ) {
380+ var hapticFiredRight = false
381+ var hapticFiredLeft = false
382+ coroutineScope.launch { popScale.snapTo(1f ) }
383+ horizontalDrag(dragId) { change ->
384+ val delta = change.position.x - change.previousPosition.x
385+ val newOffset = (offsetX.value + delta).coerceIn(endToStartLimit, startToEndLimit)
386+ coroutineScope.launch { offsetX.snapTo(newOffset) }
387+ if (! hapticFiredRight && startToEndThreshold > 0 && newOffset >= startToEndThreshold) {
388+ haptic.performHapticFeedback(HapticFeedbackType .LongPress )
389+ hapticFiredRight = true
390+ coroutineScope.launch { popScale.snapTo(POP_SCALE_PEAK ); popScale.animateTo(1f , popAnimationSpec) }
391+ }
392+ if (! hapticFiredLeft && endToStartThreshold < 0 && newOffset <= endToStartThreshold) {
393+ haptic.performHapticFeedback(HapticFeedbackType .LongPress )
394+ hapticFiredLeft = true
395+ coroutineScope.launch { popScale.snapTo(POP_SCALE_PEAK ); popScale.animateTo(1f , popAnimationSpec) }
396+ }
397+ change.consume()
398+ }
399+ val finalOffset = offsetX.value
400+ when {
401+ hapticFiredRight && finalOffset >= startToEndThreshold -> onSwipe(resolveReadUnreadAction(getModel()))
402+ hapticFiredLeft && finalOffset <= endToStartThreshold -> onSwipe(ConversationOpsAction .Leave )
403+ }
404+ coroutineScope.launch { offsetX.animateTo(0f , spring()) }
405+ }
406+
407+ private fun resolveReadUnreadAction (model : ConversationModel ): ConversationOpsAction =
408+ if (model.unreadMessages > 0 ) ConversationOpsAction .MarkAsRead else ConversationOpsAction .MarkAsUnread
409+
360410@Composable
361411private fun SwipeBackground (
362412 modifier : Modifier = Modifier ,
0 commit comments