Improve reordering folder experience.
This commit is contained in:
parent
9e955e94d9
commit
422acde111
6 changed files with 350 additions and 105 deletions
|
@ -1,23 +1,22 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
@ -31,6 +30,8 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
|
@ -42,7 +43,9 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.DropdownMenus
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
|
@ -52,6 +55,7 @@ import org.signal.core.ui.copied.androidx.compose.rememberDragDropState
|
|||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
|
@ -83,6 +87,16 @@ class ChatFoldersFragment : ComposeFragment() {
|
|||
Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show()
|
||||
viewModel.createFolder(requireContext(), folder)
|
||||
},
|
||||
onDeleteClicked = { folder ->
|
||||
viewModel.setCurrentFolder(folder)
|
||||
viewModel.showDeleteDialog(true)
|
||||
},
|
||||
onDeleteConfirmed = {
|
||||
viewModel.deleteFolder(context = requireContext(), forceRefresh = true)
|
||||
},
|
||||
onDeleteDismissed = {
|
||||
viewModel.showDeleteDialog(false)
|
||||
},
|
||||
onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) }
|
||||
)
|
||||
}
|
||||
|
@ -95,101 +109,125 @@ fun FoldersScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
onFolderClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onAdd: (ChatFolderRecord) -> Unit = {},
|
||||
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onDeleteConfirmed: () -> Unit = {},
|
||||
onDeleteDismissed: () -> Unit = {},
|
||||
onPositionUpdated: (Int, Int) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val isRtl = ViewUtil.isRtl(LocalContext.current)
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState =
|
||||
rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
rememberDragDropState(listState, includeHeader = true, includeFooter = true) { fromIndex, toIndex ->
|
||||
onPositionUpdated(fromIndex, toIndex)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp, start = 24.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.ChatFoldersFragment__create_a_folder),
|
||||
onClick = { onFolderClicked(ChatFolderRecord()) }
|
||||
if (state.showDeleteDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "",
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__delete_this_chat_folder),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
onConfirm = onDeleteConfirmed,
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onDismiss = onDeleteDismissed
|
||||
)
|
||||
}
|
||||
|
||||
val columnHeight = dimensionResource(id = R.dimen.chat_folder_row_height).value * state.folders.size
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.height(columnHeight.dp)
|
||||
.dragContainer(dragDropState),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_folder_24,
|
||||
title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name,
|
||||
subtitle = getFolderDescription(folder),
|
||||
onClick = if (!isAllChats) {
|
||||
{ onFolderClicked(folder) }
|
||||
} else null,
|
||||
elevation = elevation,
|
||||
showDragHandle = true
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
leftDpOffset = if (isRtl) 0.dp else screenWidth - 48.dp,
|
||||
rightDpOffset = if (isRtl) 48.dp else screenWidth
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
item {
|
||||
DraggableItem(dragDropState, 0) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp, start = 24.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.ChatFoldersFragment__create_a_folder),
|
||||
onClick = { onFolderClicked(ChatFolderRecord()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, 1 + index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_folder_24,
|
||||
title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name,
|
||||
subtitle = getFolderDescription(folder),
|
||||
onClick = if (!isAllChats) {
|
||||
{ onFolderClicked(folder) }
|
||||
} else null,
|
||||
onDelete = { onDeleteClicked(folder) },
|
||||
elevation = elevation,
|
||||
showDragHandle = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.suggestedFolders.forEach { chatFolder ->
|
||||
when (chatFolder.folderType) {
|
||||
ChatFolderRecord.FolderType.UNREAD -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__unread)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_chat_badge_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
item {
|
||||
DraggableItem(dragDropState, 1 + state.folders.size) {
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.INDIVIDUAL -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.GROUP -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__groups)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.ALL -> {
|
||||
throw IllegalStateException("All chats should not be suggested")
|
||||
}
|
||||
ChatFolderRecord.FolderType.CUSTOM -> {
|
||||
throw IllegalStateException("Custom folders should not be suggested")
|
||||
|
||||
state.suggestedFolders.forEach { chatFolder ->
|
||||
when (chatFolder.folderType) {
|
||||
ChatFolderRecord.FolderType.UNREAD -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__unread)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_chat_badge_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.INDIVIDUAL -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.GROUP -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__groups)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages),
|
||||
onAdd = { onAdd(chatFolder) }
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.ALL -> {
|
||||
error("All chats should not be suggested")
|
||||
}
|
||||
ChatFolderRecord.FolderType.CUSTOM -> {
|
||||
error("Custom folders should not be suggested")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -218,6 +256,7 @@ private fun getFolderDescription(folder: ChatFolderRecord): String {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FolderRow(
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -226,12 +265,26 @@ fun FolderRow(
|
|||
subtitle: String = "",
|
||||
onClick: (() -> Unit)? = null,
|
||||
onAdd: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
elevation: Dp = 0.dp,
|
||||
showDragHandle: Boolean = false
|
||||
) {
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (onClick != null) {
|
||||
modifier = if (onClick != null && onDelete != null) {
|
||||
modifier
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { menuController.show() }
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
|
||||
.shadow(elevation = elevation)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(start = 24.dp, end = 12.dp)
|
||||
} else if (onClick != null) {
|
||||
modifier
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
|
@ -284,6 +337,47 @@ fun FolderRow(
|
|||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Menu(controller = menuController, offsetX = 0.dp, offsetY = 4.dp) { menuController ->
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = {
|
||||
onClick!!()
|
||||
menuController.hide()
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_edit_24),
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.ChatFoldersFragment__edit_folder),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = {
|
||||
onDelete!!()
|
||||
menuController.hide()
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_trash_24),
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.CreateFoldersFragment__delete_folder),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -147,10 +147,13 @@ class ChatFoldersViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
fun deleteFolder() {
|
||||
fun deleteFolder(context: Context, forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder)
|
||||
|
||||
if (forceRefresh) {
|
||||
loadCurrentFolders(context)
|
||||
}
|
||||
internalState.update {
|
||||
it.copy(showDeleteDialog = false)
|
||||
}
|
||||
|
|
|
@ -129,7 +129,7 @@ class CreateFoldersFragment : ComposeFragment() {
|
|||
onToggleShowMuted = { viewModel.toggleShowMutedChats(it) },
|
||||
onDeleteClicked = { viewModel.showDeleteDialog(true) },
|
||||
onDeleteConfirmed = {
|
||||
viewModel.deleteFolder()
|
||||
viewModel.deleteFolder(requireContext())
|
||||
navController.popBackStack()
|
||||
},
|
||||
onDeleteDismissed = {
|
||||
|
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.copied.androidx.compose.material3.DropdownMenu
|
||||
|
@ -32,6 +33,8 @@ object DropdownMenus {
|
|||
fun Menu(
|
||||
controller: MenuController = remember { MenuController() },
|
||||
modifier: Modifier = Modifier,
|
||||
offsetX: Dp = dimensionResource(id = R.dimen.core_ui__gutter),
|
||||
offsetY: Dp = 0.dp,
|
||||
content: @Composable ColumnScope.(MenuController) -> Unit
|
||||
) {
|
||||
MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) {
|
||||
|
@ -39,8 +42,8 @@ object DropdownMenus {
|
|||
expanded = controller.isShown(),
|
||||
onDismissRequest = controller::hide,
|
||||
offset = DpOffset(
|
||||
x = dimensionResource(id = R.dimen.core_ui__gutter),
|
||||
y = 0.dp
|
||||
x = offsetX,
|
||||
y = offsetY
|
||||
),
|
||||
content = { content(controller) },
|
||||
modifier = modifier
|
||||
|
|
|
@ -3,7 +3,6 @@ package org.signal.core.ui.copied.androidx.compose
|
|||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
|
@ -23,6 +22,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
@ -33,13 +33,14 @@ import kotlinx.coroutines.launch
|
|||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
|
||||
*
|
||||
* Allows for dragging and dropping to reorder within lazy columns
|
||||
* Supports adding non-draggable headers and footers.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||
fun rememberDragDropState(lazyListState: LazyListState, includeHeader: Boolean, includeFooter: Boolean, onMove: (Int, Int) -> Unit): DragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state =
|
||||
remember(lazyListState) {
|
||||
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||
DragDropState(state = lazyListState, onMove = onMove, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
|
@ -54,6 +55,8 @@ class DragDropState
|
|||
internal constructor(
|
||||
private val state: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val includeHeader: Boolean,
|
||||
private val includeFooter: Boolean,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
|
@ -80,7 +83,11 @@ internal constructor(
|
|||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
|
||||
.firstOrNull { item ->
|
||||
offset.y.toInt() in item.offset..(item.offset + item.size) &&
|
||||
(!includeHeader || item.index != 0) &&
|
||||
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
|
||||
}
|
||||
?.also {
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset
|
||||
|
@ -106,6 +113,10 @@ internal constructor(
|
|||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
if ((includeHeader && draggingItemIndex == 0) ||
|
||||
(includeFooter && draggingItemIndex == (state.layoutInfo.totalItemsCount - 1))
|
||||
) return
|
||||
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
|
@ -116,19 +127,20 @@ internal constructor(
|
|||
val targetItem =
|
||||
state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
||||
draggingItem.index != item.index
|
||||
item.index != draggingItem.index &&
|
||||
(!includeHeader || item.index != 0) &&
|
||||
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
|
||||
}
|
||||
if (targetItem != null) {
|
||||
if (
|
||||
draggingItem.index == state.firstVisibleItemIndex ||
|
||||
targetItem.index == state.firstVisibleItemIndex
|
||||
) {
|
||||
state.requestScrollToItem(
|
||||
state.firstVisibleItemIndex,
|
||||
state.firstVisibleItemScrollOffset
|
||||
)
|
||||
|
||||
if (targetItem != null &&
|
||||
(!includeHeader || targetItem.index != 0) &&
|
||||
(!includeFooter || targetItem.index != (state.layoutInfo.totalItemsCount - 1))
|
||||
) {
|
||||
if (includeHeader) {
|
||||
onMove.invoke(draggingItem.index - 1, targetItem.index - 1)
|
||||
} else {
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
}
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll =
|
||||
|
@ -149,16 +161,18 @@ internal constructor(
|
|||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||
fun Modifier.dragContainer(dragDropState: DragDropState, leftDpOffset: Dp, rightDpOffset: Dp): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
detectDragGestures(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
onDragCancel = { dragDropState.onDragInterrupted() },
|
||||
leftDpOffset = leftDpOffset,
|
||||
rightDpOffset = rightDpOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
package org.signal.core.ui.copied.androidx.compose
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.isOutOfBounds
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastAll
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Modified version of detectDragGesturesAfterLongPress from [androidx.compose.foundation.gestures.DragGestureDetector]
|
||||
* that allows you to optionally offset the starting and ending position of the draggable area
|
||||
*/
|
||||
suspend fun PointerInputScope.detectDragGestures(
|
||||
onDragStart: (Offset) -> Unit = { },
|
||||
onDragEnd: () -> Unit = { },
|
||||
onDragCancel: () -> Unit = { },
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
|
||||
leftDpOffset: Dp = 0.dp,
|
||||
rightDpOffset: Dp
|
||||
) {
|
||||
awaitEachGesture {
|
||||
try {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val drag = awaitLongPressOrCancellation(down.id)
|
||||
if (drag != null && (drag.position.x > leftDpOffset.toPx()) && (drag.position.x < rightDpOffset.toPx())) {
|
||||
onDragStart.invoke(drag.position)
|
||||
|
||||
if (
|
||||
drag(drag.id) {
|
||||
onDrag(it, it.positionChange())
|
||||
it.consume()
|
||||
}
|
||||
) {
|
||||
// consume up if we quit drag gracefully with the up
|
||||
currentEvent.changes.fastForEach {
|
||||
if (it.changedToUp()) it.consume()
|
||||
}
|
||||
onDragEnd()
|
||||
} else {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
onDragCancel()
|
||||
throw c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a reduced long press timeout
|
||||
*/
|
||||
suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
|
||||
pointerId: PointerId
|
||||
): PointerInputChange? {
|
||||
if (currentEvent.isPointerUp(pointerId)) {
|
||||
return null // The pointer has already been lifted, so the long press is cancelled.
|
||||
}
|
||||
|
||||
val initialDown =
|
||||
currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null
|
||||
|
||||
var longPress: PointerInputChange? = null
|
||||
var currentDown = initialDown
|
||||
val longPressTimeout = (viewConfiguration.longPressTimeoutMillis / 100)
|
||||
return try {
|
||||
// wait for first tap up or long press
|
||||
withTimeout(longPressTimeout) {
|
||||
var finished = false
|
||||
while (!finished) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) {
|
||||
// All pointers are up
|
||||
finished = true
|
||||
}
|
||||
|
||||
if (
|
||||
event.changes.fastAny {
|
||||
it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
|
||||
}
|
||||
) {
|
||||
finished = true // Canceled
|
||||
}
|
||||
|
||||
// Check for cancel by position consumption. We can look on the Final pass of
|
||||
// the existing pointer event because it comes after the Main pass we checked
|
||||
// above.
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.fastAny { it.isConsumed }) {
|
||||
finished = true
|
||||
}
|
||||
if (event.isPointerUp(currentDown.id)) {
|
||||
val newPressed = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (newPressed != null) {
|
||||
currentDown = newPressed
|
||||
longPress = currentDown
|
||||
} else {
|
||||
// should technically never happen as we checked it above
|
||||
finished = true
|
||||
}
|
||||
// Pointer (id) stayed down.
|
||||
} else {
|
||||
longPress = event.changes.fastFirstOrNull { it.id == currentDown.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
longPress ?: initialDown
|
||||
}
|
||||
}
|
||||
|
||||
private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
|
||||
changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
|
Loading…
Add table
Reference in a new issue