Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-30 14:20:20 +03:00
parent f26ad91361
commit c0fa00479b
8 changed files with 170 additions and 97 deletions

View file

@ -117,7 +117,6 @@ fun TangemHeaderRow(
* @param modifier Modifier for the composable
* @param subtitle Optional subtitle as a TextReference
* @param headTangemIconUM Optional TangemIconUM for the head icon
* @param footerTangemIconRes Optional drawable resource ID for the footer icon
* @param isEnabled Boolean indicating if the row is clickable
* @param onItemClick Optional click callback for the row
*/

View file

@ -171,7 +171,9 @@ fun TangemTopBar(
if (reserveSlotSpace || endContent != null) {
AnimatedContent(
targetState = endContent != null,
modifier = Modifier.size(TangemTheme.dimens2.x11),
modifier = Modifier
.height(TangemTheme.dimens2.x11)
.widthIn(min = TangemTheme.dimens2.x11),
label = "End Content Visibility",
) { isVisible ->
if (isVisible) {

View file

@ -17,7 +17,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.topbar.collapsing.entity.TangemCollapsingAppBarState
import com.tangem.core.ui.ds.topbar.collapsing.entity.TopBapScrollDirection
import com.tangem.core.ui.ds.topbar.collapsing.entity.TopBarScrollDirection
import com.tangem.core.ui.ds.topbar.collapsing.entity.rememberTangemCollapsingAppBarState
import com.tangem.core.ui.utils.toPx
import kotlin.math.abs
@ -37,6 +37,7 @@ import kotlin.math.absoluteValue
*/
@Composable
fun rememberTangemExitUntilCollapsedScrollBehavior(
isTopOverscrollEnabled: Boolean = true,
expandedHeight: Dp = -Int.MAX_VALUE.dp,
partialCollapsedHeight: Dp = expandedHeight,
snapAnimationSpec: AnimationSpec<Float>? = spring(),
@ -45,6 +46,7 @@ fun rememberTangemExitUntilCollapsedScrollBehavior(
val topBarState = rememberTangemCollapsingAppBarState(
heightOffsetLimit = -expandedHeight.toPx(),
partialHeightLimit = partialCollapsedHeight.toPx(),
isTopOverscrollEnabled = isTopOverscrollEnabled,
)
return exitUntilCollapsedScrollBehavior(
state = topBarState,
@ -76,7 +78,7 @@ private fun exitUntilCollapsedScrollBehavior(
val dy = available.y
val consume = if (dy < 0) {
state.direction = TopBapScrollDirection.Collapsing
state.direction = TopBarScrollDirection.Collapsing
state.dispatchRawDelta(dy)
} else {
0f
@ -89,16 +91,57 @@ private fun exitUntilCollapsedScrollBehavior(
val dy = available.y
val consume = if (dy > 0) {
state.direction = TopBapScrollDirection.Expanding
state.direction = TopBarScrollDirection.Expanding
state.dispatchRawDelta(dy)
} else {
state.direction = TopBapScrollDirection.Collapsing
state.direction = TopBarScrollDirection.Collapsing
0f
}
return Offset(0f, consume)
}
@Suppress("MagicNumber")
override suspend fun onPreFling(available: Velocity): Velocity {
val vy = available.y
// Only handle upward fling (collapsing)
if (vy >= 0f) return Velocity.Zero
val effectiveLimit = if (state.isTopOverscrollEnabled) {
state.heightOffsetLimit + state.partialHeightLimit
} else {
state.heightOffsetLimit
}
// Already at the collapse limit — nothing to consume
if (state.heightOffset <= effectiveLimit) return Velocity.Zero
state.direction = TopBarScrollDirection.Collapsing
var remainingVelocity = vy
if (flingAnimationSpec != null) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = vy,
).animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val prevOffset = state.heightOffset
state.heightOffset =
(prevOffset + delta).coerceAtLeast(effectiveLimit)
val consumed = abs(prevOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// Stop when the bar can't collapse any further
if (consumed < 0.5f && abs(delta) > 0.5f) {
cancelAnimation()
}
}
}
return Velocity(0f, available.y - remainingVelocity)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val superConsumed = super.onPostFling(consumed, available)
return superConsumed + settleAppBar(
@ -179,7 +222,7 @@ private suspend fun settleAppBar(
val availableDelta = partialLimit - initialHeightOffset
state.heightOffset = if (delta < 0f && initialHeightOffset > partialLimit) {
state.heightOffset = if (delta < 0f && initialHeightOffset >= partialLimit) {
(initialHeightOffset + delta).coerceAtLeast(partialLimit)
} else {
initialHeightOffset + delta
@ -196,17 +239,17 @@ private suspend fun settleAppBar(
if (snapAnimationSpec != null && state.heightOffset > partialLimit && state.heightOffset < 0f) {
AnimationState(initialValue = state.heightOffset).animateTo(
when (state.direction) {
TopBapScrollDirection.Collapsing -> if (state.collapsedFraction > snapCollapseThreshold) {
TopBarScrollDirection.Collapsing -> if (state.collapsedFraction > snapCollapseThreshold) {
partialLimit
} else {
0f
}
TopBapScrollDirection.Expanding -> if (state.collapsedFraction < snapExpandThreshold) {
TopBarScrollDirection.Expanding -> if (state.collapsedFraction < snapExpandThreshold) {
0f
} else {
partialLimit
}
TopBapScrollDirection.Idle -> 0f
TopBarScrollDirection.Idle -> 0f
},
animationSpec = snapAnimationSpec,
) {

View file

@ -30,6 +30,7 @@ class TangemCollapsingAppBarState(
val initialHeightOffset: Float = 0f,
val heightOffsetLimit: Float = 0f,
val partialHeightLimit: Float = heightOffsetLimit,
var isTopOverscrollEnabled: Boolean = true,
) : ScrollableState {
private val _heightOffset = mutableFloatStateOf(initialHeightOffset)
@ -42,8 +43,7 @@ class TangemCollapsingAppBarState(
var heightOffset: Float
get() = _heightOffset.floatValue
set(newOffset) {
_heightOffset.floatValue =
newOffset.coerceIn(minimumValue = heightOffsetLimit, maximumValue = 0f)
_heightOffset.floatValue = newOffset.coerceIn(minimumValue = heightOffsetLimit, maximumValue = 0f)
}
/**
@ -60,11 +60,13 @@ class TangemCollapsingAppBarState(
/**
* The current scroll direction of the app bar, which can be Collapsing, Expanding, or Idle.
*/
var direction: TopBapScrollDirection = TopBapScrollDirection.Idle
var direction: TopBarScrollDirection = TopBarScrollDirection.Idle
private val scrollableState = ScrollableState { value ->
val effectiveLimit = if (isTopOverscrollEnabled) heightOffsetLimit + partialHeightLimit else heightOffsetLimit
val consume = if (value < 0) {
max(heightOffsetLimit - heightOffset, value)
// Already at or past the effective limit — don't consume collapsing scroll
if (heightOffset <= effectiveLimit) 0f else max(effectiveLimit - heightOffset, value)
} else {
min(0f - heightOffset, value)
}
@ -104,12 +106,20 @@ class TangemCollapsingAppBarState(
/** The default [Saver] implementation for [TangemCollapsingAppBarState]. */
val Saver: Saver<TangemCollapsingAppBarState, *> =
listSaver(
save = { state -> listOf(state.heightOffsetLimit, state.heightOffset, state.partialHeightLimit) },
save = { state ->
listOf(
state.heightOffsetLimit,
state.heightOffset,
state.partialHeightLimit,
state.isTopOverscrollEnabled,
)
},
restore = { state ->
TangemCollapsingAppBarState(
heightOffsetLimit = state[0],
partialHeightLimit = state[2],
initialHeightOffset = state[1],
heightOffsetLimit = state[0] as Float,
initialHeightOffset = state[1] as Float,
partialHeightLimit = state[2] as Float,
isTopOverscrollEnabled = state[3] as Boolean,
)
},
)
@ -121,6 +131,7 @@ class TangemCollapsingAppBarState(
*/
@Composable
fun rememberTangemCollapsingAppBarState(
isTopOverscrollEnabled: Boolean = true,
heightOffsetLimit: Float = -Float.MAX_VALUE,
partialHeightLimit: Float = -Float.MAX_VALUE,
initialHeightOffset: Float = 0f,
@ -130,13 +141,16 @@ fun rememberTangemCollapsingAppBarState(
initialHeightOffset = initialHeightOffset,
partialHeightLimit = partialHeightLimit,
heightOffsetLimit = heightOffsetLimit,
isTopOverscrollEnabled = isTopOverscrollEnabled,
)
}.also {
it.isTopOverscrollEnabled = isTopOverscrollEnabled
}
}
/**
* The scroll direction of the top app bar, which can be Collapsing, Expanding, or Idle.
*/
enum class TopBapScrollDirection {
enum class TopBarScrollDirection {
Collapsing, Expanding, Idle
}

View file

@ -37,13 +37,13 @@ internal class TangemPayWalletSelectorModel @Inject constructor(
onWalletClick = { params.listener.onWalletSelected(it) },
)
val uiState: StateFlow<WalletSelectorBSContentUM>
field = MutableStateFlow(getInitialState())
init {
fetchUserWalletsUM()
}
val uiState: StateFlow<WalletSelectorBSContentUM>
field = MutableStateFlow(getInitialState())
private fun getInitialState(): WalletSelectorBSContentUM {
return WalletSelectorBSContentUM(
userWallets = persistentListOf(),

View file

@ -76,20 +76,17 @@ internal class WalletTokensListUMConverter(
onEmptyClick = { clickIntents.onManageTokensClick(value.mainAccount.accountId) },
)
} else {
val isCollapsable = value.accountStatuses.count {
it is AccountStatus.CryptoPortfolio && it.account.tokensCount > 0
} > 1
val tokenListUM = value.accountStatuses
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.asSequence()
.flatMap { accountStatus ->
if (isAccountsModeEnabled) {
val isCollapsable = accountStatus.tokenList.flattenCurrencies().isNotEmpty()
val isExpanded = expandedAccounts.contains(accountStatus.account.accountId)
sequenceOf(
TokensListItemUM2.Portfolio(
tokenRowUM = accountRowConverter.convert(accountStatus),
isExpanded = isExpanded || !isCollapsable,
isExpanded = isExpanded,
isCollapsable = isCollapsable,
onEmptyClick = { clickIntents.onManageTokensClick(accountStatus.account.accountId) },
tokenList = getTokenListItems(
@ -166,7 +163,7 @@ internal class WalletTokensListUMConverter(
return if (accountList.flattenCurrencies().size > 1 && !selectedWallet.isSingleWalletWithToken()) {
TangemButtonUM(
text = resourceReference(R.string.organize_tokens_title),
isEnabled = accountList.totalFiatBalance is TotalFiatBalance.Loading,
isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
type = TangemButtonType.PrimaryInverse,

View file

@ -58,6 +58,7 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.*
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData
import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM
@ -95,12 +96,28 @@ internal fun WalletScreen2(
pageCount = { state.wallets2.size },
)
val listStates = rememberSaveable(saver = lazyListStateMapSaver(walletsPagerState.pageCount)) {
mutableMapOf<Int, LazyListState>().apply {
repeat(walletsPagerState.pageCount) { index -> put(index, LazyListState()) }
}
}
val isTopOverscrollEnabled by remember {
derivedStateOf {
val listState = listStates[walletsPagerState.currentPage] ?: return@derivedStateOf false
listState.layoutInfo.totalItemsCount > 0 &&
!listState.canScrollBackward && !listState.canScrollForward ||
listState.canScrollBackward && !listState.canScrollForward
}
}
val partialCollapsedHeight = 64.dp + statusBarHeight
val balanceBlockHeight = 320.dp + partialCollapsedHeight
val behavior = rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight = balanceBlockHeight,
partialCollapsedHeight = partialCollapsedHeight,
snapAnimationSpec = spring(stiffness = Spring.StiffnessMedium),
isTopOverscrollEnabled = isTopOverscrollEnabled,
)
val coroutineScope = rememberCoroutineScope()
@ -113,6 +130,7 @@ internal fun WalletScreen2(
bottomSheetContent = bottomSheetContent,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
listStates = listStates,
)
WalletEventEffect(
@ -135,6 +153,7 @@ private fun WalletContent2(
walletsPagerState: PagerState,
tangemPayComponent: TangemPayMainBlockComponent,
behavior: TangemCollapsingAppBarBehavior,
listStates: Map<Int, LazyListState>,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
@ -174,12 +193,6 @@ private fun WalletContent2(
}
}
val listStates = rememberSaveable(saver = lazyListStateMapSaver(walletsPagerState.pageCount)) {
mutableMapOf<Int, LazyListState>().apply {
repeat(walletsPagerState.pageCount) { index -> put(index, LazyListState()) }
}
}
val canPagerScroll by remember { derivedStateOf { behavior.state.heightOffset == 0f } }
val pullToRefreshState = rememberPullToRefreshState()
@ -220,7 +233,8 @@ private fun WalletContent2(
LaunchedEffect(walletsPagerState.currentPage, currentWallet.walletsBalanceUM) {
if (walletsPagerState.currentPage == currentWalletIndex) {
walletBalance = (currentWallet.walletsBalanceUM as? WalletBalanceUM.Content)?.balanceInAppBar
walletBalance =
(currentWallet.walletsBalanceUM as? WalletBalanceUM.Content)?.balanceInAppBar
}
}
LaunchedEffect(walletsPagerState.currentPage, currentWallet.pullToRefreshConfig) {
@ -240,39 +254,45 @@ private fun WalletContent2(
val pageSlideAlpha by rememberPageAlpha(walletsPagerState, currentWalletIndex)
TangemPullToRefreshSlidingContainer(
state = pullToRefreshState,
config = currentWallet.pullToRefreshConfig,
modifier = Modifier.alpha(pageSlideAlpha),
indicatorOffset = with(LocalDensity.current) {
behavior.state.partialHeightLimit.toDp()
},
TangemSharedTransitionLayout(
modifier = Modifier
.fillMaxSize()
.alpha(pageSlideAlpha),
) {
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
WalletBalance(
behavior = behavior,
walletBalanceUM = currentWallet.walletsBalanceUM,
buttons = currentWallet.buttons,
isBalanceHidden = state.isHidingMode,
)
TangemPullToRefreshSlidingContainer(
state = pullToRefreshState,
config = currentWallet.pullToRefreshConfig,
indicatorOffset = with(LocalDensity.current) {
behavior.state.partialHeightLimit.toDp()
},
body = {
WalletListContent(
currentWallet = currentWallet,
listState = listState,
isBalanceHidden = state.isHidingMode,
tangemPayComponent = tangemPayComponent,
contentPadding = contentPadding,
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
)
},
)
) {
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
WalletBalance(
behavior = behavior,
walletBalanceUM = currentWallet.walletsBalanceUM,
buttons = currentWallet.buttons,
isBalanceHidden = state.isHidingMode,
)
},
body = {
WalletListContent(
currentWallet = currentWallet,
listState = listState,
isBalanceHidden = state.isHidingMode,
contentPadding = contentPadding,
tangemPayComponent = tangemPayComponent,
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
)
},
)
}
val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
val peekHeight =
bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
MarketsHint(
modifier = Modifier
.align(Alignment.BottomCenter)

View file

@ -15,7 +15,6 @@ import com.tangem.common.ui.notifications.notificationsCarousel
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.tokensListItems
@ -40,41 +39,40 @@ internal fun WalletListContent(
val movableItemModifier = Modifier.padding(horizontal = TangemTheme.dimens2.x3)
val itemModifier = movableItemModifier.padding(top = TangemTheme.dimens2.x3)
TangemSharedTransitionLayout(modifier) {
LazyColumn(
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
overscrollEffect = rememberOverscrollEffect(),
) {
notifications(
notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(),
contentColor = containerColor,
modifier = movableItemModifier,
)
notificationsCarousel(
containerColor = containerColor,
modifier = movableItemModifier,
notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(),
)
LazyColumn(
modifier = modifier,
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
overscrollEffect = rememberOverscrollEffect(),
) {
notifications(
notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(),
contentColor = containerColor,
modifier = movableItemModifier,
)
notificationsCarousel(
containerColor = containerColor,
modifier = movableItemModifier,
notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(),
)
tangemPay(
tangemPayComponent = tangemPayComponent,
tangemPayUM = currentWallet.tangemPayMainUM,
isBalanceHidden = isBalanceHidden,
modifier = itemModifier,
)
tangemPay(
tangemPayComponent = tangemPayComponent,
tangemPayUM = currentWallet.tangemPayMainUM,
isBalanceHidden = isBalanceHidden,
modifier = itemModifier,
)
tokensListItems2(
walletTokensListUM = currentWallet.tokensListUM,
modifier = movableItemModifier,
isBalanceHidden = isBalanceHidden,
)
tokensListItems2(
walletTokensListUM = currentWallet.tokensListUM,
modifier = movableItemModifier,
isBalanceHidden = isBalanceHidden,
)
nftCollections2(state = currentWallet, itemModifier = itemModifier)
nftCollections2(state = currentWallet, itemModifier = itemModifier)
organizeTokens2(state = currentWallet, itemModifier = itemModifier)
}
organizeTokens2(state = currentWallet, itemModifier = itemModifier)
}
}