Last active
June 2, 2024 14:50
-
-
Save sagar-viradiya/b52951bebd8b52b87063ba742f3c8d2e to your computer and use it in GitHub Desktop.
An attampt to implement Threads app like path animation on pull to refresh in Jetpack Compose
This file contains 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
@OptIn(ExperimentalMaterialApi::class) | |
@Composable | |
fun PullToRefreshAnimation() { | |
val path = remember { | |
GitHubLogoPath.path.toPath() | |
} | |
val lines = remember { | |
path.asAndroidPath().flatten(error = 0.5f).toList() | |
} | |
var isRefreshing by remember { mutableStateOf(false) } | |
val scope = rememberCoroutineScope() | |
val pullRefreshState = rememberPullRefreshState( | |
refreshing = isRefreshing, | |
refreshThreshold = 50.dp, | |
onRefresh = { | |
scope.launch { | |
isRefreshing = true | |
// Mimic refresh | |
delay(9000) | |
isRefreshing = false | |
} | |
}) | |
// Offset Y animation of logo while dragging down | |
val offsetYAnimation by animateIntAsState(targetValue = when { | |
isRefreshing -> 50 | |
pullRefreshState.progress in 0f..1f -> (50 * pullRefreshState.progress).roundToInt() | |
pullRefreshState.progress > 1f -> (50 + ((pullRefreshState.progress - 1f) * .1f) * 100).roundToInt() | |
else -> 0 | |
}) | |
// Alpha animation of logo while dragging down | |
val alphaAnimation by animateFloatAsState(targetValue = when { | |
isRefreshing -> 0.3f | |
(1 - pullRefreshState.progress * 10) > 0.3f -> 1 - pullRefreshState.progress * 10 | |
(1 - pullRefreshState.progress * 10) < 0.3f -> 0.3f | |
else -> 1f | |
}) | |
// Scale animation of logo while dragging down | |
val scaleAnimation by animateFloatAsState(targetValue = when { | |
isRefreshing -> 1.2f | |
pullRefreshState.progress + 1 > 1.2f -> 1.2f | |
pullRefreshState.progress + 1 < 1.2f -> pullRefreshState.progress + 1 | |
else -> 1f | |
}) | |
// State to hold pull is completed (true) or not (false) | |
val pullCompleted by remember { | |
derivedStateOf { | |
(lines.size * (pullRefreshState.progress - 0.15f)).toInt() - 10 > lines.size | |
} | |
} | |
// Animatable to scale up and down the logo when pull is completed | |
val scaleAnimationOnPullCompleted = remember { | |
Animatable(initialValue = 1f) | |
} | |
val hapticFeedback = LocalHapticFeedback.current | |
LaunchedEffect(pullCompleted) { | |
if (pullCompleted) { | |
// Perform logo scale up and scale down animations | |
scaleAnimationOnPullCompleted.animateTo(1.17f) | |
scaleAnimationOnPullCompleted.animateTo(1f) | |
// Give haptic feedback to user to let them know threshold of the pull | |
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) | |
} | |
} | |
// Perform path animation running back and forth until refresh finished | |
val pathBackAndForthAnimationOnRefreshing = remember { | |
Animatable(initialValue = 0f) | |
} | |
LaunchedEffect(isRefreshing) { | |
if (isRefreshing) { | |
pathBackAndForthAnimationOnRefreshing.animateTo( | |
targetValue = 1f, | |
animationSpec = infiniteRepeatable(tween(2000), repeatMode = RepeatMode.Reverse) | |
) | |
} else { | |
pathBackAndForthAnimationOnRefreshing.snapTo(0f) | |
} | |
} | |
Box( | |
modifier = Modifier.pullRefresh(state = pullRefreshState) | |
) { | |
// Draw GitHub logo | |
Canvas( | |
modifier = Modifier | |
.size(44.dp) | |
.align(Alignment.TopCenter) | |
.padding(vertical = 24.dp) | |
.graphicsLayer { | |
// Apply all animation values while dragging down | |
translationY = offsetYAnimation.dp.toPx() | |
alpha = alphaAnimation | |
transformOrigin = TransformOrigin(0.5f, 0.5f) | |
scaleX = scaleAnimation | |
scaleY = scaleAnimation | |
} | |
.graphicsLayer { | |
// Scale up and down on finishing pull | |
scaleX = scaleAnimationOnPullCompleted.value | |
scaleY = scaleAnimationOnPullCompleted.value | |
}, | |
onDraw = { | |
drawPath( | |
path = path, | |
brush = SolidColor(Color.White), | |
style = Stroke(width = 6f) | |
) | |
} | |
) | |
// Draw path on top of logo while dragging down | |
Canvas( | |
modifier = Modifier | |
.size(44.dp) | |
.align(Alignment.TopCenter) | |
.padding(vertical = 24.dp) | |
.graphicsLayer { | |
// Apply all animation values while dragging down | |
translationY = offsetYAnimation.dp.toPx() | |
transformOrigin = TransformOrigin(0.5f, 0.5f) | |
scaleX = scaleAnimation | |
scaleY = scaleAnimation | |
}, | |
onDraw = { | |
val currentLength = (lines.size * (minOf(1f, pullRefreshState.progress - 0.15f))).toInt() | |
if (currentLength < 0) return@Canvas | |
val minIndex = when { | |
currentLength - 10 < 0 -> 0 | |
pullRefreshState.progress > 1.15f -> { | |
(lines.size * (pullRefreshState.progress - 0.15f)).toInt() - 10 | |
} | |
else -> currentLength - 10 | |
} | |
if (minIndex > lines.size) return@Canvas | |
for (i in minIndex..< currentLength) { | |
drawLine( | |
brush = SolidColor(Color.White), | |
start = Offset(lines[i].start.x, lines[i].start.y), | |
end = Offset(lines[i].end.x, lines[i].end.y), | |
strokeWidth = 6f, | |
cap = StrokeCap.Round | |
) | |
} | |
} | |
) | |
// Draw path going back and forth while refresh is in progress | |
Canvas( | |
modifier = Modifier | |
.size(44.dp) | |
.align(Alignment.TopCenter) | |
.padding(vertical = 24.dp) | |
.graphicsLayer { | |
// Apply all animation values while dragging down | |
translationY = offsetYAnimation.dp.toPx() | |
transformOrigin = TransformOrigin(0.5f, 0.5f) | |
scaleX = scaleAnimation | |
scaleY = scaleAnimation | |
}, | |
onDraw = { | |
val currentLength = (lines.size * pathBackAndForthAnimationOnRefreshing.value).toInt() | |
val minIndex = when { | |
currentLength - 10 < 0 -> 0 | |
else -> currentLength - 10 | |
} | |
for (i in minIndex..< currentLength) { | |
drawLine( | |
brush = SolidColor(Color.White), | |
start = Offset(lines[i].start.x, lines[i].start.y), | |
end = Offset(lines[i].end.x, lines[i].end.y), | |
strokeWidth = 6f, | |
cap = StrokeCap.Round | |
) | |
} | |
} | |
) | |
LazyColumn(modifier = Modifier | |
.fillMaxSize() | |
.graphicsLayer { | |
// Apply offset Y to lazy column as well while dragging down | |
translationY = offsetYAnimation.dp.toPx() | |
} | |
) { | |
// LazyColumn items here | |
} | |
} | |
} | |
private const val GITHUB_LOGO_PATH = "M 61.2 0 C 26.525 0 0 26.325 0 61 C 0 88.725 17.45 112.45 42.375 120.8 C 45.575 121.375 46.7 119.4 46.7 117.775 C 46.7 116.225 46.625 107.675 46.625 102.425 C 46.625 102.425 29.125 106.175 25.45 94.975 C 25.45 94.975 22.6 87.7 18.5 85.825 C 18.5 85.825 12.775 81.9 18.9 81.975 C 18.9 81.975 25.125 82.475 28.55 88.425 C 34.025 98.075 43.2 95.3 46.775 93.65 C 47.35 89.65 48.975 86.875 50.775 85.225 C 36.8 83.675 22.7 81.65 22.7 57.6 C 22.7 50.725 24.6 47.275 28.6 42.875 C 27.95 41.25 25.825 34.55 29.25 25.9 C 34.475 24.275 46.5 32.65 46.5 32.65 C 51.5 31.25 56.875 30.525 62.2 30.525 S 72.9 31.25 77.9 32.65 C 77.9 32.65 89.925 24.25 95.15 25.9 C 98.575 34.575 96.45 41.25 95.8 42.875 C 99.8 47.3 102.25 50.75 102.25 57.6 C 102.25 81.725 87.525 83.65 73.55 85.225 C 75.85 87.2 77.8 90.95 77.8 96.825 C 77.8 105.25 77.725 115.675 77.725 117.725 C 77.725 119.35 78.875 121.325 82.05 120.75 C 107.05 112.45 124 88.725 124 61 C 124 26.325 95.875 0 61.2 0 Z" | |
private object GitHubLogoPath { | |
val path = PathParser().parsePathString( | |
GITHUB_LOGO_PATH | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here is the animation in action!
screen-20240123-235303.mp4