From 51e3b07346b4dee1f02640c4271365b5b02e8bc9 Mon Sep 17 00:00:00 2001 From: AllenTom Date: Mon, 16 Sep 2024 01:29:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 20 +- .../riderpro/ui/composables/DragAndDrop.kt | 278 ++++++++++++++++++ .../com/aiosman/riderpro/ui/post/NewPost.kt | 227 +++++++++++--- .../riderpro/ui/post/NewPostViewModel.kt | 1 + 4 files changed, 484 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/DragAndDrop.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b62647..a76750c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + - + + - + @@ -49,6 +52,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/DragAndDrop.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/DragAndDrop.kt new file mode 100644 index 0000000..9551ccf --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/DragAndDrop.kt @@ -0,0 +1,278 @@ +package com.aiosman.riderpro.ui.composables + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DraggableGrid( + items: List, + onMove: (Int, Int) -> Unit, + onDragModeStart: () -> Unit, // New parameter for drag start + onDragModeEnd: () -> Unit, // New parameter for drag end, + additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items + lockedIndices: List = emptyList(), // New parameter for locked indices + content: @Composable (T, Boolean) -> Unit, +) { + + val gridState = rememberLazyGridState() + val dragDropState = + rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.dragContainer(dragDropState), + state = gridState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + + ) { + itemsIndexed(items, key = { _, item -> item }) { index, item -> + DraggableItem(dragDropState, index) { isDragging -> + content(item, isDragging) + } + } + additionalItems.forEach { additionalItem -> + item { + additionalItem() + } + } + + } +} + +fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@ExperimentalFoundationApi +@Composable +fun LazyGridItemScope.DraggableItem( + dragDropState: GridDragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable (isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = dragDropState.draggingItemOffset.x + translationY = dragDropState.draggingItemOffset.y + } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = dragDropState.previousItemOffset.value.x + translationY = dragDropState.previousItemOffset.value.y + } + } else { + Modifier.animateItemPlacement() + } + Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) { + content(dragging) + } +} + + +@Composable +fun rememberGridDragDropState( + gridState: LazyGridState, + onMove: (Int, Int) -> Unit, + onDragModeStart: () -> Unit, + onDragModeEnd: () -> Unit, + lockedIndices: List // New parameter for locked indices +): GridDragDropState { + val scope = rememberCoroutineScope() + val state = remember(gridState) { + GridDragDropState( + state = gridState, + onMove = onMove, + scope = scope, + onDragModeStart = onDragModeStart, + onDragModeEnd = onDragModeEnd, + lockedIndices = lockedIndices // Pass the locked indices + ) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} + +class GridDragDropState internal constructor( + private val state: LazyGridState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit, + private val onDragModeStart: () -> Unit, + private val onDragModeEnd: () -> Unit, + private val lockedIndices: List // New parameter for locked indices +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero) + private var draggingItemInitialOffset by mutableStateOf(Offset.Zero) + internal val draggingItemOffset: Offset + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset() + } ?: Offset.Zero + + private val draggingItemLayoutInfo: LazyGridItemInfo? + get() = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offset.x.toInt() in item.offset.x..item.offsetEnd.x && + offset.y.toInt() in item.offset.y..item.offsetEnd.y + }?.also { + if (it.index !in lockedIndices) { // Check if the item is not locked + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset.toOffset() + onDragModeStart() // Notify drag start + } + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + Offset.Zero, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Offset.VisibilityThreshold + ) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = Offset.Zero + draggingItemIndex = null + draggingItemInitialOffset = Offset.Zero + onDragModeEnd() // Notify drag end + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset.toOffset() + draggingItemOffset + val endOffset = startOffset + draggingItem.size.toSize() + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x && + middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y && + draggingItem.index != item.index && + item.index !in lockedIndices // Check if the target item is not locked + } + if (targetItem != null) { + val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { + draggingItem.index + } else if (draggingItem.index == state.firstVisibleItemIndex) { + targetItem.index + } else { + null + } + if (scrollToIndex != null) { + scope.launch { + state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) + onMove.invoke(draggingItem.index, targetItem.index) + } + } else { + onMove.invoke(draggingItem.index, targetItem.index) + } + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta.y > 0 -> + (endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta.y < 0 -> + (startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyGridItemInfo.offsetEnd: IntOffset + get() = this.offset + this.size +} + +operator fun IntOffset.plus(size: IntSize): IntOffset { + return IntOffset(x + size.width, y + size.height) +} + +operator fun Offset.plus(size: Size): Offset { + return Offset(x + size.width, y + size.height) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt index d316790..4a5ddaf 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt @@ -1,12 +1,12 @@ package com.aiosman.riderpro.ui.post -import android.app.Activity -import android.content.Intent -import android.util.Log +import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -14,10 +14,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,9 +25,12 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -36,12 +39,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale @@ -50,17 +56,24 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.lifecycle.viewModelScope -import coil.compose.AsyncImage import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.R import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.DraggableGrid import com.aiosman.riderpro.ui.composables.RelPostCard import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.google.accompanist.systemuicontroller.rememberSystemUiController import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.NoDragCancelledAnimation +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyGridState +import org.burnoutcrew.reorderable.reorderable +import java.io.File @Preview @@ -76,7 +89,9 @@ fun NewPostScreen() { } StatusBarMaskLayout( darkIcons = true, - modifier = Modifier.fillMaxSize().background(Color.White) + modifier = Modifier + .fillMaxSize() + .background(Color.White) ) { Column( modifier = Modifier @@ -94,14 +109,19 @@ fun NewPostScreen() { NewPostTextField("Share your adventure…", NewPostViewModel.textContent) { NewPostViewModel.textContent = it } - Column ( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) ) { model.relMoment?.let { Text("Share with") Spacer(modifier = Modifier.height(8.dp)) Box( - modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(color = Color(0xFFEEEEEE)).padding(24.dp) + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(color = Color(0xFFEEEEEE)) + .padding(24.dp) ) { RelPostCard( momentEntity = it, @@ -188,6 +208,7 @@ fun NewPostTopBar(onSendClick: () -> Unit = {}) { } } } + @Composable fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Unit) { @@ -211,7 +232,48 @@ fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Uni } } -@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun VerticalGrid( + modifier: Modifier = Modifier, +) { + var data by remember { mutableStateOf((0 until 20).toList()) } + val state = rememberReorderableLazyGridState(onMove = { from, to -> + data = data.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + state = state.gridState, + contentPadding = PaddingValues(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.reorderable(state) + ) { + items(data.size, { it }) { item -> + val imageItem = data[item] + ReorderableItem(state, item) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 8.dp else 0.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .detectReorderAfterLongPress(state) + .shadow(elevation.value) + .aspectRatio(1f) + .background(MaterialTheme.colors.primary) + ) { + Text( + text = imageItem.toString(), + color = Color.White + ) + } + } + } + } +} + + @Composable fun AddImageGrid() { val navController = LocalNavController.current @@ -226,44 +288,104 @@ fun AddImageGrid() { } } + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success) { + model.imageUriList += model.currentPhotoUri.toString() + } + } + val state = + rememberReorderableLazyGridState( + onMove = { from, to -> + model.imageUriList = model.imageUriList.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) + val stroke = Stroke( width = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) ) - Box( - modifier = Modifier.fillMaxWidth() - ) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - model.imageUriList.forEach { - CustomAsyncImage( - context, - it, - contentDescription = "Image", - modifier = Modifier - .size(110.dp) + DraggableGrid( + items = NewPostViewModel.imageUriList, + onMove = { from, to -> + NewPostViewModel.imageUriList = NewPostViewModel.imageUriList.toMutableList().apply { + add(to, removeAt(from)) + } + }, + lockedIndices = listOf( - .drawBehind { - drawRoundRect(color = Color(0xFF999999), style = stroke) - }.noRippleClickable { - navController.navigate(NavigationRoute.NewPostImageGrid.route) - }, - contentScale = ContentScale.Crop + ), + onDragModeEnd = {}, + onDragModeStart = {}, + additionalItems = listOf( + + ), + + ) { item, isDrag -> + Box( + modifier = Modifier + ) { + CustomAsyncImage( + LocalContext.current, + item, + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .noRippleClickable { + navController.navigate(NavigationRoute.NewPostImageGrid.route) + }, + contentScale = ContentScale.Crop + ) + if (isDrag) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66000000)) ) } + } + } + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { +// items(model.imageUriList.size) { index -> +// val uri = model.imageUriList[index] +// Box( +// modifier = Modifier +// .drawBehind { +// drawRoundRect(color = Color(0xFF999999), style = stroke) +// } +// ) { +// CustomAsyncImage( +// context, +// uri, +// contentDescription = "Image", +// modifier = Modifier +// .fillMaxWidth().aspectRatio(1f) +// .noRippleClickable { +// navController.navigate(NavigationRoute.NewPostImageGrid.route) +// }, +// contentScale = ContentScale.Crop +// ) +// } +// } + item { Box( modifier = Modifier - .size(110.dp) - + .fillMaxWidth() + .aspectRatio(1f) .drawBehind { drawRoundRect(color = Color(0xFF999999), style = stroke) } - .noRippleClickable{ + .noRippleClickable { pickImagesLauncher.launch("image/*") }, ) { @@ -275,10 +397,37 @@ fun AddImageGrid() { .align(Alignment.Center) ) } - + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .drawBehind { + drawRoundRect(color = Color(0xFF999999), style = stroke) + } + .noRippleClickable { + val photoFile = File(context.cacheDir, "photo.jpg") + val photoUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile + ) + model.currentPhotoUri = photoUri + takePictureLauncher.launch(photoUri) + }, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_camera), + contentDescription = "Take Photo", + modifier = Modifier + .size(48.dp) + .align(Alignment.Center), + colorFilter = ColorFilter.tint(Color.Gray) + ) + } } } - } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt index e28049f..e827171 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt @@ -29,6 +29,7 @@ object NewPostViewModel : ViewModel() { var imageUriList by mutableStateOf(listOf()) var relPostId by mutableStateOf(null) var relMoment by mutableStateOf(null) + var currentPhotoUri: Uri? = null fun asNewPost() { textContent = "" searchPlaceAddressResult = null