Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Created March 26, 2024 11:23
Show Gist options
  • Select an option

  • Save KlassenKonstantin/b08f0800bc1bdc010d348bb74768d1ed to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown

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
Copy Markdown
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