Updated on 2026-08-14

This commit is contained in:
Tangem 2023-06-09 10:42:22 +03:00
parent 6386cb1ea4
commit b97ea5faf8
10 changed files with 342 additions and 60 deletions

View file

@ -22,6 +22,7 @@ dependencies {
implementation(deps.compose.ui.tooling)
implementation(deps.compose.shimmer)
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.reorderable)
/** Other libraries */
implementation(deps.kotlin.immutable.collections)

View file

@ -105,21 +105,22 @@ internal object WalletPreviewData {
private const val tokensSize = 3
val draggableItems = List(networksSize) { it }
.flatMap { index ->
val n = index + 1
val lastNetworkIndex = networksSize - 1
val networkNumber = index + 1
val group = DraggableItem.GroupHeader(
id = "group_$n",
networkName = "$n",
id = "group_$networkNumber",
networkName = "$networkNumber",
)
val tokens: MutableList<DraggableItem.Token> = mutableListOf()
repeat(times = tokensSize) { i ->
val nt = i + 1
val tokenNumber = i + 1
tokens.add(
DraggableItem.Token(
tokenItemState = tokenItemDragState.copy(
id = "${group.id}_token_$nt",
name = "Token $nt",
id = "${group.id}_token_$tokenNumber",
name = "Token $tokenNumber",
networkIconResId = R.drawable.img_eth_22.takeIf { i != 0 },
),
groupId = group.id,
@ -127,9 +128,14 @@ internal object WalletPreviewData {
)
}
val divider = DraggableItem.GroupPlaceholder(id = "divider_$networkNumber")
buildList {
add(group)
addAll(tokens)
if (index != lastNetworkIndex) {
add(divider)
}
}
}
.toPersistentList()
@ -146,6 +152,12 @@ internal object WalletPreviewData {
onSortByBalanceClick = {},
onGroupByNetworkClick = {},
),
dragConfig = OrganizeTokensStateHolder.DragConfig(
onItemDragged = { _, _ -> },
onDragStart = {},
canDragItemOver = { _, _ -> false },
onItemDragEnd = {},
),
actions = OrganizeTokensStateHolder.ActionsConfig(
onApplyClick = {},
onCancelClick = {},

View file

@ -14,6 +14,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.impl.R
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
@Composable
internal fun NetworkGroupItem(networkName: String, modifier: Modifier = Modifier) {
@ -24,16 +26,33 @@ internal fun NetworkGroupItem(networkName: String, modifier: Modifier = Modifier
}
@Composable
internal fun DraggableNetworkGroupItem(networkName: String, modifier: Modifier = Modifier) {
internal fun DraggableNetworkGroupItem(
networkName: String,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
InternalNetworkGroupItem(
modifier = modifier,
networkName = networkName,
endIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_group_drop_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size32)
.let {
if (reorderableTokenListState != null) {
it.detectReorder(reorderableTokenListState)
} else {
it
}
},
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_group_drop_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
},
)
}

View file

@ -34,6 +34,8 @@ import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
private const val DOTS = "•••"
val TOKEN_ITEM_HEIGHT: Dp
@ -46,7 +48,7 @@ internal fun TokenItem(state: TokenItemState, modifier: Modifier = Modifier) {
when (state) {
is TokenItemState.Content -> ContentTokenItem(state, modifier)
is TokenItemState.Loading -> LoadingTokenItem(modifier)
is TokenItemState.Draggable -> DraggableTokenItem(state, modifier)
is TokenItemState.Draggable -> DraggableTokenItem(state, modifier, reorderableTokenListState = null)
is TokenItemState.Unreachable -> UnreachableTokenItem(state, modifier)
}
}
@ -71,7 +73,11 @@ private fun ContentTokenItem(content: TokenItemState.Content, modifier: Modifier
}
@Composable
internal fun DraggableTokenItem(state: TokenItemState.Draggable, modifier: Modifier = Modifier) {
internal fun DraggableTokenItem(
state: TokenItemState.Draggable,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
InternalTokenItem(
modifier = modifier,
name = state.name,
@ -81,13 +87,25 @@ internal fun DraggableTokenItem(state: TokenItemState.Draggable, modifier: Modif
amount = state.fiatAmount,
hasPending = false,
options = { ref ->
Icon(
Box(
modifier = Modifier
.constrainAsOptionsItem(scope = this, ref),
painter = painterResource(id = R.drawable.ic_drag_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
.size(TangemTheme.dimens.size32)
.constrainAsOptionsItem(scope = this, ref)
.let {
if (reorderableTokenListState != null) {
it.detectReorder(reorderableTokenListState)
} else {
it
}
},
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_drag_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
},
)
}

View file

@ -9,10 +9,7 @@ import androidx.compose.material.AppBarDefaults
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
@ -32,6 +29,10 @@ import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.component.DraggableNetworkGroupItem
import com.tangem.feature.wallet.presentation.common.component.DraggableTokenItem
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@Composable
internal fun OrganizeTokensScreen(state: OrganizeTokensStateHolder, modifier: Modifier = Modifier) {
@ -47,6 +48,7 @@ internal fun OrganizeTokensScreen(state: OrganizeTokensStateHolder, modifier: Mo
modifier = Modifier.padding(paddingValues),
listState = tokensListState,
state = state.itemsState,
dragConfig = state.dragConfig,
)
},
floatingActionButtonPosition = FabPosition.Center,
@ -57,28 +59,56 @@ internal fun OrganizeTokensScreen(state: OrganizeTokensStateHolder, modifier: Mo
)
}
// TODO: Fix list animations
@Composable
private fun TokenList(listState: LazyListState, state: OrganizeTokensListState, modifier: Modifier = Modifier) {
private fun TokenList(
listState: LazyListState,
state: OrganizeTokensListState,
dragConfig: OrganizeTokensStateHolder.DragConfig,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val reorderableListState = rememberReorderableLazyListState(
onMove = dragConfig.onItemDragged,
listState = listState,
canDragOver = dragConfig.canDragItemOver,
onDragEnd = { _, _ -> dragConfig.onItemDragEnd() },
)
val items = state.items
val lastItemIndex = items.lastIndex
LazyColumn(
modifier = Modifier
.reorderable(reorderableListState)
.align(Alignment.TopCenter)
.padding(horizontal = TangemTheme.dimens.spacing16),
state = listState,
state = reorderableListState.listState,
contentPadding = PaddingValues(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing92,
),
) {
itemsIndexed(
items = state.items,
items = items,
key = { _, item -> item.id },
) { index, item ->
val onDragStart = remember(item) {
{ dragConfig.onDragStart(item) }
}
DraggableItem(
item = item,
index = index,
lastItemIndex = state.items.lastIndex,
lastItemIndex = lastItemIndex,
reorderableState = reorderableListState,
onDragStart = onDragStart,
)
if (item is DraggableItem.GroupPlaceholder) {
// This item should be displayed in the list but remain invisible
Box(modifier = Modifier.fillMaxWidth())
}
}
}
@ -87,19 +117,39 @@ private fun TokenList(listState: LazyListState, state: OrganizeTokensListState,
}
@Composable
private fun DraggableItem(index: Int, item: DraggableItem, lastItemIndex: Int) {
val itemModifier = Modifier
.clipFirstAndLastItems(index, lastItemIndex)
private fun LazyItemScope.DraggableItem(
index: Int,
item: DraggableItem,
lastItemIndex: Int,
reorderableState: ReorderableLazyListState,
onDragStart: () -> Unit,
) {
ReorderableItem(
reorderableState = reorderableState,
index = index,
key = item.id,
) { isDragging ->
when (item) {
is DraggableItem.GroupHeader -> DraggableNetworkGroupItem(
modifier = itemModifier,
networkName = item.networkName,
)
is DraggableItem.Token -> DraggableTokenItem(
modifier = itemModifier,
state = item.tokenItemState,
)
if (isDragging) {
onDragStart()
}
val itemModifier = Modifier
.clipFirstLastAndDraggingItems(index, lastItemIndex, isDragging)
when (item) {
is DraggableItem.GroupHeader -> DraggableNetworkGroupItem(
modifier = itemModifier,
networkName = item.networkName,
reorderableTokenListState = reorderableState,
)
is DraggableItem.Token -> DraggableTokenItem(
modifier = itemModifier,
state = item.tokenItemState,
reorderableTokenListState = reorderableState,
)
is DraggableItem.GroupPlaceholder -> Unit
}
}
}
@ -201,29 +251,36 @@ private fun Actions(config: OrganizeTokensStateHolder.ActionsConfig, modifier: M
}
}
private fun Modifier.clipFirstAndLastItems(index: Int, lastItemIndex: Int): Modifier = composed {
when (index) {
0 -> {
this
.clip(
private fun Modifier.clipFirstLastAndDraggingItems(index: Int, lastItemIndex: Int, isDragging: Boolean): Modifier =
composed {
when {
isDragging -> {
val elevation by animateDpAsState(
targetValue = TangemTheme.dimens.elevation12,
label = "dragging_item_shadow_elevation",
)
this.shadow(elevation, shape = TangemTheme.shapes.roundedCornersXMedium)
}
index == 0 -> {
this.clip(
RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
),
)
}
lastItemIndex -> {
this
.clip(
}
index == lastItemIndex -> {
this.clip(
RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
),
)
}
else -> this
}
else -> this
}
}
// region Preview

View file

@ -2,12 +2,14 @@ package com.tangem.feature.wallet.presentation.organizetokens
import androidx.compose.runtime.Immutable
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import org.burnoutcrew.reorderable.ItemPosition
internal data class OrganizeTokensStateHolder(
val header: HeaderConfig,
val itemsState: OrganizeTokensListState,
val dragConfig: DragConfig,
val actions: ActionsConfig,
) {
@ -20,44 +22,79 @@ internal data class OrganizeTokensStateHolder(
val onApplyClick: () -> Unit,
val onCancelClick: () -> Unit,
)
data class DragConfig(
val onItemDragged: (from: ItemPosition, to: ItemPosition) -> Unit,
val canDragItemOver: (dragOver: ItemPosition, dragging: ItemPosition) -> Boolean,
val onItemDragEnd: () -> Unit,
val onDragStart: (item: DraggableItem) -> Unit,
)
}
@Immutable
internal sealed class OrganizeTokensListState {
abstract val items: ImmutableList<DraggableItem>
internal sealed interface OrganizeTokensListState {
val items: PersistentList<DraggableItem>
data class GroupedByNetwork(
override val items: ImmutableList<DraggableItem>,
) : OrganizeTokensListState()
override val items: PersistentList<DraggableItem>,
) : OrganizeTokensListState
data class Ungrouped(
override val items: ImmutableList<DraggableItem.Token>,
) : OrganizeTokensListState()
override val items: PersistentList<DraggableItem.Token>,
) : OrganizeTokensListState
@Suppress("UNCHECKED_CAST")
inline fun updateItems(update: (List<DraggableItem>) -> List<DraggableItem>): OrganizeTokensListState {
fun updateItems(update: (PersistentList<DraggableItem>) -> List<DraggableItem>): OrganizeTokensListState {
val updatedItems = update(this.items).toPersistentList()
return when (this) {
is GroupedByNetwork -> this.copy(items = updatedItems)
is Ungrouped -> this.copy(items = updatedItems as ImmutableList<DraggableItem.Token>)
is Ungrouped -> this.copy(items = updatedItems as PersistentList<DraggableItem.Token>)
}
}
}
/**
* Helper class for the DND list items
*
* @property id ID of the item
* */
@Immutable
internal sealed interface DraggableItem {
val id: String
/**
* Item for network group header.
*
* @property id ID of the network group
* @property networkName network group name
* */
data class GroupHeader(
override val id: String,
val networkName: String,
) : DraggableItem
/**
* Item for token.
*
* @property tokenItemState state of the token item
* @property groupId ID of the network group which contains this token
* @property id ID of the token
* */
data class Token(
val tokenItemState: TokenItemState.Draggable,
val groupId: String,
) : DraggableItem {
override val id: String = tokenItemState.id
}
/**
* Helper item used to detect possible positions where a network group can be placed.
* Used only on [OrganizeTokensListState.GroupedByNetwork] and placed between network groups.
*
* @property id ID of the placeholder
* */
data class GroupPlaceholder(
override val id: String,
) : DraggableItem
}

View file

@ -5,10 +5,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensStateHolder.DragConfig
import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensStateHolder.HeaderConfig
import com.tangem.feature.wallet.presentation.organizetokens.utils.checkCanMoveHeaderOver
import com.tangem.feature.wallet.presentation.organizetokens.utils.checkCanMoveTokenOver
import com.tangem.feature.wallet.presentation.organizetokens.utils.findItemsToMove
import com.tangem.feature.wallet.presentation.organizetokens.utils.moveItem
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toPersistentList
import org.burnoutcrew.reorderable.ItemPosition
import javax.inject.Inject
import kotlin.properties.Delegates
@ -16,6 +22,10 @@ import kotlin.properties.Delegates
@HiltViewModel
internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() {
// TODO: Move to domain
@Volatile
private var groupIdToTokens: Map<String, List<DraggableItem.Token>>? = null
var router: InnerWalletRouter by Delegates.notNull()
var uiState: OrganizeTokensStateHolder by mutableStateOf(getInitialState())
@ -25,6 +35,12 @@ internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() {
itemsState = OrganizeTokensListState.Ungrouped(
items = WalletPreviewData.draggableTokens,
),
dragConfig = DragConfig(
onItemDragged = this::moveItem,
canDragItemOver = this::checkCanMoveItemOver,
onItemDragEnd = this::expandGroups,
onDragStart = this::collapseGroup,
),
header = HeaderConfig(
onSortByBalanceClick = { /* no-op */ },
onGroupByNetworkClick = this::toggleTokensByNetworkGrouping,
@ -43,4 +59,69 @@ internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() {
uiState = uiState.copy(itemsState = newListState)
}
private fun checkCanMoveItemOver(moveOverItemPosition: ItemPosition, movedItemPosition: ItemPosition): Boolean {
val items = (uiState.itemsState as? OrganizeTokensListState.GroupedByNetwork)
?.items
?: return true // If ungrouped then item can be moved anywhere
val (moveOverItem, movedItem) = items.findItemsToMove(moveOverItemPosition.key, movedItemPosition.key)
if (moveOverItem == null || movedItem == null) {
return false
}
return when (movedItem) {
is DraggableItem.GroupHeader -> checkCanMoveHeaderOver(moveOverItemPosition, moveOverItem, items.lastIndex)
is DraggableItem.Token -> checkCanMoveTokenOver(movedItem, moveOverItem)
is DraggableItem.GroupPlaceholder -> false
}
}
private fun collapseGroup(item: DraggableItem) {
if (!groupIdToTokens.isNullOrEmpty() || item is DraggableItem.Token) return
val itemsState = uiState.itemsState as? OrganizeTokensListState.GroupedByNetwork ?: return
groupIdToTokens = itemsState.items
.asSequence()
.filterIsInstance<DraggableItem.Token>()
.groupBy(DraggableItem.Token::groupId)
uiState = uiState.copy(
itemsState = itemsState.updateItems { items ->
items.filterNot { it is DraggableItem.Token && it.groupId == item.id }
},
)
}
private fun expandGroups() {
if (groupIdToTokens.isNullOrEmpty()) return
val itemsState = uiState.itemsState as? OrganizeTokensListState.GroupedByNetwork ?: return
val currentGroups = itemsState.items.filterIsInstance<DraggableItem.GroupHeader>()
val newItems = currentGroups
.flatMapIndexed { index, group ->
mutableListOf<DraggableItem>(group)
.also { it.addAll(groupIdToTokens?.get(group.id).orEmpty()) }
.also {
if (index != currentGroups.lastIndex) {
it.add(DraggableItem.GroupPlaceholder(id = "group_divider_$index"))
}
}
}
uiState = uiState.copy(
itemsState = itemsState.updateItems { newItems },
)
groupIdToTokens = null
}
private fun moveItem(from: ItemPosition, to: ItemPosition) {
uiState = uiState.copy(
itemsState = uiState.itemsState.updateItems {
it.moveItem(from.index, to.index)
},
)
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils
import com.tangem.feature.wallet.presentation.organizetokens.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 {
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 {
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)
}

View file

@ -31,6 +31,7 @@ compose-constraint = "1.0.1"
compose-navigation = "2.5.3"
compose-accompanist = "0.30.1"
compose-paging = "1.0.0-alpha18"
compose-reorderable = "0.9.6"
# endregion Compose
# region Other libraries
@ -147,6 +148,7 @@ compose-accompanist-appCompatTheme = { module = "com.google.accompanist:accompan
compose-accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "compose-accompanist" }
compose-accompanist-webView = { module = "com.google.accompanist:accompanist-webview", version.ref = "compose-accompanist" }
compose-paging = { module = "androidx.paging:paging-compose", version.ref = "compose-paging" }
compose-reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "compose-reorderable" }
# endregion Compose
# region Firebase