Updated on 2026-08-14
This commit is contained in:
parent
0b86264bbf
commit
6a082c1d58
19 changed files with 603 additions and 118 deletions
|
|
@ -43,6 +43,7 @@ fun TangemContextMenu(
|
|||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset.Zero,
|
||||
properties: PopupProperties = PopupProperties(focusable = true),
|
||||
positionProvider: PopupPositionProvider? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val expandedStates = remember { MutableTransitionState(false) }
|
||||
|
|
@ -51,7 +52,7 @@ fun TangemContextMenu(
|
|||
if (expandedStates.currentState || expandedStates.targetState) {
|
||||
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
|
||||
val density = LocalDensity.current
|
||||
val popupPositionProvider = DropdownMenuPositionProvider(
|
||||
val popupPositionProvider = positionProvider ?: DropdownMenuPositionProvider(
|
||||
offset,
|
||||
density,
|
||||
) { parentBounds, menuBounds ->
|
||||
|
|
@ -249,6 +250,57 @@ internal data class DropdownMenuPositionProvider(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PopupPositionProvider] that centers the popup horizontally on the screen
|
||||
* and positions it below the anchor. If there is not enough space below,
|
||||
* it positions the popup above the anchor. If there is no space in either direction,
|
||||
* it reports the required vertical shift via [onAnchorShiftRequired] so the caller
|
||||
* can move the anchor upward to make room below.
|
||||
*/
|
||||
@Immutable
|
||||
class CenteredContextMenuPositionProvider(
|
||||
private val contentOffset: DpOffset,
|
||||
private val density: Density,
|
||||
private val onAnchorShiftRequired: (Int) -> Unit = {},
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset {
|
||||
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
|
||||
val x = (windowSize.width - popupContentSize.width) / 2
|
||||
|
||||
val yBelow = anchorBounds.bottom + contentOffsetY
|
||||
val yAbove = anchorBounds.top - contentOffsetY - popupContentSize.height
|
||||
|
||||
val isFitsBelow = yBelow + popupContentSize.height <= windowSize.height
|
||||
val isFitsAbove = yAbove >= 0
|
||||
|
||||
val y = when {
|
||||
isFitsBelow -> {
|
||||
onAnchorShiftRequired(0)
|
||||
yBelow
|
||||
}
|
||||
isFitsAbove -> {
|
||||
onAnchorShiftRequired(0)
|
||||
yAbove
|
||||
}
|
||||
else -> {
|
||||
// Neither fits — calculate how much the anchor must shift up
|
||||
// so the popup fits below. Place popup at bottom edge of screen.
|
||||
val desiredY = windowSize.height - popupContentSize.height
|
||||
val shift = yBelow - desiredY
|
||||
onAnchorShiftRequired(shift)
|
||||
desiredY
|
||||
}
|
||||
}
|
||||
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
package com.tangem.core.ui.ds.row.token
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
|
|
@ -110,7 +105,7 @@ fun TangemTokenRow(
|
|||
.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -198,36 +193,10 @@ fun TangemTokenRow(
|
|||
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
|
||||
)
|
||||
},
|
||||
modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun Modifier.tokenClickable(tokenRowUM: TangemTokenRowUM): Modifier = composed {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val onClick = tokenRowUM.onItemClick
|
||||
val onLongClick = tokenRowUM.onItemLongClick
|
||||
val onHapticLongClick = if (onLongClick != null) {
|
||||
{
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
when {
|
||||
onClick == null && onLongClick == null -> this
|
||||
onClick == null && onLongClick != null -> combinedClickable(onClick = {}, onLongClick = onHapticLongClick)
|
||||
onClick != null && onLongClick == null -> combinedClickable(onClick = onClick)
|
||||
onClick != null && onLongClick != null -> {
|
||||
combinedClickable(onClick = onClick, onLongClick = onHapticLongClick)
|
||||
}
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.core.ui.ds.row.token
|
|||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeState
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
|
|
@ -11,7 +12,9 @@ import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
|
|||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
sealed class TangemTokenRowUM : TangemRowUM {
|
||||
|
||||
|
|
@ -43,11 +46,12 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
abstract val onItemClick: (() -> Unit)?
|
||||
|
||||
/** Callback which will be called when an item is long clicked */
|
||||
abstract val onItemLongClick: (() -> Unit)?
|
||||
abstract val onItemLongClick: ((Offset, TangemTokenRowUM) -> Any)?
|
||||
|
||||
/**
|
||||
* Content state of [TangemTokenRowUM]
|
||||
*/
|
||||
@Serializable
|
||||
data class Content(
|
||||
override val id: String,
|
||||
override val headIconUM: TangemIconUM.Currency,
|
||||
|
|
@ -58,12 +62,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty,
|
||||
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
|
||||
override val onItemClick: (() -> Unit)?,
|
||||
override val onItemLongClick: (() -> Unit)?,
|
||||
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Any)?,
|
||||
) : TangemTokenRowUM()
|
||||
|
||||
/**
|
||||
* Loading state of [TangemTokenRowUM]
|
||||
*/
|
||||
@Serializable
|
||||
data class Loading(
|
||||
override val id: String,
|
||||
override val headIconUM: TangemIconUM.Currency = TangemIconUM.Currency(CurrencyIconState.Loading),
|
||||
|
|
@ -75,12 +80,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
|
||||
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty
|
||||
override val onItemClick: (() -> Unit)? = null
|
||||
override val onItemLongClick: (() -> Unit)? = null
|
||||
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state of [TangemTokenRowUM]
|
||||
*/
|
||||
@Serializable
|
||||
data class Empty(
|
||||
override val id: String,
|
||||
) : TangemTokenRowUM() {
|
||||
|
|
@ -92,12 +98,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
|
||||
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty
|
||||
override val onItemClick: (() -> Unit)? = null
|
||||
override val onItemLongClick: (() -> Unit)? = null
|
||||
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Actionable state of [TangemTokenRowUM]
|
||||
*/
|
||||
@Serializable
|
||||
data class Actionable(
|
||||
override val id: String,
|
||||
override val headIconUM: TangemIconUM.Currency,
|
||||
|
|
@ -105,16 +112,17 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
override val subtitleUM: SubtitleUM,
|
||||
override val tailUM: TangemRowTailUM,
|
||||
override val onItemClick: (() -> Unit)?,
|
||||
override val onItemLongClick: (() -> Unit)?,
|
||||
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)?,
|
||||
override val topEndContentUM: EndContentUM = EndContentUM.Empty,
|
||||
override val bottomEndContentUM: EndContentUM = EndContentUM.Empty,
|
||||
) : TangemTokenRowUM() {
|
||||
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
sealed class TitleUM {
|
||||
|
||||
@Serializable
|
||||
data class Content(
|
||||
val text: TextReference,
|
||||
val hasPending: Boolean = false,
|
||||
|
|
@ -123,16 +131,20 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
val onBadgeClick: (() -> Unit)? = null,
|
||||
) : TitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Loading : TitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Placeholder : TitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Empty : TitleUM()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
sealed class SubtitleUM {
|
||||
|
||||
@Serializable
|
||||
data class Content(
|
||||
val text: TextReference,
|
||||
val isAvailable: Boolean = true,
|
||||
|
|
@ -142,16 +154,20 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
val badge: TangemBadgeUM? = null,
|
||||
) : SubtitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Loading : SubtitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Placeholder : SubtitleUM()
|
||||
|
||||
@Serializable
|
||||
data object Empty : SubtitleUM()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
sealed class EndContentUM {
|
||||
|
||||
@Serializable
|
||||
data class Content(
|
||||
val text: TextReference,
|
||||
val isAvailable: Boolean = true,
|
||||
|
|
@ -161,13 +177,17 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
val priceChangeUM: PriceChangeState = PriceChangeState.Unknown,
|
||||
) : EndContentUM()
|
||||
|
||||
@Serializable
|
||||
data object Loading : EndContentUM()
|
||||
|
||||
@Serializable
|
||||
data object Placeholder : EndContentUM()
|
||||
|
||||
@Serializable
|
||||
data object Empty : EndContentUM()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
sealed class PromoBannerUM {
|
||||
data class Content(
|
||||
|
|
@ -184,6 +204,7 @@ sealed class TangemTokenRowUM : TangemRowUM {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object Empty : PromoBannerUM()
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ object TangemTokenRowPreviewData {
|
|||
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val defaultEllipsisState: TangemTokenRowUM.Content
|
||||
|
|
@ -155,7 +155,7 @@ object TangemTokenRowPreviewData {
|
|||
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val tokenState: TangemTokenRowUM.Content
|
||||
|
|
@ -169,7 +169,7 @@ object TangemTokenRowPreviewData {
|
|||
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val customTokenState: TangemTokenRowUM.Content
|
||||
|
|
@ -183,7 +183,7 @@ object TangemTokenRowPreviewData {
|
|||
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val draggableState: TangemTokenRowUM.Actionable
|
||||
|
|
@ -194,7 +194,7 @@ object TangemTokenRowPreviewData {
|
|||
subtitleUM = subtitleUM,
|
||||
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24),
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val draggableStateV2: TangemTokenRowUM.Actionable
|
||||
|
|
@ -207,7 +207,7 @@ object TangemTokenRowPreviewData {
|
|||
bottomEndContentUM = bottomEndContentUM,
|
||||
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24),
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val loadingState: TangemTokenRowUM.Loading
|
||||
|
|
@ -252,7 +252,7 @@ object TangemTokenRowPreviewData {
|
|||
priceChangeUM = priceChangeState,
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
)
|
||||
|
||||
val accountLetterState: TangemTokenRowUM.Content
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue