Skip to content

Commit 9d0cf75

Browse files
committed
feat: implement optional scrollbar support desktop platform
- Add `ScrollbarContainer` component with platform-specific implementations for Android (no-op) and JVM (Desktop). - Implement a custom `StaggeredGridScrollbarAdapter` for Compose Desktop to support scrollbars in `LazyVerticalStaggeredGrid`. - Add `LocalScrollbarEnabled` CompositionLocal to manage scrollbar visibility globally. - Integrate `ScrollbarContainer` into various screens including Home, Search, Starred, Favourites, Apps, Details, and Developer Profile. - Add a toggle in the Profile appearance settings to enable or disable scrollbars. - Update `TweaksRepository` and `ProfileViewModel` to persist and handle the new scrollbar preference.
1 parent 1200292 commit 9d0cf75

21 files changed

Lines changed: 424 additions & 89 deletions

File tree

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ fun App(deepLinkUri: String? = null) {
103103
AppNavigation(
104104
navController = navController,
105105
isLiquidGlassEnabled = state.isLiquidGlassEnabled,
106+
isScrollbarEnabled = state.isScrollbarEnabled,
106107
)
107108
}
108109
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ data class MainState(
1414
val isDarkTheme: Boolean? = null,
1515
val currentFontTheme: FontTheme = FontTheme.CUSTOM,
1616
val isLiquidGlassEnabled: Boolean = true,
17+
val isScrollbarEnabled: Boolean = false,
1718
)

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ class MainViewModel(
8080
}
8181
}
8282

