Skip to content

Commit c22a86d

Browse files
committed
feat: implement robust scroll direction tracking and enhance UI animations
- Extract and enhance `isScrollingUp` logic into a shared utility supporting both `LazyListState` and `LazyStaggeredGridState`. - Introduce `SCROLL_THRESHOLD_PX` (200px) to prevent flickering and ensure the header only toggles after a significant scroll gesture. - Update `HomeRoot` to use the new scroll utility and refactor header animations from slide-based to `expandVertically`/`shrinkVertically` for smoother transitions. - Adjust `HomeTopAppBar` to use empty content padding and window insets to prevent layout shifts. - Clean up unused animation imports and simplify `LaunchedEffect` logic in `HomeRootContent`.
1 parent 338c62a commit c22a86d

2 files changed

Lines changed: 132 additions & 60 deletions

File tree

  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils
  • feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package zed.rainxch.core.presentation.utils
2+
3+
import androidx.compose.foundation.lazy.LazyListState
4+
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.State
7+
import androidx.compose.runtime.derivedStateOf
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableIntStateOf
10+
import androidx.compose.runtime.mutableStateOf
11+
import androidx.compose.runtime.remember
12+
import androidx.compose.runtime.setValue
13+
14+
private const val SCROLL_THRESHOLD_PX = 200
15+
16+
@Composable
17+
fun LazyStaggeredGridState.isScrollingUp(): State<Boolean> {
18+
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
19+
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
20+
var accumulatedDelta by remember(this) { mutableIntStateOf(0) }
21+
var headerVisible by remember(this) { mutableStateOf(true) }
22+
23+
return remember(this) {
24+
derivedStateOf {
25+
if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) {
26+
accumulatedDelta = 0
27+
headerVisible = true
28+
} else {
29+
val delta =
30+
when {
31+
firstVisibleItemIndex > previousIndex -> SCROLL_THRESHOLD_PX
32+
firstVisibleItemIndex < previousIndex -> -SCROLL_THRESHOLD_PX
33+
else -> firstVisibleItemScrollOffset - previousScrollOffset
34+
}
35+
36+
if ((delta > 0 && accumulatedDelta >= 0) || (delta < 0 && accumulatedDelta <= 0)) {
37+
accumulatedDelta += delta
38+
} else {
39+
accumulatedDelta = delta
40+
}
41+
42+
if (accumulatedDelta > SCROLL_THRESHOLD_PX) {
43+
headerVisible = false
44+
accumulatedDelta = 0
45+
} else if (accumulatedDelta < -SCROLL_THRESHOLD_PX) {
46+
headerVisible = true
47+
accumulatedDelta = 0
48+
}
49+
}
50+
51+
previousIndex = firstVisibleItemIndex
52+
previousScrollOffset = firstVisibleItemScrollOffset
53+
headerVisible
54+
}
55+
}
56+
}
57+
58+
@Composable
59+
fun LazyListState.isScrollingUp(): State<Boolean> {
60+
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
61+
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
62+
var accumulatedDelta by remember(this) { mutableIntStateOf(0) }
63+
var headerVisible by remember(this) { mutableStateOf(true) }
64+
65+
return remember(this) {
66+
derivedStateOf {
67+
if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) {
68+
accumulatedDelta = 0
69+
headerVisible = true
70+
} else {
71+
val delta =
72+
when {
73+
firstVisibleItemIndex > previousIndex -> SCROLL_THRESHOLD_PX
74+
firstVisibleItemIndex < previousIndex -> -SCROLL_THRESHOLD_PX
75+
else -> firstVisibleItemScrollOffset - previousScrollOffset
76+
}
77+
78+
if ((delta > 0 && accumulatedDelta >= 0) || (delta < 0 && accumulatedDelta <= 0)) {
79+
accumulatedDelta += delta
80+
} else {
81+
accumulatedDelta = delta
82+
}
83+
84+
if (accumulatedDelta > SCROLL_THRESHOLD_PX) {
85+
headerVisible = false
86+
accumulatedDelta = 0
87+
} else if (accumulatedDelta < -SCROLL_THRESHOLD_PX) {
88+
headerVisible = true
89+
accumulatedDelta = 0
90+
}
91+
}
92+
93+
previousIndex = firstVisibleItemIndex
94+
previousScrollOffset = firstVisibleItemScrollOffset
95+
headerVisible
96+
}
97+
}
98+
}

feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt

Lines changed: 34 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ package zed.rainxch.home.presentation
22

33
import androidx.compose.animation.AnimatedVisibility
44
import androidx.compose.animation.animateColorAsState
5-
import androidx.compose.animation.core.Spring
6-
import androidx.compose.animation.core.spring
75
import androidx.compose.animation.core.tween
6+
import androidx.compose.animation.expandVertically
87
import androidx.compose.animation.fadeIn
98
import androidx.compose.animation.fadeOut
10-
import androidx.compose.animation.slideInVertically
11-
import androidx.compose.animation.slideOutVertically
9+
import androidx.compose.animation.shrinkVertically
1210
import androidx.compose.foundation.Image
1311
import androidx.compose.foundation.background
1412
import androidx.compose.foundation.clickable
@@ -19,12 +17,14 @@ import androidx.compose.foundation.layout.Column
1917
import androidx.compose.foundation.layout.PaddingValues
2018
import androidx.compose.foundation.layout.Row
2119
import androidx.compose.foundation.layout.Spacer
20+
import androidx.compose.foundation.layout.WindowInsets
2221
import androidx.compose.foundation.layout.fillMaxSize
2322
import androidx.compose.foundation.layout.fillMaxWidth
2423
import androidx.compose.foundation.layout.height
2524
import androidx.compose.foundation.layout.padding
2625
import androidx.compose.foundation.layout.size
2726
import androidx.compose.foundation.layout.width
27+
import androidx.compose.foundation.lazy.LazyColumn
2828
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
2929
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
3030
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -85,6 +85,7 @@ import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight
8585
import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid
8686
import zed.rainxch.core.presentation.theme.GithubStoreTheme
8787
import zed.rainxch.core.presentation.utils.ObserveAsEvents
88+
import zed.rainxch.core.presentation.utils.isScrollingUp
8889
import zed.rainxch.core.presentation.utils.toIcons
8990
import zed.rainxch.core.presentation.utils.toLabel
9091
import zed.rainxch.githubstore.core.presentation.res.*
@@ -185,11 +186,9 @@ fun HomeScreen(
185186
}
186187
}
187188

188-
val currentOnAction by rememberUpdatedState(onAction)
189-
190189
LaunchedEffect(shouldLoadMore) {
191190
if (shouldLoadMore) {
192-
currentOnAction(HomeAction.LoadMore)
191+
onAction(HomeAction.LoadMore)
193192
}
194193
}
195194

@@ -227,20 +226,16 @@ fun HomeScreen(
227226
) {
228227
AnimatedVisibility(
229228
visible = isHeaderVisible,
230-
enter = slideInVertically(
231-
initialOffsetY = { -it },
232-
animationSpec = spring(
233-
dampingRatio = Spring.DampingRatioNoBouncy,
234-
stiffness = Spring.StiffnessMediumLow,
235-
),
236-
) + fadeIn(tween(200)),
237-
exit = slideOutVertically(
238-
targetOffsetY = { -it },
239-
animationSpec = spring(
240-
dampingRatio = Spring.DampingRatioNoBouncy,
241-
stiffness = Spring.StiffnessMediumLow,
242-
),
243-
) + fadeOut(tween(150)),
229+
enter =
230+
expandVertically(
231+
expandFrom = Alignment.Top,
232+
animationSpec = tween(250),
233+
) + fadeIn(tween(200)),
234+
exit =
235+
shrinkVertically(
236+
shrinkTowards = Alignment.Top,
237+
animationSpec = tween(200),
238+
) + fadeOut(tween(150)),
244239
) {
245240
Column {
246241
HomeTopAppBar(
@@ -340,20 +335,22 @@ private fun TopicChips(
340335
modifier = Modifier.size(18.dp),
341336
)
342337
},
343-
colors = FilterChipDefaults.filterChipColors(
344-
containerColor = containerColor,
345-
labelColor = labelColor,
346-
iconColor = labelColor,
347-
selectedContainerColor = containerColor,
348-
selectedLabelColor = labelColor,
349-
selectedLeadingIconColor = labelColor,
350-
),
351-
border = FilterChipDefaults.filterChipBorder(
352-
borderColor = Color.Transparent,
353-
selectedBorderColor = Color.Transparent,
354-
enabled = true,
355-
selected = isSelected,
356-
),
338+
colors =
339+
FilterChipDefaults.filterChipColors(
340+
containerColor = containerColor,
341+
labelColor = labelColor,
342+
iconColor = labelColor,
343+
selectedContainerColor = containerColor,
344+
selectedLabelColor = labelColor,
345+
selectedLeadingIconColor = labelColor,
346+
),
347+
border =
348+
FilterChipDefaults.filterChipBorder(
349+
borderColor = Color.Transparent,
350+
selectedBorderColor = Color.Transparent,
351+
enabled = true,
352+
selected = isSelected,
353+
),
357354
shape = RoundedCornerShape(12.dp),
358355
)
359356
}
@@ -604,6 +601,8 @@ private fun HomeTopAppBar(
604601
}
605602
},
606603
modifier = Modifier.padding(12.dp),
604+
contentPadding = PaddingValues(),
605+
windowInsets = WindowInsets(),
607606
)
608607
}
609608

@@ -663,31 +662,6 @@ private fun PlatformsPopup(
663662
}
664663
}
665664

666-
/**
667-
* Tracks scroll direction — returns true when the user is scrolling up
668-
* (or is at the very top of the list), false when scrolling down.
669-
*/
670-
@Composable
671-
private fun LazyStaggeredGridState.isScrollingUp(): State<Boolean> {
672-
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
673-
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
674-
675-
return remember(this) {
676-
derivedStateOf {
677-
if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) {
678-
true // at the very top — always show header
679-
} else if (previousIndex != firstVisibleItemIndex) {
680-
previousIndex > firstVisibleItemIndex
681-
} else {
682-
previousScrollOffset >= firstVisibleItemScrollOffset
683-
}.also {
684-
previousIndex = firstVisibleItemIndex
685-
previousScrollOffset = firstVisibleItemScrollOffset
686-
}
687-
}
688-
}
689-
}
690-
691665
@Preview
692666
@Composable
693667
private fun Preview() {

0 commit comments

Comments
 (0)