Created
March 26, 2024 11:23
-
-
Save KlassenKonstantin/b08f0800bc1bdc010d348bb74768d1ed to your computer and use it in GitHub Desktop.
Fitbit style Pull 2 Refresh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Absolutely! It's just, if I don't see performance issues, I prefer to use the more convenient tools