Skip to content

Commit a51ef07

Browse files
committed
feat(ui): Implement progress sliders and fix layout virtualization
Implements a visual progress scrollbar on all long lists to improve user navigation and provide a better sense of progress. Bumps app version to 0.9.1. Integrated the new scrollbar into the following screens: - `SessionSetupScreen`: For the main folder selection list. - `DuplicatesScreen`: For the list view, grid view, and the detailed group view. - `SummarySheet` (in `SwiperScreen.kt`): For the list of pending changes. - `OpenSourceLicensesScreen`: For the list of libraries. Refactored the `SettingsScreen` to correct its structure. The 'About' section was a remnant of a previous design and was incorrectly nested within 'Help & Support', causing a layout padding issue. It is now a distinct top-level section for consistency. A critical layout and state calculation issue was resolved in the `SummarySheet`'s grid view. The original implementation used a nested `LazyVerticalGrid` inside the main `LazyColumn`, which provided unstable measurements to the `LazyListS> Also includes misc code formatting updates and some unused code removal across the project. VERSION: 0.9.0 -> 0.9.1
1 parent 7128d1b commit a51ef07

8 files changed

Lines changed: 768 additions & 463 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ android {
3939
minSdk = 29
4040
targetSdk = 36
4141
versionCode = 1
42-
versionName = "0.9.0"
42+
versionName = "0.9.1"
4343

4444
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
4545
vectorDrawables {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.cleansweep.ui.components
2+
3+
import androidx.compose.animation.core.animateFloatAsState
4+
import androidx.compose.animation.core.tween
5+
import androidx.compose.foundation.background
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.BoxWithConstraints
8+
import androidx.compose.foundation.layout.fillMaxHeight
9+
import androidx.compose.foundation.layout.fillMaxWidth
10+
import androidx.compose.foundation.layout.height
11+
import androidx.compose.foundation.layout.offset
12+
import androidx.compose.foundation.layout.width
13+
import androidx.compose.foundation.lazy.LazyListState
14+
import androidx.compose.foundation.lazy.grid.LazyGridState
15+
import androidx.compose.foundation.shape.CircleShape
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.derivedStateOf
19+
import androidx.compose.runtime.getValue
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.draw.alpha
23+
import androidx.compose.ui.draw.clip
24+
import androidx.compose.ui.platform.LocalDensity
25+
import androidx.compose.ui.unit.dp
26+
27+
private const val SCROLLBAR_MIN_THUMB_HEIGHT = 0.1f
28+
private const val SCROLLBAR_MAX_THUMB_HEIGHT = 0.4f
29+
private const val SCROLLBAR_INACTIVE_ALPHA = 0f
30+
private const val SCROLLBAR_ACTIVE_ALPHA = 0.8f
31+
32+
private data class ScrollbarRenderInfo(
33+
val thumbSize: Float,
34+
val scrollBias: Float,
35+
val isVisible: Boolean
36+
)
37+
38+
@Composable
39+
private fun FastScrollbarInternal(
40+
isScrollInProgress: Boolean,
41+
renderInfo: ScrollbarRenderInfo,
42+
modifier: Modifier = Modifier
43+
) {
44+
val alpha by animateFloatAsState(
45+
targetValue = if (isScrollInProgress && renderInfo.isVisible) SCROLLBAR_ACTIVE_ALPHA else SCROLLBAR_INACTIVE_ALPHA,
46+
animationSpec = tween(durationMillis = if (isScrollInProgress) 75 else 500),
47+
label = "ScrollbarAlpha"
48+
)
49+
50+
if (!renderInfo.isVisible) {
51+
return
52+
}
53+
54+
val density = LocalDensity.current
55+
56+
BoxWithConstraints(
57+
modifier = modifier
58+
.fillMaxHeight()
59+
.width(8.dp)
60+
.alpha(alpha)
61+
) {
62+
val trackHeightPx = constraints.maxHeight
63+
val thumbHeightDp = maxHeight * renderInfo.thumbSize
64+
65+
val scrollableDistPx = trackHeightPx - with(density) { thumbHeightDp.toPx() }
66+
val thumbYPx = renderInfo.scrollBias * scrollableDistPx
67+
val thumbYDp = with(density) { thumbYPx.toDp() }
68+
69+
Box(
70+
modifier = Modifier
71+
.fillMaxWidth()
72+
.height(thumbHeightDp)
73+
.offset(y = thumbYDp)
74+
.clip(CircleShape)
75+
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
76+
)
77+
}
78+
}
79+
80+
@Composable
81+
fun FastScrollbar(
82+
state: LazyListState,
83+
modifier: Modifier = Modifier
84+
) {
85+
val isScrollInProgress by remember { derivedStateOf { state.isScrollInProgress } }
86+
87+
val renderInfo by remember {
88+
derivedStateOf {
89+
val layoutInfo = state.layoutInfo
90+
val visibleItemsInfo = layoutInfo.visibleItemsInfo
91+
if (visibleItemsInfo.isEmpty()) {
92+
return@derivedStateOf ScrollbarRenderInfo(0f, 0f, isVisible = false)
93+
}
94+
95+
val totalItemsCount = layoutInfo.totalItemsCount
96+
val visibleItemsCount = visibleItemsInfo.size
97+
val isVisible = totalItemsCount > visibleItemsCount
98+
if (!isVisible) {
99+
return@derivedStateOf ScrollbarRenderInfo(1f, 0f, isVisible = false)
100+
}
101+
102+
val thumbSize = (40f / totalItemsCount).coerceIn(SCROLLBAR_MIN_THUMB_HEIGHT, SCROLLBAR_MAX_THUMB_HEIGHT)
103+
104+
val firstVisibleItemIndex = state.firstVisibleItemIndex
105+
val scrollableItemsCount = (totalItemsCount - visibleItemsCount).toFloat().coerceAtLeast(1f)
106+
val scrollBias = (firstVisibleItemIndex.toFloat() / scrollableItemsCount).coerceIn(0f, 1f)
107+
108+
ScrollbarRenderInfo(thumbSize, scrollBias, isVisible = true)
109+
}
110+
}
111+
112+
FastScrollbarInternal(
113+
isScrollInProgress = isScrollInProgress,
114+
renderInfo = renderInfo,
115+
modifier = modifier
116+
)
117+
}
118+
119+
@Composable
120+
fun FastScrollbar(
121+
state: LazyGridState,
122+
modifier: Modifier = Modifier
123+
) {
124+
val isScrollInProgress by remember { derivedStateOf { state.isScrollInProgress } }
125+
126+
val renderInfo by remember {
127+
derivedStateOf {
128+
val layoutInfo = state.layoutInfo
129+
val visibleItemsInfo = layoutInfo.visibleItemsInfo
130+
if (visibleItemsInfo.isEmpty()) {
131+
return@derivedStateOf ScrollbarRenderInfo(0f, 0f, isVisible = false)
132+
}
133+
134+
val totalItemsCount = layoutInfo.totalItemsCount
135+
val visibleItemsCount = visibleItemsInfo.size
136+
val isVisible = totalItemsCount > visibleItemsCount
137+
if (!isVisible) {
138+
return@derivedStateOf ScrollbarRenderInfo(1f, 0f, isVisible = false)
139+
}
140+
141+
val thumbSize = (40f / totalItemsCount).coerceIn(SCROLLBAR_MIN_THUMB_HEIGHT, SCROLLBAR_MAX_THUMB_HEIGHT)
142+
143+
val firstVisibleItemIndex = state.firstVisibleItemIndex
144+
val scrollableItemsCount = (totalItemsCount - visibleItemsCount).toFloat().coerceAtLeast(1f)
145+
val scrollBias = (firstVisibleItemIndex.toFloat() / scrollableItemsCount).coerceIn(0f, 1f)
146+
147+
ScrollbarRenderInfo(thumbSize, scrollBias, isVisible = true)
148+
}
149+
}
150+
151+
FastScrollbarInternal(
152+
isScrollInProgress = isScrollInProgress,
153+
renderInfo = renderInfo,
154+
modifier = modifier
155+
)
156+
}

0 commit comments

Comments
 (0)