Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-10 17:14:20 +05:00
parent dbacd5b2a6
commit 14b3b93cf4
7 changed files with 287 additions and 58 deletions

View file

@ -1,20 +1,32 @@
package com.tangem.core.ui.components.containers.pullToRefresh
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlin.math.ln
/**
* A composable function that provides a pull-to-refresh container using Material3's PullToRefreshBox.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TangemPullToRefreshContainer(
@ -45,6 +57,114 @@ fun TangemPullToRefreshContainer(
}
}
/**
* A composable function that provides a pull-to-refresh container that slides the content down
* to reveal a progress indicator, then slides it back up when refreshing completes.
*
* The indicator appears during the pull gesture (driven by drag distance) and remains visible
* while refreshing is in progress.
*
* @param config Pull-to-refresh configuration (isRefreshing, onRefresh).
* @param modifier Modifier applied to the outer container.
* @param indicatorOffset Additional offset for the indicator block position.
* @param content The content to display.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TangemPullToRefreshSlidingContainer(
config: PullToRefreshConfig,
modifier: Modifier = Modifier,
state: PullToRefreshState = rememberPullToRefreshState(),
indicatorOffset: Dp = 0.dp,
content: @Composable BoxScope.() -> Unit,
) {
val indicatorSize = 24.dp
val contentOffset = getPullToRefreshIndicatorOffset(
pullToRefreshConfig = config,
pullToRefreshState = state,
)
PullToRefreshBox(
isRefreshing = config.isRefreshing,
onRefresh = {
config.onRefresh(PullToRefreshConfig.ShowRefreshState())
},
state = state,
modifier = modifier,
indicator = {},
) {
Box(modifier = Modifier.fillMaxSize()) {
// Content slides down
Box(
modifier = Modifier
.fillMaxSize()
.offset(y = contentOffset),
) {
content()
}
// Indicator block slides in from above
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(contentOffset)
.offset(y = indicatorOffset),
) {
if (contentOffset > 0.dp) {
if (config.isRefreshing) {
// Indeterminate spinner while refreshing
CircularProgressIndicator(
modifier = Modifier.size(indicatorSize),
color = TangemTheme.colors2.graphic.neutral.primary,
strokeWidth = 2.dp,
)
} else {
// Determinate arc driven by pull fraction
CircularProgressIndicator(
progress = { state.distanceFraction.coerceAtLeast(0f).coerceIn(0f, 1f) },
modifier = Modifier.size(indicatorSize),
color = TangemTheme.colors2.graphic.neutral.primary,
trackColor = Color.Transparent,
strokeWidth = 2.dp,
)
}
}
}
}
}
}
/**
* Calculates the vertical offset for the pull-to-refresh indicator
* based on the current pull state and refreshing status.
*/
@Composable
fun getPullToRefreshIndicatorOffset(
pullToRefreshConfig: PullToRefreshConfig?,
pullToRefreshState: PullToRefreshState,
): Dp {
val indicatorBlockHeight = 56.dp
val maxOverscroll = 24.dp
val refreshingOffset by animateDpAsState(
targetValue = if (pullToRefreshConfig?.isRefreshing == true) indicatorBlockHeight else 0.dp,
animationSpec = tween(durationMillis = 300),
label = "SlidingContentOffset",
)
// Drag-driven offset with overscroll: linear up to indicatorBlockHeight,
// then dampened logarithmic curve beyond for a rubber-band effect
val fraction = pullToRefreshState.distanceFraction.coerceAtLeast(0f)
val dragOffset = if (fraction <= 1f) {
indicatorBlockHeight * fraction
} else {
val overscrollFraction = ln(1f + (fraction - 1f)) / ln(2f) // dampened curve
indicatorBlockHeight + maxOverscroll * overscrollFraction.coerceAtMost(1f)
}
// Use the larger of the two so the transition from drag → refreshing is seamless
return maxOf(dragOffset, refreshingOffset)
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@ -55,10 +175,31 @@ private fun TangemPullToRefreshContainer_Preview() {
config = PullToRefreshConfig(isRefreshing = true, {}),
) {
Box(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
)
}
}
}
// endregion
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPullToRefreshSlidingContainer_Preview() {
TangemThemePreviewRedesign {
TangemPullToRefreshSlidingContainer(
config = PullToRefreshConfig(isRefreshing = true, {}),
indicatorOffset = 56.dp,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1),
)
}
}
}
// endregion

View file

@ -2,15 +2,17 @@ package com.tangem.core.ui.ds.topbar.collapsing
import androidx.compose.animation.core.*
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
@ -121,20 +123,24 @@ private fun exitUntilCollapsedScrollBehavior(
@Composable
fun Modifier.snapToExitUntilCollapsed(behavior: TangemCollapsingAppBarBehavior): Modifier {
return draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
behavior.state.heightOffset += delta
},
onDragStopped = { velocity ->
settleAppBar(
state = behavior.state,
velocity = velocity,
flingAnimationSpec = behavior.flingAnimationSpec,
snapAnimationSpec = behavior.snapAnimationSpec,
)
},
)
return nestedScroll(behavior.nestedScrollConnection)
.scrollable(
orientation = Orientation.Vertical,
state = behavior.state,
flingBehavior = remember(behavior) {
object : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
val consumed = settleAppBar(
state = behavior.state,
velocity = initialVelocity,
flingAnimationSpec = behavior.flingAnimationSpec,
snapAnimationSpec = behavior.snapAnimationSpec,
)
return initialVelocity - consumed.y
}
}
},
)
}
/**

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.child.wallet.model.intents
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.onramp.FetchHotCryptoUseCase
@ -15,6 +16,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
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.state.transformers.SetRefreshStateTransformer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@ -41,6 +43,7 @@ internal class WalletClickIntents @Inject constructor(
private val onrampStatusFactory: OnrampStatusFactory,
private val tangemPayIntents: TangemPayClickIntentsImplementor,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
private val designFeatureToggles: DesignFeatureToggles,
) : BaseWalletClickIntents(),
WalletCardClickIntents by walletCardClickIntentsImplementor,
WalletWarningsClickIntents by warningsClickIntentsImplementer,
@ -86,17 +89,24 @@ internal class WalletClickIntents @Inject constructor(
}
fun onRefreshSwipe(showRefreshState: Boolean) {
when (stateController.getSelectedWallet()) {
is WalletState.MultiCurrency.Content -> {
refreshMultiCurrencyContent(showRefreshState)
if (designFeatureToggles.isRedesignEnabled) {
when (stateController.getSelectedWalletUM()) {
is WalletUM.Content -> refreshMultiCurrencyContent(showRefreshState)
is WalletUM.Locked -> Unit
}
is WalletState.SingleCurrency.Content,
-> {
refreshSingleCurrencyContent(showRefreshState)
} else {
when (stateController.getSelectedWallet()) {
is WalletState.MultiCurrency.Content -> {
refreshMultiCurrencyContent(showRefreshState)
}
is WalletState.SingleCurrency.Content,
-> {
refreshSingleCurrencyContent(showRefreshState)
}
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
-> Unit
}
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
-> Unit
}
}

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
@ -42,6 +43,7 @@ import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.background.northernlights.NorthernLightsBackground
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
@ -130,6 +132,11 @@ private fun WalletContent2(
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
var walletBalance by remember { mutableStateOf<TextReference?>(TextReference.EMPTY) }
var pullToRefreshConfig by remember {
mutableStateOf(
state.wallets2.getOrNull(state.selectedWalletIndex)?.pullToRefreshConfig,
)
}
BaseScaffoldWithMarkets(
state = state,
@ -164,6 +171,8 @@ private fun WalletContent2(
val canPagerScroll by remember { derivedStateOf { behavior.state.heightOffset == 0f } }
val pullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.fillMaxSize()
@ -180,6 +189,8 @@ private fun WalletContent2(
WalletPagerIndicator(
pagerState = walletsPagerState,
pullToRefreshState = pullToRefreshState,
pullToRefreshConfig = pullToRefreshConfig,
behavior = behavior,
)
@ -199,6 +210,11 @@ private fun WalletContent2(
walletBalance = (currentWallet.walletsBalanceUM as? WalletBalanceUM.Content)?.balanceInAppBar
}
}
LaunchedEffect(walletsPagerState.currentPage, currentWallet.pullToRefreshConfig) {
if (walletsPagerState.currentPage == currentWalletIndex) {
pullToRefreshConfig = currentWallet.pullToRefreshConfig
}
}
val isShowMarketsHint by remember {
derivedStateOf {
@ -211,8 +227,13 @@ private fun WalletContent2(
val pageSlideAlpha by rememberPageAlpha(walletsPagerState, currentWalletIndex)
Box(
TangemPullToRefreshSlidingContainer(
state = pullToRefreshState,
config = currentWallet.pullToRefreshConfig,
modifier = Modifier.alpha(pageSlideAlpha),
indicatorOffset = with(LocalDensity.current) {
behavior.state.partialHeightLimit.toDp()
},
) {
TangemCollapsingTopBar(
state = behavior.state,

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
@ -75,7 +76,7 @@ internal fun WalletBalance(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
.padding(vertical = 58.dp),
) {
Balance(
walletBalanceUM = walletBalanceUM,
@ -147,7 +148,6 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean,
text = "123456",
style = TangemTheme.typography2.titleRegular44,
radius = TangemTheme.dimens2.x25,
textSizeHeight = true,
)
}
}
@ -194,6 +194,7 @@ private fun WalletBalance_Preview(@PreviewParameter(WalletBalancePreviewProvider
behavior = rememberTangemExitUntilCollapsedScrollBehavior(),
buttons = WalletPreviewData.actionButtons,
isBalanceHidden = false,
modifier = Modifier.background(TangemTheme.colors2.surface.level1),
)
}
}

View file

@ -1,49 +1,71 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.getPullToRefreshIndicatorOffset
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
private const val MIN_SCALE = 0.75f
private const val MAX_SCALE = 1f
private const val WALLET_INDICATOR_OFFSET = 0.63f
@Composable
internal fun WalletPagerIndicator(pagerState: PagerState, behavior: TangemCollapsingAppBarBehavior) {
internal fun WalletPagerIndicator(
pagerState: PagerState,
behavior: TangemCollapsingAppBarBehavior,
pullToRefreshConfig: PullToRefreshConfig?,
pullToRefreshState: PullToRefreshState,
) {
val collapsedFraction = behavior.state.collapsedFraction
val alpha = MAX_SCALE - collapsedFraction
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
val height = with(LocalDensity.current) {
behavior.state.heightOffsetLimit.toDp().unaryMinus()
}
Box(
modifier = Modifier
.graphicsLayer {
scaleY = scale
translationY = behavior.state.heightOffset
}
.fillMaxWidth()
.height(
with(LocalDensity.current) {
behavior.state.heightOffsetLimit.toDp().unaryMinus()
},
)
.alpha(alpha),
val contentOffset = getPullToRefreshIndicatorOffset(
pullToRefreshConfig = pullToRefreshConfig,
pullToRefreshState = pullToRefreshState,
)
val padding = height * WALLET_INDICATOR_OFFSET
AnimatedVisibility(
visible = pagerState.pageCount > 1,
enter = fadeIn(),
exit = fadeOut(),
) {
TangemPagerIndicator(
pagerState = pagerState,
Box(
modifier = Modifier
.padding(top = 248.dp)
.scale(scaleY = 1f, scaleX = scale)
.fillMaxWidth(),
)
.graphicsLayer {
scaleY = scale
translationY = behavior.state.heightOffset + contentOffset.toPx()
}
.fillMaxWidth()
.height(height)
.alpha(alpha),
) {
TangemPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.padding(top = padding)
.scale(scaleY = 1f, scaleX = scale)
.fillMaxWidth(),
)
}
}
}

View file

@ -1,17 +1,24 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
@ -55,11 +62,32 @@ internal fun WalletTopBar(
TangemTopBar(
title = wrappedBalance,
startAction = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
isActionable = false,
),
endActions = topBarConfig.endActions,
startContent = {
TangemTopBarActionContent(
TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
isActionable = false,
),
)
},
endContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5),
modifier = Modifier
.clip(CircleShape)
.background(
lerp(
start = Color.Transparent,
stop = TangemTheme.colors2.button.backgroundSecondary,
fraction = behavior.state.collapsedFraction,
),
),
) {
topBarConfig.endActions.forEach { action ->
TangemTopBarActionContent(action)
}
}
},
modifier = Modifier
.statusBarsPadding()
.testTag(MainScreenTestTags.TOP_BAR),
@ -134,7 +162,7 @@ private fun WalletTopBar_WithQrButton_Preview() {
topBarConfig = WalletTopBarConfig(
endActions = persistentListOf(
TangemTopBarActionUM(
iconRes = com.tangem.core.ui.R.drawable.ic_qrcode_scaner_24,
iconRes = R.drawable.ic_qrcode_scaner_24,
onClick = {},
),
TangemTopBarActionUM(