Updated on 2026-08-14

This commit is contained in:
Tangem 2023-08-21 11:39:42 +03:00
parent c7b11501e7
commit f089d31106
14 changed files with 475 additions and 209 deletions

View file

@ -0,0 +1,23 @@
package com.tangem.core.ui.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
/**
* A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event
* is triggered.
*
* @param event The [StateEvent] to listen to.
* @param onTrigger The action to execute when the event is triggered.
*/
@Composable
@NonRestartableComposable
fun EventEffect(event: StateEvent, onTrigger: suspend () -> Unit) {
LaunchedEffect(event) {
if (event is StateEvent.Triggered) {
onTrigger()
event.consume()
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.core.ui.event
import androidx.compose.runtime.Immutable
/**
* Represents compose state event, which can be consumed or triggered.
*
* This is especially useful for handling one-off UI events like showing snack bars or navigation which should not be
* re-triggered on recompositions or state changes.
*/
@Immutable
sealed class StateEvent {
/** Defines the action to be executed when the event is consumed. */
protected abstract val onConsume: () -> Unit
/**
* Represents an already consumed state event.
* Events of this type will not trigger any further actions.
*/
object Consumed : StateEvent() {
override val onConsume: () -> Unit = {}
}
/**
* Represents a state event that has been triggered but not yet consumed.
*
* @property onConsume The action to be executed when the event is consumed.
*/
data class Triggered(override val onConsume: () -> Unit) : StateEvent()
/**
* Consumes the event, triggering any associated action.
*/
fun consume() {
onConsume()
}
}
/**
* Creates a [StateEvent.Triggered] instance.
*
* @param onConsume The action to be executed when the event is consumed.
* @return A triggered state event.
*/
fun triggered(onConsume: () -> Unit): StateEvent.Triggered = StateEvent.Triggered(onConsume)
/**
* Represents a statically defined [StateEvent.Consumed] event.
*/
val consumed: StateEvent.Consumed = StateEvent.Consumed

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.consumed
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
@ -145,7 +146,7 @@ internal object WalletPreviewData {
private const val networksSize = 10
private const val tokensSize = 3
val draggableItems by lazy {
private val draggableItems by lazy {
List(networksSize) { it }
.flatMap { index ->
val lastNetworkIndex = networksSize - 1
@ -194,7 +195,7 @@ internal object WalletPreviewData {
.toPersistentList()
}
val draggableTokens by lazy {
private val draggableTokens by lazy {
draggableItems
.filterIsInstance<DraggableItem.Token>()
.toMutableList()
@ -224,6 +225,7 @@ internal object WalletPreviewData {
onApplyClick = {},
onCancelClick = {},
),
scrollListToTop = consumed,
)
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.wallet.presentation.organizetokens
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import org.burnoutcrew.reorderable.ItemPosition
internal interface OrganizeTokensIntents {
fun onBackClick()
fun onSortClick()
fun onGroupClick()
fun onApplyClick()
fun onCancelClick()
}
internal interface DragAndDropIntents {
fun onItemDragged(from: ItemPosition, to: ItemPosition)
fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean
fun onItemDraggingStart(item: DraggableItem)
fun onItemDraggingEnd()
}

View file

@ -1,14 +0,0 @@
package com.tangem.feature.wallet.presentation.organizetokens
internal interface OrganizeTokensIntents {
fun onBackClick()
fun onSortClick()
fun onGroupClick()
fun onApplyClick()
fun onCancelClick()
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.organizetokens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@ -26,6 +27,7 @@ import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.buttons.actions.RoundedActionButton
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.impl.R
@ -64,6 +66,10 @@ internal fun OrganizeTokensScreen(state: OrganizeTokensState, modifier: Modifier
},
containerColor = TangemTheme.colors.background.secondary,
)
EventEffect(state.scrollListToTop) {
tokensListState.animateScrollToItem(index = 0)
}
}
@Composable
@ -74,11 +80,16 @@ private fun TokenList(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val onDragEnd: (Int, Int) -> Unit = remember {
{ _, _ ->
dndConfig.onItemDragEnd()
}
}
val reorderableListState = rememberReorderableLazyListState(
onMove = dndConfig.onItemDragged,
listState = listState,
canDragOver = dndConfig.canDragItemOver,
onDragEnd = { _, _ -> dndConfig.onItemDragEnd() },
onDragEnd = onDragEnd,
)
val items = state.items
@ -109,11 +120,6 @@ private fun TokenList(
reorderableState = reorderableListState,
onDragStart = onDragStart,
)
if (item is DraggableItem.GroupPlaceholder) {
// This item should be displayed in the list but remain invisible
Box(modifier = Modifier.fillMaxWidth())
}
}
}
@ -121,6 +127,7 @@ private fun TokenList(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LazyItemScope.DraggableItem(
index: Int,
@ -129,15 +136,13 @@ private fun LazyItemScope.DraggableItem(
onDragStart: () -> Unit,
) {
ReorderableItem(
reorderableState = reorderableState,
defaultDraggingModifier = Modifier.animateItemPlacement(
animationSpec = tween(easing = LinearOutSlowInEasing),
),
state = reorderableState,
index = index,
key = item.id,
) { isDragging ->
if (isDragging) {
onDragStart()
}
val itemModifier = Modifier.applyShapeAndShadow(item.roundingMode, item.showShadow)
when (item) {
@ -151,7 +156,14 @@ private fun LazyItemScope.DraggableItem(
state = item.tokenItemState,
reorderableTokenListState = reorderableState,
)
is DraggableItem.GroupPlaceholder -> Unit
// Should be presented in the list but remain invisible
is DraggableItem.GroupPlaceholder -> Box(modifier = Modifier.fillMaxWidth())
}
LaunchedEffect(isDragging) {
if (isDragging) {
onDragStart()
}
}
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.feature.wallet.presentation.organizetokens
import com.tangem.common.Provider
import com.tangem.core.ui.event.consumed
import com.tangem.core.ui.event.triggered
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.error.TokenListSortingError
@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.*
internal class OrganizeTokensStateHolder(
private val intents: OrganizeTokensIntents,
private val dragAndDropIntents: DragAndDropIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val onSubscription: () -> Unit,
stateFlowScope: CoroutineScope,
@ -60,6 +63,14 @@ internal class OrganizeTokensStateHolder(
updateState { tokenListConverter.convert(tokenList) }
}
fun updateStateAfterTokenListSorting(tokenList: TokenList) {
updateState {
tokenListConverter.convert(tokenList).copy(
scrollListToTop = triggered(::consumeScrollListToTopEvent),
)
}
}
fun updateStateToDisplayProgress() {
updateState { inProgressStateConverter.convert(value = this) }
}
@ -68,6 +79,15 @@ internal class OrganizeTokensStateHolder(
updateState { inProgressStateConverter.convertBack(value = this) }
}
fun updateStateWithManualSorting(itemsState: OrganizeTokensListState) {
updateState {
copy(
header = header.copy(isSortedByBalance = false),
itemsState = itemsState,
)
}
}
fun updateStateWithError(error: TokenListError) {
updateState { tokenListErrorConverter.convert(error) }
}
@ -88,17 +108,21 @@ internal class OrganizeTokensStateHolder(
onApplyClick = intents::onApplyClick,
onCancelClick = intents::onCancelClick,
),
// TODO: Will be added in next MR
dndConfig = OrganizeTokensState.DragAndDropConfig(
onItemDragged = { _, _ -> },
onDragStart = { },
onItemDragEnd = { },
canDragItemOver = { _, _ -> false },
onItemDragged = dragAndDropIntents::onItemDragged,
onDragStart = dragAndDropIntents::onItemDraggingStart,
onItemDragEnd = dragAndDropIntents::onItemDraggingEnd,
canDragItemOver = dragAndDropIntents::canDragItemOver,
),
scrollListToTop = consumed,
)
}
private fun updateState(block: OrganizeTokensState.() -> OrganizeTokensState) {
stateFlowInternal.update(block)
}
private fun consumeScrollListToTopEvent() {
updateState { copy(scrollListToTop = consumed) }
}
}

View file

@ -16,6 +16,8 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState
import com.tangem.feature.wallet.presentation.organizetokens.utils.CryptoCurrenciesIdsResolver
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.disableSortingByBalance
import com.tangem.feature.wallet.presentation.organizetokens.utils.dnd.DragAndDropAdapter
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.router.WalletRoute
import dagger.hilt.android.lifecycle.HiltViewModel
@ -39,12 +41,19 @@ internal class OrganizeTokensViewModel @Inject constructor(
private val selectedAppCurrencyFlow = createSelectedAppCurrencyFlow()
private val dragAndDropAdapter = DragAndDropAdapter(
listStateProvider = Provider { uiState.value.itemsState },
scope = viewModelScope,
)
private val stateHolder = OrganizeTokensStateHolder(
stateFlowScope = viewModelScope,
intents = this,
dragAndDropIntents = dragAndDropAdapter,
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
onSubscription = {
bootstrapTokenList()
bootstrapDragAndDropUpdates()
},
)
@ -69,7 +78,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
toggleTokenListSortingUseCase(list).fold(
ifLeft = stateHolder::updateStateWithError,
ifRight = {
stateHolder.updateStateWithTokenList(it)
stateHolder.updateStateAfterTokenListSorting(it)
tokenList = it
},
)
@ -83,7 +92,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
toggleTokenListGroupingUseCase(list).fold(
ifLeft = stateHolder::updateStateWithError,
ifRight = {
stateHolder.updateStateWithTokenList(it)
stateHolder.updateStateAfterTokenListSorting(it)
tokenList = it
},
)
@ -133,6 +142,16 @@ internal class OrganizeTokensViewModel @Inject constructor(
}
}
private fun bootstrapDragAndDropUpdates() {
dragAndDropAdapter.stateFlow
.distinctUntilChanged()
.onEach {
stateHolder.updateStateWithManualSorting(it)
tokenList = tokenList?.disableSortingByBalance()
}
.launchIn(viewModelScope)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.presentation.organizetokens.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import org.burnoutcrew.reorderable.ItemPosition
@Immutable
@ -10,6 +11,7 @@ internal data class OrganizeTokensState(
val header: HeaderConfig,
val actions: ActionsConfig,
val dndConfig: DragAndDropConfig,
val scrollListToTop: StateEvent,
) {
data class HeaderConfig(

View file

@ -1,184 +1,27 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.common
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import kotlinx.collections.immutable.PersistentList
import org.burnoutcrew.reorderable.ItemPosition
internal fun List<DraggableItem>.findItemsToMove(
moveOverItemKey: Any?,
movedItemKey: Any?,
): Pair<DraggableItem?, DraggableItem?> {
var moveOverItem: DraggableItem? = null
var movedItem: DraggableItem? = null
for (item in this) {
if (item.id == moveOverItemKey) {
moveOverItem = item
}
if (item.id == movedItemKey) {
movedItem = item
}
if (moveOverItem != null && movedItem != null) {
break
}
}
return Pair(moveOverItem, movedItem)
}
internal fun checkCanMoveHeaderOver(
moveOverItemPosition: ItemPosition,
moveOverItem: DraggableItem,
lastItemIndex: Int,
): Boolean {
// Group item can be moved only to group divider or to ages of the items list
return when {
moveOverItemPosition.index == 0 -> true
moveOverItemPosition.index == lastItemIndex -> true
moveOverItem is DraggableItem.GroupPlaceholder -> true
else -> false
}
}
internal fun checkCanMoveTokenOver(item: DraggableItem.Token, moveOverItem: DraggableItem): Boolean {
// Token item can be moved only in its group
return when (moveOverItem) {
is DraggableItem.GroupHeader -> false // Token item can not be moved to group item
is DraggableItem.Token -> item.groupId == moveOverItem.groupId // Token item can not be moved over its group
is DraggableItem.GroupPlaceholder -> false
}
}
internal fun PersistentList<DraggableItem>.moveItem(fromIndex: Int, toIndex: Int): PersistentList<DraggableItem> {
val fromItem = this[fromIndex]
return this
.removeAt(fromIndex)
.add(toIndex, fromItem)
}
internal fun List<DraggableItem>.divideItems(movingItem: DraggableItem): List<DraggableItem> {
return this.map {
it
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = it.id == movingItem.id)
}
}
@Suppress("UNCHECKED_CAST") // Erased type
internal fun <T : DraggableItem> List<T>.uniteItems(): List<T> {
internal fun List<DraggableItem>.uniteItems(): List<DraggableItem> {
val lastItemIndex = this.lastIndex
return this.mapIndexed { index, item ->
val mode = when (index) {
0 -> DraggableItem.RoundingMode.Top()
lastItemIndex -> DraggableItem.RoundingMode.Bottom()
else -> DraggableItem.RoundingMode.None
else -> when (item) {
is DraggableItem.GroupHeader -> DraggableItem.RoundingMode.Top(showGap = true)
is DraggableItem.Token -> if (this[index + 1] is DraggableItem.GroupPlaceholder) {
DraggableItem.RoundingMode.Bottom(showGap = true)
} else {
DraggableItem.RoundingMode.None
}
is DraggableItem.GroupPlaceholder -> DraggableItem.RoundingMode.None
}
}
item
.updateRoundingMode(mode)
.updateShadowVisibility(show = false)
} as List<T>
}
// TODO: Move to domain
@Volatile
private var groupIdToTokens: Map<String, List<DraggableItem.Token>>? = null
internal fun List<DraggableItem>.collapseGroup(group: DraggableItem.GroupHeader): List<DraggableItem> {
if (!groupIdToTokens.isNullOrEmpty()) return this
groupIdToTokens = this
.asSequence()
.filterIsInstance<DraggableItem.Token>()
.groupBy { it.groupId }
return this
.filterNot { it is DraggableItem.Token && it.groupId == group.id }
.divideGroups(group)
}
internal fun List<DraggableItem>.expandGroups(): List<DraggableItem> {
if (groupIdToTokens.isNullOrEmpty()) return this
val currentGroups = this.filterIsInstance<DraggableItem.GroupHeader>()
val lastGroupIndex = currentGroups.lastIndex
return currentGroups
.flatMapIndexed { index, group ->
buildList {
add(group)
addAll(groupIdToTokens?.get(group.id).orEmpty())
if (index != lastGroupIndex) {
add(DraggableItem.GroupPlaceholder(id = "group_divider_$index"))
}
}
}
.uniteItems()
.also { groupIdToTokens = null }
}
/**
* Applies the correct [DraggableItem.RoundingMode] and shadow status to each item in the list,
* based on the relationship of each item to the [movingItem] and its position in the list.
*
* @param movingItem The item that is being dragged/moved.
* @return A list of [DraggableItem]s with updated rounding modes and shadow statuses.
*/
internal fun List<DraggableItem>.divideGroups(movingItem: DraggableItem): List<DraggableItem> {
val lastItemIndex = this.lastIndex
return this.mapIndexed { index, item ->
when {
// Case when current item is the moving item
item.id == movingItem.id -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when moving item is a token and current item is the group of the moving token
movingItem is DraggableItem.Token && item.id == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when both moving item and current item are tokens and belong to the same group
movingItem is DraggableItem.Token &&
item is DraggableItem.Token && item.groupId == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when current item is the first item in the list
index == 0 -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top())
.updateShadowVisibility(show = false)
}
// Case when current item is the last item in the list
index == lastItemIndex -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom())
.updateShadowVisibility(show = false)
}
// Case when previous item is a GroupPlaceholder
this[index - 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when next item is a GroupPlaceholder
this[index + 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom(showGap = true))
.updateShadowVisibility(show = false)
}
// Default case when none of the above conditions are met
else -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.None)
.updateShadowVisibility(show = false)
}
}
}
}

View file

@ -3,12 +3,10 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.common
import com.tangem.domain.tokens.model.TokenList
import com.tangem.domain.tokens.model.TokenList.SortType
internal fun TokenList.updateSorting(isSortedByBalance: Boolean): TokenList {
val sortType = if (isSortedByBalance) SortType.BALANCE else SortType.NONE
internal fun TokenList.disableSortingByBalance(): TokenList {
return when (this) {
is TokenList.GroupedByNetwork -> this.copy(sortedBy = sortType)
is TokenList.Ungrouped -> this.copy(sortedBy = sortType)
is TokenList.GroupedByNetwork -> this.copy(sortedBy = SortType.NONE)
is TokenList.Ungrouped -> this.copy(sortedBy = SortType.NONE)
is TokenList.NotInitialized -> this
}
}

View file

@ -1,9 +1,11 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.converter.items
import com.tangem.domain.tokens.model.TokenList
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class TokenListToListStateConverter(
@ -28,11 +30,12 @@ internal class TokenListToListStateConverter(
)
}
@Suppress("UNCHECKED_CAST") // Erased type
private fun createListState(tokenList: TokenList.Ungrouped): OrganizeTokensListState.Ungrouped {
return OrganizeTokensListState.Ungrouped(
items = tokensConverter.convertList(tokenList.currencies)
.uniteItems()
.toPersistentList(),
.toPersistentList() as PersistentList<DraggableItem.Token>,
)
}

View file

@ -0,0 +1,169 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.dnd
import com.tangem.common.Provider
import com.tangem.feature.wallet.presentation.organizetokens.DragAndDropIntents
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.updateItems
import kotlinx.collections.immutable.mutate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ItemPosition
internal class DragAndDropAdapter(
private val listStateProvider: Provider<OrganizeTokensListState>,
private val scope: CoroutineScope,
) : DragAndDropIntents {
private val draggableGroupsOperations = DraggableGroupsOperations()
private val currentListState: OrganizeTokensListState
get() = listStateProvider.invoke()
private val listStateFlowInternal: MutableSharedFlow<OrganizeTokensListState> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private var currentDraggingItem: DraggableItem? = null
val stateFlow: Flow<OrganizeTokensListState>
get() = listStateFlowInternal
override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean {
val items = (currentListState as? OrganizeTokensListState.GroupedByNetwork)
?.items
?: return true // If ungrouped then item can be moved anywhere
val (dragOverItem, draggingItem) = findItemsToMove(
items = items,
moveOverItemKey = dragOver.key,
movedItemKey = dragging.key,
)
if (dragOverItem == null || draggingItem == null) {
return false
}
return when (draggingItem) {
is DraggableItem.GroupHeader -> checkCanMoveHeaderOver(dragOver, dragOverItem, items.lastIndex)
is DraggableItem.Token -> checkCanMoveTokenOver(draggingItem, dragOverItem)
is DraggableItem.GroupPlaceholder -> false
}
}
override fun onItemDraggingStart(item: DraggableItem) {
if (currentDraggingItem != null) return
currentDraggingItem = item
updateListState {
when (item) {
is DraggableItem.GroupPlaceholder -> items
is DraggableItem.GroupHeader -> draggableGroupsOperations.collapseGroup(items, item)
is DraggableItem.Token -> when (this) {
is OrganizeTokensListState.GroupedByNetwork -> draggableGroupsOperations.divideGroups(items, item)
is OrganizeTokensListState.Ungrouped -> divideTokens(items, item)
is OrganizeTokensListState.Empty -> items
}
}
}
}
override fun onItemDraggingEnd() {
scope.launch(Dispatchers.IO) {
val draggingItem = currentDraggingItem ?: return@launch
delay(FINISH_DRAGGING_DELAY_MILLIS)
updateListState {
when (draggingItem) {
is DraggableItem.GroupHeader -> draggableGroupsOperations.expandGroups(items)
is DraggableItem.Token -> items.uniteItems()
is DraggableItem.GroupPlaceholder -> items
}
}
currentDraggingItem = null
}
}
override fun onItemDragged(from: ItemPosition, to: ItemPosition) = updateListState {
items.mutate {
it.add(to.index, it.removeAt(from.index))
}
}
private fun updateListState(block: OrganizeTokensListState.() -> List<DraggableItem>) {
val updatedState = currentListState.updateItems { block(currentListState) }
listStateFlowInternal.tryEmit(updatedState)
}
private fun findItemsToMove(
items: List<DraggableItem>,
moveOverItemKey: Any?,
movedItemKey: Any?,
): Pair<DraggableItem?, DraggableItem?> {
var moveOverItem: DraggableItem? = null
var movedItem: DraggableItem? = null
for (item in items) {
if (item.id == moveOverItemKey) {
moveOverItem = item
}
if (item.id == movedItemKey) {
movedItem = item
}
if (moveOverItem != null && movedItem != null) {
break
}
}
return Pair(moveOverItem, movedItem)
}
private fun checkCanMoveHeaderOver(
moveOverItemPosition: ItemPosition,
moveOverItem: DraggableItem,
lastItemIndex: Int,
): Boolean {
// Group item can be moved only to group divider or to ages of the items list
return when {
moveOverItemPosition.index == 0 -> true
moveOverItemPosition.index == lastItemIndex -> true
moveOverItem is DraggableItem.GroupPlaceholder -> true
else -> false
}
}
private fun checkCanMoveTokenOver(item: DraggableItem.Token, moveOverItem: DraggableItem): Boolean {
// Token item can be moved only in its group
return when (moveOverItem) {
is DraggableItem.GroupHeader -> false // Token item can not be moved to group item
is DraggableItem.Token -> item.groupId == moveOverItem.groupId // Token item can not be moved over its group
is DraggableItem.GroupPlaceholder -> false
}
}
@Suppress("UNCHECKED_CAST") // Erased type
private fun divideTokens(
items: List<DraggableItem.Token>,
movingItem: DraggableItem.Token,
): List<DraggableItem.Token> {
return items.map { token ->
token
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = token.id == movingItem.id)
} as List<DraggableItem.Token>
}
private companion object {
const val FINISH_DRAGGING_DELAY_MILLIS = 200L
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.dnd
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupPlaceholder
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems
internal class DraggableGroupsOperations {
private var groupIdToTokens: Map<String, List<DraggableItem.Token>>? = null
fun collapseGroup(items: List<DraggableItem>, movingGroup: DraggableItem.GroupHeader): List<DraggableItem> {
if (!groupIdToTokens.isNullOrEmpty()) return items
groupIdToTokens = items
.asSequence()
.filterIsInstance<DraggableItem.Token>()
.groupBy { it.groupId }
val itemsWithoutGroupTokens = items.filterNot {
it is DraggableItem.Token && it.groupId == movingGroup.id
}
return divideGroups(itemsWithoutGroupTokens, movingGroup)
}
fun expandGroups(items: List<DraggableItem>): List<DraggableItem> {
if (groupIdToTokens.isNullOrEmpty()) return items
val currentGroups = items.filterIsInstance<DraggableItem.GroupHeader>()
val lastGroupIndex = currentGroups.lastIndex
val expandedGroups = currentGroups
.flatMapIndexed { index, group ->
buildList {
add(group)
addAll(groupIdToTokens?.get(group.id).orEmpty())
if (index != lastGroupIndex) {
add(getGroupPlaceholder(index))
}
}
}
.uniteItems()
groupIdToTokens = null
return expandedGroups
}
fun divideGroups(items: List<DraggableItem>, movingItem: DraggableItem): List<DraggableItem> {
val lastItemIndex = items.lastIndex
return items.mapIndexed { index, item ->
when {
// Case when current item is the moving item
item.id == movingItem.id -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when moving item is a token and current item is the group of the moving token
movingItem is DraggableItem.Token && item.id == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when both moving item and current item are tokens and belong to the same group
movingItem is DraggableItem.Token &&
item is DraggableItem.Token && item.groupId == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when current item is the first item in the list
index == 0 -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top())
.updateShadowVisibility(show = false)
}
// Case when current item is the last item in the list
index == lastItemIndex -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom())
.updateShadowVisibility(show = false)
}
// Case when previous item is a GroupPlaceholder
items[index - 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when next item is a GroupPlaceholder
items[index + 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom(showGap = true))
.updateShadowVisibility(show = false)
}
// Default case when none of the above conditions are met
else -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.None)
.updateShadowVisibility(show = false)
}
}
}
}
}