Updated on 2026-08-14

This commit is contained in:
Tangem 2023-10-06 15:00:57 +08:00
parent 2a97712a72
commit 59080b682d
3 changed files with 313 additions and 153 deletions

View file

@ -3,19 +3,16 @@ package com.tangem.feature.wallet.presentation.common.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.constraintlayout.compose.*
import androidx.compose.ui.unit.Constraints
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemTheme
@ -24,152 +21,64 @@ import com.tangem.feature.wallet.presentation.common.component.token.*
import com.tangem.feature.wallet.presentation.common.component.token.icon.TokenIcon
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import org.burnoutcrew.reorderable.ReorderableLazyListState
import kotlin.math.max
private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.22
private const val PRICE_CHANGE_MIN_WIDTH_COEFFICIENT = 0.16
private enum class LayoutId {
ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, PRICE_CHANGE, NON_FIAT_CONTENT
}
@Suppress("LongMethod")
@Composable
internal fun TokenItem(
state: TokenItemState,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
var rootWidth by remember { mutableStateOf(Int.MIN_VALUE) }
@Suppress("DestructuringDeclarationWithTooManyEntries")
BaseContainer(
CustomContainer(
state = state,
modifier = modifier
.tokenClickable(state)
.onSizeChanged { rootWidth = it.width },
.tokenClickable(state = state)
.background(color = TangemTheme.colors.background.primary),
) {
val (iconRef, titleRef, cryptoAmountRef, fiatAmountRef, priceChangeRef, nonFiatContentRef) = createRefs()
val isBalanceHidden = (state as? TokenItemState.Content)?.isBalanceHidden ?: false
TokenIcon(
state = state.iconState,
modifier = Modifier.constrainAs(iconRef) {
centerVerticallyTo(parent)
start.linkTo(parent.start)
},
)
val density = LocalDensity.current
val titleRequiredMinWidth by remember(rootWidth) {
derivedStateOf { with(density) { rootWidth.toDp().times(other = 0.22f) } }
}
TokenIcon(state = state.iconState, modifier = Modifier.layoutId(layoutId = LayoutId.ICON))
TokenTitle(
state = state.titleState,
modifier = Modifier
.layoutId(layoutId = LayoutId.TITLE)
.padding(horizontal = TangemTheme.dimens.spacing8)
.constrainAs(titleRef) {
start.linkTo(iconRef.end)
top.linkTo(parent.top)
width = Dimension.fillToConstraints.atLeast(dp = titleRequiredMinWidth)
when (state) {
is TokenItemState.Content -> end.linkTo(fiatAmountRef.start)
is TokenItemState.Draggable -> end.linkTo(nonFiatContentRef.start)
is TokenItemState.Unreachable,
is TokenItemState.NoAddress,
-> {
end.linkTo(nonFiatContentRef.start)
bottom.linkTo(parent.bottom)
}
else -> Unit
}
},
.padding(bottom = TangemTheme.dimens.spacing2),
)
TokenFiatAmount(
state = state.fiatAmountState,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.constrainAs(fiatAmountRef) {
top.linkTo(parent.top)
end.linkTo(parent.end)
width = Dimension.fillToConstraints.atMostWrapContent
if (state is TokenItemState.Content) {
start.linkTo(titleRef.end)
}
},
modifier = Modifier
.layoutId(layoutId = LayoutId.FIAT_AMOUNT)
.padding(bottom = TangemTheme.dimens.spacing2),
)
val marginBetweenRows = TangemTheme.dimens.spacing2
TokenCryptoAmount(
state = state.cryptoAmountState,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing8)
.constrainAs(cryptoAmountRef) {
start.linkTo(iconRef.end)
top.linkTo(titleRef.bottom, marginBetweenRows)
bottom.linkTo(parent.bottom)
when (state) {
is TokenItemState.Content -> {
end.linkTo(priceChangeRef.start)
width = Dimension.fillToConstraints.atMostWrapContent
}
is TokenItemState.Draggable -> {
end.linkTo(nonFiatContentRef.start)
width = Dimension.fillToConstraints
}
else -> Unit
}
},
.layoutId(layoutId = LayoutId.CRYPTO_AMOUNT)
.padding(horizontal = TangemTheme.dimens.spacing8),
)
val priceChangeRequiredMinWidth by remember(rootWidth) {
derivedStateOf { with(density) { rootWidth.toDp().times(other = 0.16f) } }
}
TokenPriceChange(
state = state.priceChangeState,
modifier = Modifier.constrainAs(priceChangeRef) {
top.linkTo(fiatAmountRef.bottom, marginBetweenRows)
end.linkTo(anchor = parent.end)
bottom.linkTo(parent.bottom)
when (state.priceChangeState) {
is TokenItemState.PriceChangeState.Content,
is TokenItemState.PriceChangeState.Unknown,
-> {
start.linkTo(cryptoAmountRef.end)
width = Dimension.fillToConstraints
.atLeast(priceChangeRequiredMinWidth)
}
else -> Unit
}
},
modifier = Modifier.layoutId(layoutId = LayoutId.PRICE_CHANGE),
)
NonFiatContentBlock(
state = state,
reorderableTokenListState = reorderableTokenListState,
modifier = Modifier.constrainAs(nonFiatContentRef) {
centerVerticallyTo(parent)
end.linkTo(parent.end)
},
)
}
}
@Composable
private inline fun BaseContainer(
modifier: Modifier = Modifier,
crossinline content: @Composable ConstraintLayoutScope.() -> Unit,
) {
Box(
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size68)
.background(color = TangemTheme.colors.background.primary),
) {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.padding(all = TangemTheme.dimens.spacing14),
content = content,
modifier = Modifier.layoutId(layoutId = LayoutId.NON_FIAT_CONTENT),
)
}
}
@ -196,47 +105,299 @@ private fun Modifier.tokenClickable(state: TokenItemState): Modifier = composed
}
}
// region preview
@Preview
/**
* IMPORTANT! All margins that used between children setup like as children paddings.
*/
@Suppress("LongMethod")
@Composable
private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val density = LocalDensity.current
val dimens = TangemTheme.dimens
Layout(content = content, modifier = modifier) { measurables, constraints ->
val layoutWidth = constraints.maxWidth
val layoutPadding = with(density) { dimens.size14.roundToPx() }
val layoutWidthWithPaddings = layoutWidth - 2 * layoutPadding
val titleMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt()
val priceChangeMinWidth = (layoutWidth * PRICE_CHANGE_MIN_WIDTH_COEFFICIENT).toInt()
val icon = measurables.measure(layoutId = LayoutId.ICON, constraints = constraints)
/*
* Title width take the whole REMAINING space.
* If FiatAmount took the whole free space, then Title will has min width.
*/
val title: Placeable
// FiatAmount width must take the whole free space but is not greater the Title min size
var fiatAmount: Placeable? = null
// CryptoAmount width must take the whole free space but is not greater the PriceChange min size
var cryptoAmount: Placeable? = null
/*
* PriceChange width take the whole REMAINING space.
* If CryptoAmount took the whole free space, then PriceChange will has min width.
*/
val priceChange: Placeable?
val nonFiatContent = measurables.measure(layoutId = LayoutId.NON_FIAT_CONTENT, constraints = constraints)
var firstRowRemainingFreeSpace: Int? = null
var secondRowRemainingFreeSpace: Int? = null
when (state) {
is TokenItemState.Content,
is TokenItemState.Loading,
is TokenItemState.Locked,
-> {
fiatAmount = measurables.measureFiatAmount(
state = state,
maxWidth = layoutWidthWithPaddings - icon.width - titleMinWidth,
defaultConstraints = constraints,
)
cryptoAmount = measurables.measureCryptoAmount(
state = state,
maxWidth = layoutWidthWithPaddings - icon.width - priceChangeMinWidth,
defaultConstraints = constraints,
)
firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - fiatAmount.width
secondRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - cryptoAmount.width
}
is TokenItemState.Draggable -> {
cryptoAmount = measurables.measureCryptoAmount(
state = state,
maxWidth = layoutWidthWithPaddings - icon.width - nonFiatContent.width,
defaultConstraints = constraints,
)
firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - nonFiatContent.width
}
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> {
firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - nonFiatContent.width
}
}
title = measurables.measureTitle(
state = state,
minWidth = titleMinWidth,
remainingFreeSpace = firstRowRemainingFreeSpace,
defaultConstraints = constraints,
)
priceChange = secondRowRemainingFreeSpace?.let {
measurables.measurePriceChange(
state = state,
minWidth = priceChangeMinWidth,
remainingFreeSpace = secondRowRemainingFreeSpace,
defaultConstraints = constraints,
)
}
val layoutHeight = calculateLayoutHeight(
state = state,
minLayoutHeight = with(density) { dimens.size68.roundToPx() },
layoutPadding = layoutPadding,
betweenRowsPadding = with(density) { dimens.size2.roundToPx() },
title = title,
fiatAmount = fiatAmount,
cryptoAmount = cryptoAmount,
priceChange = priceChange,
)
layout(width = constraints.maxWidth, height = layoutHeight) {
icon.placeRelative(x = layoutPadding, y = (layoutHeight - icon.height).div(other = 2))
title.placeRelative(
x = layoutPadding + icon.width,
y = when (state) {
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> (layoutHeight - title.height).div(other = 2)
else -> layoutPadding
},
)
cryptoAmount?.placeRelative(
x = layoutPadding + icon.width,
y = layoutHeight - cryptoAmount.height - layoutPadding,
)
fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width - layoutPadding, y = layoutPadding)
priceChange?.placeRelative(
x = layoutWidth - priceChange.width - layoutPadding,
y = layoutHeight - priceChange.height - layoutPadding,
)
nonFiatContent.placeRelative(
x = layoutWidth - nonFiatContent.width - layoutPadding,
y = (layoutHeight - nonFiatContent.height).div(other = 2),
)
}
}
}
private fun List<Measurable>.measureFiatAmount(
state: TokenItemState,
maxWidth: Int,
defaultConstraints: Constraints,
): Placeable {
return measure(
layoutId = LayoutId.FIAT_AMOUNT,
constraints = when (state) {
is TokenItemState.Content,
is TokenItemState.Draggable,
-> createConstrainsSafely(maxWidth = maxWidth)
else -> defaultConstraints
},
)
}
private fun List<Measurable>.measureCryptoAmount(
state: TokenItemState,
maxWidth: Int,
defaultConstraints: Constraints,
): Placeable {
return measure(
layoutId = LayoutId.CRYPTO_AMOUNT,
constraints = when (state) {
is TokenItemState.Content,
is TokenItemState.Draggable,
-> createConstrainsSafely(maxWidth = maxWidth)
else -> defaultConstraints
},
)
}
private fun List<Measurable>.measureTitle(
state: TokenItemState,
minWidth: Int,
remainingFreeSpace: Int,
defaultConstraints: Constraints,
): Placeable {
return measure(
layoutId = LayoutId.TITLE,
constraints = when (state) {
is TokenItemState.Content,
is TokenItemState.Draggable,
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> createDynamicConstrains(minWidth = minWidth, remainingFreeSpace = remainingFreeSpace)
else -> defaultConstraints
},
)
}
private fun List<Measurable>.measurePriceChange(
state: TokenItemState,
minWidth: Int,
remainingFreeSpace: Int,
defaultConstraints: Constraints,
): Placeable {
return measure(
layoutId = LayoutId.PRICE_CHANGE,
constraints = when (state) {
is TokenItemState.Content,
-> createDynamicConstrains(minWidth = minWidth, remainingFreeSpace = remainingFreeSpace)
else -> defaultConstraints
},
)
}
private fun List<Measurable>.measure(layoutId: LayoutId, constraints: Constraints): Placeable {
return requireNotNull(
value = firstOrNull { it.layoutId == layoutId },
lazyMessage = { "Measurables[$layoutId] is null" },
).measure(constraints)
}
private fun createDynamicConstrains(minWidth: Int, remainingFreeSpace: Int): Constraints {
return createConstrainsSafely(
minWidth = minWidth,
maxWidth = max(a = minWidth, b = remainingFreeSpace),
)
}
private fun createConstrainsSafely(
minWidth: Int = 0,
maxWidth: Int = Constraints.Infinity,
minHeight: Int = 0,
maxHeight: Int = Constraints.Infinity,
): Constraints {
return Constraints(
minWidth = minWidth.makeNotLessZero(),
maxWidth = maxWidth.makeNotLessZero(),
minHeight = minHeight.makeNotLessZero(),
maxHeight = maxHeight.makeNotLessZero(),
)
}
private fun Int.makeNotLessZero(): Int = max(a = 0, b = this)
@Suppress("LongParameterList")
private fun calculateLayoutHeight(
state: TokenItemState,
minLayoutHeight: Int,
layoutPadding: Int,
betweenRowsPadding: Int,
title: Placeable,
fiatAmount: Placeable?,
cryptoAmount: Placeable?,
priceChange: Placeable?,
): Int {
val firstColumnHeight: Int
val secondColumnHeight: Int
when (state) {
is TokenItemState.Content,
is TokenItemState.Loading,
is TokenItemState.Locked,
-> {
firstColumnHeight = 2 * layoutPadding + title.height + betweenRowsPadding + (cryptoAmount?.height ?: 0)
secondColumnHeight = 2 * layoutPadding + (fiatAmount?.height ?: 0) + betweenRowsPadding +
(priceChange?.height ?: 0)
}
is TokenItemState.Draggable,
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> {
firstColumnHeight = minLayoutHeight
secondColumnHeight = minLayoutHeight
}
}
return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight)
}
@Preview(widthDp = 360)
@Composable
private fun Preview_CustomTokenItem_InLight(@PreviewParameter(TokenItemStateProvider::class) state: TokenItemState) {
TangemTheme(isDark = false) {
TokenItem(state)
TokenItem(state = state)
}
}
@Preview
@Composable
private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = true) {
TokenItem(state)
}
}
private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItemState>(
private class TokenItemStateProvider : CollectionPreviewParameterProvider<TokenItemState>(
collection = listOf(
WalletPreviewData.tokenItemVisibleState.copy(
cryptoAmountState = TokenItemState.CryptoAmountState.Content(
text = "5,41221467146712416241274127841274174213421 MATIC",
),
),
WalletPreviewData.tokenItemVisibleState.copy(
priceChangeState = TokenItemState.PriceChangeState.Content(
valueInPercent = "31231231231231231231223123123123212312312312.0%",
type = PriceChangeType.UP,
),
),
WalletPreviewData.tokenItemVisibleState.copy(
cryptoAmountState = TokenItemState.CryptoAmountState.Content(
text = "5,41221467146712416241274127841274174213421 MATIC",
),
priceChangeState = TokenItemState.PriceChangeState.Content(
valueInPercent = "31231231231231231231223123123123212312312312.0%",
type = PriceChangeType.UP,
),
),
WalletPreviewData.tokenItemVisibleState.copy(
iconState = WalletPreviewData.coinIconState.copy(showCustomBadge = true),
titleState = TokenItemState.TitleState.Content(
text = "PolygonPolygonPolygonPolygonPolygonPolygon",
hasPending = true,
),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3213123123321312312312312312 $"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,4123123213123123123123123123 MATIC"),
priceChangeState = TokenItemState.PriceChangeState.Content(
valueInPercent = "2365723643724723423742342374623642374723472342342.0%",
type = PriceChangeType.UP,
),
),
WalletPreviewData.tokenItemUnreachableState,
WalletPreviewData.tokenItemNoAddressState,
@ -247,6 +408,4 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItem
WalletPreviewData.customTokenItemVisibleState,
WalletPreviewData.customTestnetTokenItemVisibleState,
),
)
// endregion preview
)

View file

@ -40,6 +40,7 @@ private fun ContentTitle(name: String, hasPending: Boolean, modifier: Modifier =
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing6),
verticalAlignment = Alignment.CenterVertically,
) {
/*
* If currency name has a long width, then it will completely displace the image.

View file

@ -52,7 +52,7 @@ internal sealed class TokenItemState {
override val id: String,
override val iconState: IconState,
override val titleState: TitleState,
override val fiatAmountState: FiatAmountState?,
override val fiatAmountState: FiatAmountState,
override val cryptoAmountState: CryptoAmountState.Content,
override val priceChangeState: PriceChangeState?,
val isBalanceHidden: Boolean,