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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user