Skip to content

Commit 8aa0457

Browse files
Merge branch 'android:main' into loading-progress-for-image
2 parents 5bbcc90 + 0a20cb7 commit 8aa0457

File tree

10 files changed

+1354
-210
lines changed

10 files changed

+1354
-210
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
18+
19+
import androidx.compose.animation.animateColorAsState
20+
import androidx.compose.animation.core.Spring
21+
import androidx.compose.animation.core.SpringSpec
22+
import androidx.compose.foundation.background
23+
import androidx.compose.foundation.gestures.Orientation
24+
import androidx.compose.foundation.gestures.Orientation.Horizontal
25+
import androidx.compose.foundation.gestures.Orientation.Vertical
26+
import androidx.compose.foundation.gestures.ScrollableState
27+
import androidx.compose.foundation.interaction.InteractionSource
28+
import androidx.compose.foundation.interaction.MutableInteractionSource
29+
import androidx.compose.foundation.interaction.collectIsDraggedAsState
30+
import androidx.compose.foundation.interaction.collectIsHoveredAsState
31+
import androidx.compose.foundation.interaction.collectIsPressedAsState
32+
import androidx.compose.foundation.layout.Box
33+
import androidx.compose.foundation.layout.fillMaxHeight
34+
import androidx.compose.foundation.layout.fillMaxWidth
35+
import androidx.compose.foundation.layout.height
36+
import androidx.compose.foundation.layout.width
37+
import androidx.compose.foundation.shape.RoundedCornerShape
38+
import androidx.compose.material3.MaterialTheme
39+
import androidx.compose.runtime.Composable
40+
import androidx.compose.runtime.LaunchedEffect
41+
import androidx.compose.runtime.getValue
42+
import androidx.compose.runtime.mutableStateOf
43+
import androidx.compose.runtime.remember
44+
import androidx.compose.runtime.setValue
45+
import androidx.compose.ui.Modifier
46+
import androidx.compose.ui.graphics.Color
47+
import androidx.compose.ui.unit.dp
48+
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active
49+
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant
50+
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive
51+
import kotlinx.coroutines.delay
52+
53+
/**
54+
* The time period for showing the scrollbar thumb after interacting with it, before it fades away
55+
*/
56+
private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
57+
58+
/**
59+
* A [Scrollbar] that allows for fast scrolling of content by dragging its thumb.
60+
* Its thumb disappears when the scrolling container is dormant.
61+
* @param modifier a [Modifier] for the [Scrollbar]
62+
* @param state the driving state for the [Scrollbar]
63+
* @param orientation the orientation of the scrollbar
64+
* @param onThumbMoved the fast scroll implementation
65+
*/
66+
@Composable
67+
fun ScrollableState.DraggableScrollbar(
68+
modifier: Modifier = Modifier,
69+
state: ScrollbarState,
70+
orientation: Orientation,
71+
onThumbMoved: (Float) -> Unit,
72+
) {
73+
val interactionSource = remember { MutableInteractionSource() }
74+
Scrollbar(
75+
modifier = modifier,
76+
orientation = orientation,
77+
interactionSource = interactionSource,
78+
state = state,
79+
thumb = {
80+
DraggableScrollbarThumb(
81+
interactionSource = interactionSource,
82+
orientation = orientation,
83+
)
84+
},
85+
onThumbMoved = onThumbMoved,
86+
)
87+
}
88+
89+
/**
90+
* A simple [Scrollbar].
91+
* Its thumb disappears when the scrolling container is dormant.
92+
* @param modifier a [Modifier] for the [Scrollbar]
93+
* @param state the driving state for the [Scrollbar]
94+
* @param orientation the orientation of the scrollbar
95+
*/
96+
@Composable
97+
fun ScrollableState.DecorativeScrollbar(
98+
modifier: Modifier = Modifier,
99+
state: ScrollbarState,
100+
orientation: Orientation,
101+
) {
102+
val interactionSource = remember { MutableInteractionSource() }
103+
Scrollbar(
104+
modifier = modifier,
105+
orientation = orientation,
106+
interactionSource = interactionSource,
107+
state = state,
108+
thumb = {
109+
DecorativeScrollbarThumb(
110+
interactionSource = interactionSource,
111+
orientation = orientation,
112+
)
113+
},
114+
)
115+
}
116+
117+
/**
118+
* A scrollbar thumb that is intended to also be a touch target for fast scrolling.
119+
*/
120+
@Composable
121+
private fun ScrollableState.DraggableScrollbarThumb(
122+
interactionSource: InteractionSource,
123+
orientation: Orientation,
124+
) {
125+
Box(
126+
modifier = Modifier
127+
.run {
128+
when (orientation) {
129+
Vertical -> width(12.dp).fillMaxHeight()
130+
Horizontal -> height(12.dp).fillMaxWidth()
131+
}
132+
}
133+
.background(
134+
color = scrollbarThumbColor(
135+
interactionSource = interactionSource,
136+
),
137+
shape = RoundedCornerShape(16.dp),
138+
),
139+
)
140+
}
141+
142+
/**
143+
* A decorative scrollbar thumb used solely for communicating a user's position in a list.
144+
*/
145+
@Composable
146+
private fun ScrollableState.DecorativeScrollbarThumb(
147+
interactionSource: InteractionSource,
148+
orientation: Orientation,
149+
) {
150+
Box(
151+
modifier = Modifier
152+
.run {
153+
when (orientation) {
154+
Vertical -> width(2.dp).fillMaxHeight()
155+
Horizontal -> height(2.dp).fillMaxWidth()
156+
}
157+
}
158+
.background(
159+
color = scrollbarThumbColor(
160+
interactionSource = interactionSource,
161+
),
162+
shape = RoundedCornerShape(16.dp),
163+
),
164+
)
165+
}
166+
167+
/**
168+
* The color of the scrollbar thumb as a function of its interaction state.
169+
* @param interactionSource source of interactions in the scrolling container
170+
*/
171+
@Composable
172+
private fun ScrollableState.scrollbarThumbColor(
173+
interactionSource: InteractionSource,
174+
): Color {
175+
var state by remember { mutableStateOf(Dormant) }
176+
val pressed by interactionSource.collectIsPressedAsState()
177+
val hovered by interactionSource.collectIsHoveredAsState()
178+
val dragged by interactionSource.collectIsDraggedAsState()
179+
val active = (canScrollForward || canScrollForward) &&
180+
(pressed || hovered || dragged || isScrollInProgress)
181+
182+
val color by animateColorAsState(
183+
targetValue = when (state) {
184+
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
185+
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
186+
Dormant -> Color.Transparent
187+
},
188+
animationSpec = SpringSpec(
189+
stiffness = Spring.StiffnessLow,
190+
),
191+
label = "Scrollbar thumb color",
192+
)
193+
LaunchedEffect(active) {
194+
when (active) {
195+
true -> state = Active
196+
false -> if (state == Active) {
197+
state = Inactive
198+
delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS)
199+
state = Dormant
200+
}
201+
}
202+
}
203+
204+
return color
205+
}
206+
207+
private enum class ThumbState {
208+
Active, Inactive, Dormant
209+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
18+
19+
import androidx.compose.foundation.gestures.ScrollableState
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.LaunchedEffect
22+
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.runtime.setValue
26+
import androidx.compose.runtime.snapshotFlow
27+
import kotlinx.coroutines.flow.distinctUntilChanged
28+
import kotlinx.coroutines.flow.filterNotNull
29+
import kotlin.math.abs
30+
import kotlin.math.min
31+
32+
/**
33+
* Calculates the [ScrollbarState] for lazy layouts.
34+
* @param itemsAvailable the total amount of items available to scroll in the layout.
35+
* @param visibleItems a list of items currently visible in the layout.
36+
* @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout
37+
* as scrolling progresses for smooth and linear scrollbar thumb progression.
38+
* [itemsAvailable].
39+
* @param reverseLayout if the items in the backing lazy layout are laid out in reverse order.
40+
* */
41+
@Composable
42+
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.scrollbarState(
43+
itemsAvailable: Int,
44+
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
45+
crossinline firstVisibleItemIndex: LazyState.(List<LazyStateItem>) -> Float,
46+
crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float,
47+
crossinline reverseLayout: LazyState.() -> Boolean,
48+
): ScrollbarState {
49+
var state by remember { mutableStateOf(ScrollbarState.FULL) }
50+
51+
LaunchedEffect(
52+
key1 = this,
53+
key2 = itemsAvailable,
54+
) {
55+
snapshotFlow {
56+
if (itemsAvailable == 0) return@snapshotFlow null
57+
58+
val visibleItemsInfo = visibleItems(this@scrollbarState)
59+
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
60+
61+
val firstIndex = min(
62+
a = firstVisibleItemIndex(visibleItemsInfo),
63+
b = itemsAvailable.toFloat(),
64+
)
65+
if (firstIndex.isNaN()) return@snapshotFlow null
66+
67+
val itemsVisible = visibleItemsInfo.sumOf {
68+
itemPercentVisible(it).toDouble()
69+
}.toFloat()
70+
71+
val thumbTravelPercent = min(
72+
a = firstIndex / itemsAvailable,
73+
b = 1f,
74+
)
75+
val thumbSizePercent = min(
76+
a = itemsVisible / itemsAvailable,
77+
b = 1f,
78+
)
79+
ScrollbarState(
80+
thumbSizePercent = thumbSizePercent,
81+
thumbMovedPercent = when {
82+
reverseLayout() -> 1f - thumbTravelPercent
83+
else -> thumbTravelPercent
84+
},
85+
)
86+
}
87+
.filterNotNull()
88+
.distinctUntilChanged()
89+
.collect { state = it }
90+
}
91+
return state
92+
}
93+
94+
/**
95+
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar
96+
* progression.
97+
* @param visibleItems a list of items currently visible in the layout.
98+
* @param itemSize a lookup function for the size of an item in the layout.
99+
* @param offset a lookup function for the offset of an item relative to the start of the view port.
100+
* @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction
101+
* of the scroll.
102+
* @param itemIndex a lookup function for index of an item in the layout relative to
103+
* the total amount of items available.
104+
*
105+
* @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition
106+
* is the index of the consecutive item along the major axis.
107+
* */
108+
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
109+
visibleItems: List<LazyStateItem>,
110+
crossinline itemSize: LazyState.(LazyStateItem) -> Int,
111+
crossinline offset: LazyState.(LazyStateItem) -> Int,
112+
crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,
113+
crossinline itemIndex: (LazyStateItem) -> Int,
114+
): Float {
115+
if (visibleItems.isEmpty()) return 0f
116+
117+
val firstItem = visibleItems.first()
118+
val firstItemIndex = itemIndex(firstItem)
119+
120+
if (firstItemIndex < 0) return Float.NaN
121+
122+
val firstItemSize = itemSize(firstItem)
123+
if (firstItemSize == 0) return Float.NaN
124+
125+
val itemOffset = offset(firstItem).toFloat()
126+
val offsetPercentage = abs(itemOffset) / firstItemSize
127+
128+
val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage
129+
130+
val nextItemIndex = itemIndex(nextItem)
131+
132+
return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)
133+
}
134+
135+
/**
136+
* Returns the percentage of an item that is currently visible in the view port.
137+
* @param itemSize the size of the item
138+
* @param itemStartOffset the start offset of the item relative to the view port start
139+
* @param viewportStartOffset the start offset of the view port
140+
* @param viewportEndOffset the end offset of the view port
141+
*/
142+
internal fun itemVisibilityPercentage(
143+
itemSize: Int,
144+
itemStartOffset: Int,
145+
viewportStartOffset: Int,
146+
viewportEndOffset: Int,
147+
): Float {
148+
if (itemSize == 0) return 0f
149+
val itemEnd = itemStartOffset + itemSize
150+
val startOffset = when {
151+
itemStartOffset > viewportStartOffset -> 0
152+
else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))
153+
}
154+
val endOffset = when {
155+
itemEnd < viewportEndOffset -> 0
156+
else -> abs(abs(itemEnd) - abs(viewportEndOffset))
157+
}
158+
val size = itemSize.toFloat()
159+
return (size - startOffset - endOffset) / size
160+
}

0 commit comments

Comments
 (0)