From 4fee3a67f7107631e3b899d9734621677767b7ab Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Fri, 31 Jul 2026 04:52:26 +0300 Subject: [PATCH] Consistent reorderable list behavior and persistence (#5968) * Fix reordered list behavior and persistence Use remove-and-insert semantics for every reorderable collection and persist the resulting order instead of swapping endpoints. Resolve routing rules and proxy-chain members by stable keys, make proxy rows draggable, and serialize profile-order writes per group so rapid drag updates cannot be persisted out of order. * Signal touch reorder activation with haptic feedback Emit one gesture-threshold pulse when a long press makes an item draggable. Reuse the same handle across every reorderable list and rely on the platform touch-feedback setting. --- .../java/com/v2ray/ang/compose/Components.kt | 17 +++++++-- .../java/com/v2ray/ang/extension/ListExt.kt | 10 ++++++ .../com/v2ray/ang/handler/SettingsManager.kt | 34 +++--------------- .../com/v2ray/ang/ui/main/MainContract.kt | 2 -- .../com/v2ray/ang/ui/main/MainServerPager.kt | 8 ++--- .../com/v2ray/ang/ui/main/MainViewModel.kt | 22 ++++++------ .../ang/ui/routing/RoutingSettingActivity.kt | 5 ++- .../ui/routing/RoutingSettingsViewModel.kt | 11 +++--- .../ang/ui/server/ServerProxyChainActivity.kt | 30 +++++++++++----- .../ang/ui/subscription/SubSettingActivity.kt | 2 +- .../ui/subscription/SubscriptionsViewModel.kt | 11 +++--- .../com/v2ray/ang/extension/ListExtTest.kt | 36 +++++++++++++++++++ 12 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/extension/ListExt.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/extension/ListExtTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt index 5008fefe7..e353a8860 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt @@ -46,7 +46,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction @@ -278,6 +280,17 @@ private fun reorderableElevation(isDragging: Boolean) = animateDpAsState( label = "ReorderableElevation" ) +@Composable +fun ReorderableCollectionItemScope.reorderableDragHandle(): Modifier { + val hapticFeedback = LocalHapticFeedback.current + return Modifier.longPressDraggableHandle( + onDragStarted = { + // Platform haptics honor the user's touch-feedback setting. + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + ) +} + @Composable fun ReorderableListItem( scope: ReorderableCollectionItemScope, @@ -292,7 +305,7 @@ fun ReorderableListItem( Row( modifier = Modifier .fillMaxWidth() - .then(with(scope) { Modifier.longPressDraggableHandle() }), + .then(with(scope) { reorderableDragHandle() }), verticalAlignment = Alignment.CenterVertically, content = content ) @@ -309,7 +322,7 @@ fun ReorderableGridItem( Surface( modifier = Modifier .fillMaxWidth() - .then(with(scope) { Modifier.longPressDraggableHandle() }), + .then(with(scope) { reorderableDragHandle() }), shadowElevation = elevation ) { content() diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/extension/ListExt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/ListExt.kt new file mode 100644 index 000000000..bdb7b2bf9 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/ListExt.kt @@ -0,0 +1,10 @@ +package com.v2ray.ang.extension + +/** + * Moves an item to another index while preserving the relative order of the remaining items. + */ +internal fun MutableList.moveItem(fromIndex: Int, toIndex: Int): Boolean { + if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false + add(toIndex, removeAt(fromIndex)) + return true +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt index e1646aed2..b690c65db 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt @@ -19,6 +19,7 @@ import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.Language import com.v2ray.ang.enums.RoutingType import com.v2ray.ang.enums.VpnInterfaceAddressConfig +import com.v2ray.ang.extension.moveItem import com.v2ray.ang.handler.MmkvManager.decodeAllServerList import com.v2ray.ang.handler.MmkvManager.decodeServerConfig import com.v2ray.ang.handler.MmkvManager.decodeSubsList @@ -30,7 +31,6 @@ import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils import java.io.File import java.io.FileOutputStream -import java.util.Collections import java.util.Locale import kotlin.random.Random @@ -204,32 +204,6 @@ object SettingsManager { return exist == true } - /** - * Swap routing rulesets. - * @param fromPosition The position to swap from. - * @param toPosition The position to swap to. - */ - fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) { - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) return - - Collections.swap(rulesetList, fromPosition, toPosition) - MmkvManager.encodeRoutingRulesets(rulesetList) - } - - /** - * Swap subscriptions. - * @param fromPosition The position to swap from. - * @param toPosition The position to swap to. - */ - fun swapSubscriptions(fromPosition: Int, toPosition: Int) { - val subsList = decodeSubsList() - if (subsList.isEmpty()) return - - Collections.swap(subsList, fromPosition, toPosition) - MmkvManager.encodeSubsList(subsList) - } - /** * Get server via remarks. * @param remarks The remarks of the server. @@ -623,10 +597,10 @@ object SettingsManager { ) encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub) - // Move top + // Move to the top val subsList = decodeSubsList() - if (subsList.count() > 1) { - swapSubscriptions(0, subsList.count() - 1) + if (subsList.moveItem(subsList.lastIndex, 0)) { + MmkvManager.encodeSubsList(subsList) } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainContract.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainContract.kt index 4c504bf64..103f9bf26 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainContract.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainContract.kt @@ -49,8 +49,6 @@ sealed interface MainAction { data class RemoveServer(val guid: String) : MainAction data class EditServer(val guid: String, val profile: com.v2ray.ang.dto.entities.ProfileItem) : MainAction data class Search(val query: String) : MainAction - data class SwapServer(val fromIndex: Int, val toIndex: Int) : MainAction - data class ShareQRCode(val guid: String) : MainAction data class ShareClipboard(val guid: String) : MainAction data class ShareFullContent(val guid: String) : MainAction diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt index 71d17ab6d..05688e41b 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt @@ -98,7 +98,7 @@ fun GroupPagerPage( onShareServer = onShareServer, onMoreServer = onMoreServer, onRemoveServer = onRemoveServer, - onSwapServer = mainViewModel::swapServer, + onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) }, contentPadding = contentPadding ) } @@ -119,7 +119,7 @@ private fun ServerListPage( onShareServer: (String, ProfileItem) -> Unit, onMoreServer: (String, ProfileItem) -> Unit, onRemoveServer: (String) -> Unit, - onSwapServer: (Int, Int) -> Unit, + onMoveServer: (Int, Int) -> Unit, contentPadding: PaddingValues ) { if (doubleColumnDisplay) { @@ -128,7 +128,7 @@ private fun ServerListPage( } val reorderableGridState = if (canReorder) { rememberReorderableLazyGridState(gridState) { from, to -> - onSwapServer(from.index, to.index) + onMoveServer(from.index, to.index) } } else null @@ -175,7 +175,7 @@ private fun ServerListPage( } val reorderableState = if (canReorder) { rememberReorderableLazyListState(listState) { from, to -> - onSwapServer(from.index, to.index) + onMoveServer(from.index, to.index) } } else null diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt index 62f00ed73..3eed95ea8 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt @@ -14,6 +14,7 @@ import com.v2ray.ang.dto.entities.ServersCache import com.v2ray.ang.dto.entities.SubscriptionCache import com.v2ray.ang.extension.isComplexType import com.v2ray.ang.extension.matchesPattern +import com.v2ray.ang.extension.moveItem import com.v2ray.ang.ui.base.BaseViewModel import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.CancellationException @@ -32,7 +33,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.regex.PatternSyntaxException @@ -70,6 +70,7 @@ class MainViewModel( private val groupDataCache = mutableMapOf>() private val groupPageFlows = ConcurrentHashMap>>() private val groupLoadMutexes = ConcurrentHashMap() + private val serverOrderPersistenceJobs = mutableMapOf() private var setupGroupJob: Job? = null private var preloadJob: Job? = null @@ -175,7 +176,6 @@ class MainViewModel( is MainAction.SelectServer -> updateSelectedGuid(action.guid) is MainAction.RemoveServer -> removeServerAndRefresh(action.guid) is MainAction.Search -> filterConfig(action.query) - is MainAction.SwapServer -> swapServer(action.fromIndex, action.toIndex) is MainAction.ImportBatchConfig -> importBatchConfig(action.configText) is MainAction.LocateHandled -> consumeLocateTarget(action.target) is MainAction.ShareQRCode -> { @@ -646,15 +646,15 @@ class MainViewModel( } } - fun swapServer(fromPosition: Int, toPosition: Int) { - val groupId = uiState.value.selectedGroupId - if (groupId.isEmpty()) return - val servers = currentServers().toMutableList() - if (fromPosition !in servers.indices || toPosition !in servers.indices) return - Collections.swap(servers, fromPosition, toPosition) - val guids = servers.mapTo(ArrayList(servers.size)) { it.guid } + fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) { + val servers = mutableServersForGroup(groupId).value.toMutableList() + if (!servers.moveItem(fromPosition, toPosition)) return + val guids = servers.map { it.guid } mutableServersForGroup(groupId).value = servers - viewModelScope.launch(ioDispatcher) { + // A drag emits several moves; serialize writes so an older order cannot overwrite a newer one. + val previousPersistenceJob = serverOrderPersistenceJobs[groupId] + serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) { + previousPersistenceJob?.join() dataSource.encodeServerList(guids, groupId) cacheMutex.withLock { groupDataCache[groupId] = servers } } @@ -780,4 +780,4 @@ class MainViewModel( throw IllegalArgumentException("Unknown ViewModel class") } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingActivity.kt index 033dde980..e246b6780 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingActivity.kt @@ -196,7 +196,10 @@ fun RoutingSettingScreen( val lazyListState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> - viewModel.swap(from.index - 1, to.index - 1) + // Lazy list indices include the preceding non-rule content, so resolve the stable rule keys. + val fromIndex = rulesets.indexOfFirst { it.id == from.key } + val toIndex = rulesets.indexOfFirst { it.id == to.key } + viewModel.move(fromIndex, toIndex) } Scaffold( diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingsViewModel.kt index 0e79f6c9e..82d4d22ba 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingsViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/routing/RoutingSettingsViewModel.kt @@ -2,6 +2,7 @@ package com.v2ray.ang.ui.routing import android.app.Application import com.v2ray.ang.dto.entities.RulesetItem +import com.v2ray.ang.extension.moveItem import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.ui.base.BaseViewModel @@ -41,12 +42,10 @@ class RoutingSettingsViewModel(application: Application) : BaseViewModel(applica } } - fun swap(fromPosition: Int, toPosition: Int) { - if (fromPosition in rulesets.indices && toPosition in rulesets.indices) { - SettingsManager.swapRoutingRuleset(fromPosition, toPosition) - val item = rulesets.removeAt(fromPosition) - rulesets.add(toPosition, item) + fun move(fromPosition: Int, toPosition: Int) { + if (rulesets.moveItem(fromPosition, toPosition)) { + MmkvManager.encodeRoutingRulesets(rulesets) _rulesetsFlow.value = rulesets.toList() } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt index fe91af958..30894881c 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt @@ -39,15 +39,18 @@ import com.v2ray.ang.compose.AppTopBar import com.v2ray.ang.compose.ConfirmDialog import com.v2ray.ang.compose.FormDropdownField import com.v2ray.ang.compose.FormTextField +import com.v2ray.ang.compose.reorderableDragHandle import com.v2ray.ang.compose.verticalScrollbar import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.extension.isComplexType +import com.v2ray.ang.extension.moveItem import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.ui.base.BaseComponentActivity +import java.util.UUID import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -195,16 +198,22 @@ fun ProxyChainScreen( onDelete: () -> Unit ) { var remarks by rememberSaveable { mutableStateOf(initialRemarks) } - var members by rememberSaveable { mutableStateOf(initialMembers.toMutableList()) } + var members by rememberSaveable { mutableStateOf(initialMembers.toList()) } + // Member remarks may be blank or duplicated, so they cannot serve as stable Compose keys. + var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) } var showDeleteConfirm by remember { mutableStateOf(false) } val showDelete = editGuid.isNotEmpty() && !isRunning val lazyListState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> - val fromIdx = from.index - 1 - val toIdx = to.index - 1 - if (fromIdx in members.indices && toIdx in members.indices) { - members = members.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } + val fromIndex = memberKeys.indexOf(from.key) + val toIndex = memberKeys.indexOf(to.key) + val reordered = members.toMutableList() + val reorderedKeys = memberKeys.toMutableList() + if (reordered.moveItem(fromIndex, toIndex)) { + reorderedKeys.moveItem(fromIndex, toIndex) + members = reordered + memberKeys = reorderedKeys } } @@ -228,7 +237,10 @@ fun ProxyChainScreen( }, floatingActionButton = { FloatingActionButton( - onClick = { members = members.toMutableList().also { it.add("") } }, + onClick = { + members = members + "" + memberKeys = memberKeys + UUID.randomUUID().toString() + }, modifier = Modifier .offset(y = -20.dp) .navigationBarsPadding() @@ -268,13 +280,14 @@ fun ProxyChainScreen( ) } - itemsIndexed(items = members, key = { idx, _ -> "member_$idx" }) { index, member -> - ReorderableItem(reorderableState, key = "member_$index") { isDragging -> + itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member -> + ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging -> val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) Surface(shadowElevation = elevation) { Row( modifier = Modifier .fillMaxWidth() + .then(with(this) { reorderableDragHandle() }) .padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -297,6 +310,7 @@ fun ProxyChainScreen( ) IconButton(onClick = { members = members.toMutableList().also { it.removeAt(index) } + memberKeys = memberKeys.toMutableList().also { it.removeAt(index) } }) { Icon( painterResource(R.drawable.ic_delete_24dp), diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt index 75f8a79fc..7f4b09a80 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt @@ -121,7 +121,7 @@ fun SubSettingScreen( val lazyListState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> - viewModel.swap(from.index, to.index) + viewModel.move(from.index, to.index) } Scaffold( diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt index 2fffb0026..4dbcc62ee 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt @@ -6,6 +6,7 @@ import com.v2ray.ang.R import com.v2ray.ang.dto.SubscriptionUpdateMessage import com.v2ray.ang.dto.entities.SubscriptionCache import com.v2ray.ang.dto.entities.SubscriptionItem +import com.v2ray.ang.extension.moveItem import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsManager @@ -49,11 +50,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati _subsFlow.value = subscriptions.toList() } - fun swap(fromPosition: Int, toPosition: Int) { - if (fromPosition in subscriptions.indices && toPosition in subscriptions.indices) { - val item = subscriptions.removeAt(fromPosition) - subscriptions.add(toPosition, item) - SettingsManager.swapSubscriptions(fromPosition, toPosition) + fun move(fromPosition: Int, toPosition: Int) { + if (subscriptions.moveItem(fromPosition, toPosition)) { + MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid }) SettingsChangeManager.makeSetupGroupTab() _subsFlow.value = subscriptions.toList() } @@ -71,4 +70,4 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati toast(R.string.subscription_updater_job_tips) } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/extension/ListExtTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/extension/ListExtTest.kt new file mode 100644 index 000000000..12503c9ef --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/extension/ListExtTest.kt @@ -0,0 +1,36 @@ +package com.v2ray.ang.extension + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ListExtTest { + + @Test + fun moveItem_down_preservesRelativeOrder() { + val items = mutableListOf("a", "b", "c", "d") + + assertTrue(items.moveItem(0, 2)) + + assertEquals(listOf("b", "c", "a", "d"), items) + } + + @Test + fun moveItem_up_preservesRelativeOrder() { + val items = mutableListOf("a", "b", "c", "d") + + assertTrue(items.moveItem(3, 1)) + + assertEquals(listOf("a", "d", "b", "c"), items) + } + + @Test + fun moveItem_rejectsNoOpAndInvalidIndex() { + val items = mutableListOf("a", "b", "c") + + assertFalse(items.moveItem(1, 1)) + assertFalse(items.moveItem(-1, 1)) + assertEquals(listOf("a", "b", "c"), items) + } +}