Skip to content

Instantly share code, notes, and snippets.

@sagar-viradiya
Last active March 23, 2024 13:06
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sagar-viradiya/b52951bebd8b52b87063ba742f3c8d2e to your computer and use it in GitHub Desktop.
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
@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
)
}
@sagar-viradiya
Copy link
Author

Here is the animation in action!

screen-20240123-235303.mp4

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