83+
viewModelScope.launch {
84+
tweaksRepository.getScrollbarEnabled().collect { enabled ->
85+
_state.update { it.copy(isScrollbarEnabled = enabled) }
86+
}
87+
}
88+
8389
viewModelScope.launch {
8490
rateLimitRepository.rateLimitState.collect { rateLimitInfo ->
8591
_state.update { currentState ->

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import zed.rainxch.apps.presentation.AppsViewModel
3131
import zed.rainxch.auth.presentation.AuthenticationRoot
3232
import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight
3333
import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid
34+
import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled
3435
import zed.rainxch.details.presentation.DetailsRoot
3536
import zed.rainxch.devprofile.presentation.DeveloperProfileRoot
3637
import zed.rainxch.favourites.presentation.FavouritesRoot
@@ -43,6 +44,7 @@ import zed.rainxch.starred.presentation.StarredReposRoot
4344
fun AppNavigation(
4445
navController: NavHostController,
4546
isLiquidGlassEnabled: Boolean = true,
47+
isScrollbarEnabled: Boolean = false,
4648
) {
4749
val liquidState = rememberLiquidState()
4850
var bottomNavigationHeight by remember { mutableStateOf(0.dp) }
@@ -54,6 +56,7 @@ fun AppNavigation(
5456
CompositionLocalProvider(
5557
LocalBottomNavigationLiquid provides liquidState,
5658
LocalBottomNavigationHeight provides bottomNavigationHeight,
59+
LocalScrollbarEnabled provides isScrollbarEnabled,
5760
) {
5861
Box(
5962
modifier = Modifier.fillMaxSize(),

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ class TweaksRepositoryImpl(
157157
}
158158
}
159159

160+
override fun getScrollbarEnabled(): Flow<Boolean> =
161+
preferences.data.map { prefs ->
162+
prefs[SCROLLBAR_ENABLED_KEY] ?: false
163+
}
164+
165+
override suspend fun setScrollbarEnabled(enabled: Boolean) {
166+
preferences.edit { prefs ->
167+
prefs[SCROLLBAR_ENABLED_KEY] = enabled
168+
}
169+
}
170+
160171
companion object {
161172
private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L
162173

@@ -172,5 +183,6 @@ class TweaksRepositoryImpl(
172183
private val INCLUDE_PRE_RELEASES_KEY = booleanPreferencesKey("include_pre_releases")
173184
private val LIQUID_GLASS_ENABLED_KEY = booleanPreferencesKey("liquid_glass_enabled")
174185
private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled")
186+
private val SCROLLBAR_ENABLED_KEY = booleanPreferencesKey("scrollbar_enabled")
175187
}
176188
}

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,8 @@ interface TweaksRepository {
5454
fun getDiscoveryPlatform(): Flow<DiscoveryPlatform>
5555

5656
suspend fun setDiscoveryPlatform(platform: DiscoveryPlatform)
57+
58+
fun getScrollbarEnabled(): Flow<Boolean>
59+
60+
suspend fun setScrollbarEnabled(enabled: Boolean)
5761
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package zed.rainxch.core.presentation.components
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.ui.Modifier
7+
8+
@Composable
9+
actual fun ScrollbarContainer(
10+
listState: LazyListState,
11+
enabled: Boolean,
12+
modifier: Modifier,
13+
content: @Composable () -> Unit,
14+
) {
15+
content()
16+
}
17+
18+
@Composable
19+
actual fun ScrollbarContainer(
20+
gridState: LazyStaggeredGridState,
21+
enabled: Boolean,
22+
modifier: Modifier,
23+
content: @Composable () -> Unit,
24+
) {
25+
content()
26+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package zed.rainxch.core.presentation.components
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.ui.Modifier
7+
8+
/**
9+
* Wraps content with a platform-appropriate scrollbar.
10+
* On Desktop (JVM), adds a VerticalScrollbar when [enabled] is true.
11+
* On Android, renders only the [content] (no scrollbar).
12+
*/
13+
@Composable
14+
expect fun ScrollbarContainer(
15+
listState: LazyListState,
16+
enabled: Boolean,
17+
modifier: Modifier = Modifier,
18+
content: @Composable () -> Unit,
19+
)
20+
21+
/**
22+
* Overload for [LazyStaggeredGridState].
23+
*/
24+
@Composable
25+
expect fun ScrollbarContainer(
26+
gridState: LazyStaggeredGridState,
27+
enabled: Boolean,
28+
modifier: Modifier = Modifier,
29+
content: @Composable () -> Unit,
30+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package zed.rainxch.core.presentation.locals
2+
3+
import androidx.compose.runtime.compositionLocalOf
4+
5+
/**
6+
* CompositionLocal providing whether the scrollbar should be shown.
7+
* Defaults to false (no scrollbar).
8+
*/
9+
val LocalScrollbarEnabled = compositionLocalOf { false }
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package zed.rainxch.core.presentation.components
2+
3+
import androidx.compose.foundation.LocalScrollbarStyle
4+
import androidx.compose.foundation.ScrollbarAdapter
5+
import androidx.compose.foundation.VerticalScrollbar
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.fillMaxHeight
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.lazy.LazyListState
10+
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
11+
import androidx.compose.foundation.rememberScrollbarAdapter
12+
import androidx.compose.foundation.shape.RoundedCornerShape
13+
import androidx.compose.material3.MaterialTheme
14+
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.remember
16+
import androidx.compose.ui.Alignment
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.unit.dp
19+
20+
@Composable
21+
actual fun ScrollbarContainer(
22+
listState: LazyListState,
23+
enabled: Boolean,
24+
modifier: Modifier,
25+
content: @Composable () -> Unit,
26+
) {
27+
if (!enabled) {
28+
content()
29+
return
30+
}
31+
Box(modifier = modifier.padding(start = 8.dp)) {
32+
val scrollbarStyle =
33+
LocalScrollbarStyle.current.copy(
34+
shape = RoundedCornerShape(32.dp),
35+
unhoverColor = MaterialTheme.colorScheme.onSurface,
36+
hoverColor = MaterialTheme.colorScheme.onSurfaceVariant,
37+
)
38+
content()
39+
VerticalScrollbar(
40+
adapter = rememberScrollbarAdapter(listState),
41+
modifier =
42+
Modifier
43+
.fillMaxHeight()
44+
.align(Alignment.CenterEnd),
45+
style = scrollbarStyle,
46+
)
47+
}
48+
}
49+
50+
@Composable
51+
actual fun ScrollbarContainer(
52+
gridState: LazyStaggeredGridState,
53+
enabled: Boolean,
54+
modifier: Modifier,
55+
content: @Composable () -> Unit,
56+
) {
57+
if (!enabled) {
58+
content()
59+
return
60+
}
61+
Box(modifier = modifier.padding(start = 8.dp)) {
62+
val scrollbarStyle =
63+
LocalScrollbarStyle.current.copy(
64+
shape = RoundedCornerShape(32.dp),
65+
unhoverColor = MaterialTheme.colorScheme.onSurface,
66+
hoverColor = MaterialTheme.colorScheme.onSurfaceVariant,
67+
)
68+
69+
content()
70+
val adapter = remember(gridState) { StaggeredGridScrollbarAdapter(gridState) }
71+
VerticalScrollbar(
72+
adapter = adapter,
73+
modifier =
74+
Modifier
75+
.fillMaxHeight()
76+
.align(Alignment.CenterEnd),
77+
style = scrollbarStyle,
78+
)
79+
}
80+
}
81+
82+
/**
83+
* Custom [ScrollbarAdapter] for [LazyStaggeredGridState] since Compose Desktop
84+
* does not provide a built-in [rememberScrollbarAdapter] overload for staggered grids.
85+
*/
86+
private class StaggeredGridScrollbarAdapter(
87+
private val gridState: LazyStaggeredGridState,
88+
) : ScrollbarAdapter {
89+
override val scrollOffset: Float
90+
get() {
91+
val layoutInfo = gridState.layoutInfo
92+
val firstVisible = layoutInfo.visibleItemsInfo.firstOrNull() ?: return 0f
93+
val fraction = firstVisible.index.toFloat() / maxOf(layoutInfo.totalItemsCount, 1)
94+
return fraction * estimatedContentSize() - firstVisible.offset.y.toFloat()
95+
}
96+
97+
override fun maxScrollOffset(containerSize: Int): Float = (estimatedContentSize() - containerSize).coerceAtLeast(0f)
98+
99+
override suspend fun scrollTo(
100+
containerSize: Int,
101+
scrollOffset: Float,
102+
) {
103+
val totalContent = estimatedContentSize()
104+
val layoutInfo = gridState.layoutInfo
105+
if (layoutInfo.totalItemsCount == 0 || totalContent <= 0f) return
106+
val fraction = scrollOffset / totalContent
107+
val targetIndex =
108+
(fraction * layoutInfo.totalItemsCount)
109+
.toInt()
110+
.coerceIn(0, maxOf(layoutInfo.totalItemsCount - 1, 0))
111+
gridState.scrollToItem(targetIndex)
112+
}
113+
114+
private fun estimatedContentSize(): Float {
115+
val layoutInfo = gridState.layoutInfo
116+
if (layoutInfo.totalItemsCount == 0) return 0f
117+
val visibleItems = layoutInfo.visibleItemsInfo
118+
if (visibleItems.isEmpty()) return 0f
119+
val avgHeight = visibleItems.map { it.size.height }.average().toFloat()
120+
val laneCount =
121+
maxOf(
122+
visibleItems.maxOf { it.lane + 1 },
123+
1,
124+
)
125+
val rows = (layoutInfo.totalItemsCount + laneCount - 1) / laneCount
126+
return rows * avgHeight + layoutInfo.beforeContentPadding + layoutInfo.afterContentPadding
127+
}
128+
}

0 commit comments

Comments
 (0)