Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Created March 26, 2024 11:23
Show Gist options
  • Star 23 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save KlassenKonstantin/b08f0800bc1bdc010d348bb74768d1ed to your computer and use it in GitHub Desktop.
Save KlassenKonstantin/b08f0800bc1bdc010d348bb74768d1ed to your computer and use it in GitHub Desktop.
Fitbit style Pull 2 Refresh
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
setContent {
P2RTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
CompositionLocalProvider(
LocalOverscrollConfiguration provides null // Disable overscroll otherwise it consumes the drag before we get the chance
) {
val state = rememberPullState()
LaunchedEffect(state.isRefreshing) {
if (state.isRefreshing) {
delay(2000)
state.finishRefresh()
}
}
PullToRefreshLayout(
pullState = state,
) {
LazyColumn(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding()
.padding(top = state.insetTop),
contentPadding = PaddingValues(top = 16.dp)
) {
items(20) {
Card(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.height(128.dp),
shape = RoundedCornerShape(20.dp)
) {
ListItem(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(text = "") }
)
}
}
}
}
}
}
}
}
}
}
@Composable
fun PullToRefreshLayout(
modifier: Modifier = Modifier,
pullState: PullState = rememberPullState(),
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.tertiaryContainer)
.nestedScroll(pullState.scrollConnection),
) {
Indicator(pullState = pullState)
Column {
// This invisible spacer height + current top inset is always equals max top inset to keep scroll speed constant
Spacer(modifier = Modifier.height(LocalDensity.current.run { pullState.maxInsetTop.toDp() } - pullState.insetTop))
Surface(
modifier = Modifier
.offset {
IntOffset(0, pullState.offsetY.toInt())
},
color = Color.Transparent,
shape = RoundedCornerShape(
topStart = 36.dp * pullState.progressRefreshTrigger,
topEnd = 36.dp * pullState.progressRefreshTrigger,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
) {
content()
}
}
}
}
@Composable
fun Indicator(
pullState: PullState
) {
val hapticFeedback = LocalHapticFeedback.current
val scale = remember { Animatable(1f) }
// Pop the indicator once shortly when reaching refresh trigger offset. Also trigger some haptic feedback
LaunchedEffect(pullState.progressRefreshTrigger >= 1f) {
if (pullState.progressRefreshTrigger >= 1f && !pullState.isRefreshing) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
scale.snapTo(1.05f)
scale.animateTo(1.0f, tween(100))
}
}
Box(
modifier = Modifier
.statusBarsPadding()
.height(maxOf(24.dp, pullState.config.heightMax * pullState.progressHeightMax - pullState.insetTop))
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.scale(scale.value),
verticalAlignment = Alignment.CenterVertically
) {
if (pullState.isRefreshing) {
CircularProgressIndicator(
modifier = Modifier
.size(16.dp),
strokeWidth = 2.dp,
)
} else {
CircularProgressIndicator(
modifier = Modifier
.size(16.dp),
strokeWidth = 2.dp,
progress = { pullState.progressRefreshTrigger }
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
modifier = Modifier,
text = when {
pullState.isRefreshing -> "Refreshing"
pullState.progressRefreshTrigger >= 1f -> "Release to refresh"
else -> "Pull to refresh"
},
style = MaterialTheme.typography.labelLarge,
)
}
}
}
@Composable
fun rememberPullState(
config: PullStateConfig = PullStateConfig()
): PullState {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val insetTop = WindowInsets.statusBars.getTop(density)
return remember(insetTop, config, density, scope) { PullState(insetTop, config, density, scope) }
}
data class PullStateConfig(
val heightRefreshing: Dp = 90.dp,
val heightMax: Dp = 150.dp,
) {
init {
require(heightMax >= heightRefreshing)
}
}
class PullState internal constructor(
val maxInsetTop: Int,
val config: PullStateConfig,
private val density: Density,
private val scope: CoroutineScope,
) {
private val heightRefreshing = with(density) { config.heightRefreshing.toPx() }
private val heightMax = with(density) { config.heightMax.toPx() }
private val _offsetY = Animatable(0f)
val offsetY: Float get() = _offsetY.value
// 1f -> Refresh triggered on release
val progressRefreshTrigger: Float get() = (offsetY / heightRefreshing).coerceIn(0f, 1f)
// 1f -> Max drag amount reached
val progressHeightMax: Float get() = (offsetY / heightMax).coerceIn(0f, 1f)
// Use this for your content's top padding. Only relevant when app is drawing behind status bar
val insetTop: Dp get() = with(density) { (maxInsetTop - maxInsetTop * progressRefreshTrigger).toDp() }
// User drag in progress
var isDragging by mutableStateOf(false)
private set
var isRefreshing by mutableStateOf(false)
private set
var isEnabled by mutableStateOf(true)
private set
suspend fun settle(offsetY: Float) {
_offsetY.animateTo(offsetY)
}
fun finishRefresh() {
isEnabled = false
scope.launch {
settle(0f)
isRefreshing = false
isEnabled = true
}
}
val scrollConnection = object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
when {
!isEnabled -> return Offset.Zero
available.y > 0 && source == NestedScrollSource.Drag -> {
// 1. User is dragging
// 2. Scrollable container reached the top (OR max drag reached and neither scroll container nor P2R are interested. Poor available Offset...)
// 3. There is still drag available that the scrollable container did not consume
// -> Start drag. Because next frame offsetY will be > 0f, onPreScroll will take over from here
isDragging = true
scope.launch {
_offsetY.snapTo((offsetY + available.y).coerceIn(0f, heightMax))
}
}
}
return Offset.Zero
}
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
when {
!isEnabled -> return Offset.Zero
offsetY > 0 && source == NestedScrollSource.Drag -> {
// Consumes the drag as long as the indicator is visible
isDragging = true
val newOffset = offsetY + available.y
// Surplus drag amount is not consumed
val remaining = when {
newOffset > heightMax -> newOffset - heightMax
newOffset < 0f -> newOffset
else -> 0f
}
scope.launch {
_offsetY.snapTo(newOffset.coerceIn(0f, heightMax))
}
return Offset(0f, (available.y - remaining))
}
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (!isEnabled) return Velocity.Zero
isDragging = false
when {
// When refreshing and a drag stops, either settle to 0f or heightRefreshing,
isRefreshing -> {
val target = when {
heightRefreshing - offsetY < heightRefreshing / 2 -> heightRefreshing
else -> 0f
}
scope.launch {
settle(target)
}
// Consume the velocity as long as the indicator is visible
return if (offsetY == 0f) Velocity.Zero else available
}
// Trigger refresh
offsetY >= heightRefreshing -> {
isRefreshing = true
scope.launch {
settle(heightRefreshing)
}
}
// Drag cancelled, go back to 0f
else -> {
scope.launch {
settle(0f)
}
}
}
return Velocity.Zero
}
}
}
@PMARZV
Copy link

PMARZV commented Apr 7, 2024

Is it better if instead of using the offset, scale and shape modifiers, we use the graphics layer versions of these modifiers??

@KlassenKonstantin
Copy link
Author

Absolutely! It's just, if I don't see performance issues, I prefer to use the more convenient tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment