Updated on 2026-08-14
This commit is contained in:
parent
dbacd5b2a6
commit
14b3b93cf4
7 changed files with 287 additions and 58 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue