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.
This commit is contained in:
eliotcougar
2026-07-31 09:52:26 +08:00
committed by GitHub
parent 794940a1a0
commit 4fee3a67f7
12 changed files with 117 additions and 71 deletions
@@ -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()
@@ -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 <T> MutableList<T>.moveItem(fromIndex: Int, toIndex: Int): Boolean {
if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false
add(toIndex, removeAt(fromIndex))
return true
}
@@ -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)
}
}
}
@@ -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
@@ -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
@@ -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<String, List<ServersCache>>()
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
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")
}
}
}
}
@@ -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(
@@ -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()
}
}
}
}
@@ -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),
@@ -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(
@@ -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)
}
}
}
@@ -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)
}
}