diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt index 8c4063b15..82252e0fb 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt @@ -1,95 +1,23 @@ /* * Copyright 2023 The Android Open Source Project * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * https://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlin.math.abs -import kotlin.math.min - -/** - * Calculates the [ScrollbarState] for lazy layouts. - * @param itemsAvailable the total amount of items available to scroll in the layout. - * @param visibleItems a list of items currently visible in the layout. - * @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout - * as scrolling progresses for smooth and linear scrollbar thumb progression. - * [itemsAvailable]. - * @param reverseLayout if the items in the backing lazy layout are laid out in reverse order. - * */ -@Composable -internal inline fun LazyState.scrollbarState( - itemsAvailable: Int, - crossinline visibleItems: LazyState.() -> List, - crossinline firstVisibleItemIndex: LazyState.(List) -> Float, - crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float, - crossinline reverseLayout: LazyState.() -> Boolean, -): ScrollbarState { - var state by remember { mutableStateOf(ScrollbarState.FULL) } - - LaunchedEffect( - key1 = this, - key2 = itemsAvailable, - ) { - snapshotFlow { - if (itemsAvailable == 0) return@snapshotFlow null - - val visibleItemsInfo = visibleItems(this@scrollbarState) - if (visibleItemsInfo.isEmpty()) return@snapshotFlow null - - val firstIndex = min( - a = firstVisibleItemIndex(visibleItemsInfo), - b = itemsAvailable.toFloat(), - ) - if (firstIndex.isNaN()) return@snapshotFlow null - - val itemsVisible = visibleItemsInfo.sumOf { - itemPercentVisible(it).toDouble() - }.toFloat() - - val thumbTravelPercent = min( - a = firstIndex / itemsAvailable, - b = 1f, - ) - val thumbSizePercent = min( - a = itemsVisible / itemsAvailable, - b = 1f, - ) - ScrollbarState( - thumbSizePercent = thumbSizePercent, - thumbMovedPercent = when { - reverseLayout() -> 1f - thumbTravelPercent - else -> thumbTravelPercent - }, - ) - } - .filterNotNull() - .distinctUntilChanged() - .collect { state = it } - } - return state -} /** * Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt index c519b40db..668bc74e1 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -1,17 +1,17 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * https://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar @@ -24,6 +24,11 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlin.math.min /** * Calculates a [ScrollbarState] driven by the changes in a [LazyListState]. @@ -35,29 +40,58 @@ import androidx.compose.runtime.Composable fun LazyListState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, -): ScrollbarState = - scrollbarState( - itemsAvailable = itemsAvailable, - visibleItems = { layoutInfo.visibleItemsInfo }, - firstVisibleItemIndex = { visibleItems -> - interpolateFirstItemIndex( - visibleItems = visibleItems, +): ScrollbarState = produceState( + initialValue = ScrollbarState.FULL, + key1 = this, + key2 = itemsAvailable, +) { + snapshotFlow { + if (itemsAvailable == 0) return@snapshotFlow null + + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isEmpty()) return@snapshotFlow null + + val firstIndex = min( + a = interpolateFirstItemIndex( + visibleItems = visibleItemsInfo, itemSize = { it.size }, offset = { it.offset }, - nextItemOnMainAxis = { first -> visibleItems.find { it != first } }, + nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } }, itemIndex = itemIndex, - ) - }, - itemPercentVisible = itemPercentVisible@{ itemInfo -> + ), + b = itemsAvailable.toFloat(), + ) + if (firstIndex.isNaN()) return@snapshotFlow null + + val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo -> itemVisibilityPercentage( itemSize = itemInfo.size, itemStartOffset = itemInfo.offset, viewportStartOffset = layoutInfo.viewportStartOffset, viewportEndOffset = layoutInfo.viewportEndOffset, ) - }, - reverseLayout = { layoutInfo.reverseLayout }, - ) + } + + val thumbTravelPercent = min( + a = firstIndex / itemsAvailable, + b = 1f, + ) + val thumbSizePercent = min( + a = itemsVisible / itemsAvailable, + b = 1f, + ) + ScrollbarState( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = when { + layoutInfo.reverseLayout -> 1f - thumbTravelPercent + else -> thumbTravelPercent + }, + ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { value = it } +}.value /** * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] @@ -69,41 +103,68 @@ fun LazyListState.scrollbarState( fun LazyGridState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, -): ScrollbarState = - scrollbarState( - itemsAvailable = itemsAvailable, - visibleItems = { layoutInfo.visibleItemsInfo }, - firstVisibleItemIndex = { visibleItems -> - interpolateFirstItemIndex( - visibleItems = visibleItems, - itemSize = { - layoutInfo.orientation.valueOf(it.size) - }, +): ScrollbarState = produceState( + initialValue = ScrollbarState.FULL, + key1 = this, + key2 = itemsAvailable, +) { + snapshotFlow { + if (itemsAvailable == 0) return@snapshotFlow null + + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isEmpty()) return@snapshotFlow null + + val firstIndex = min( + a = interpolateFirstItemIndex( + visibleItems = visibleItemsInfo, + itemSize = { layoutInfo.orientation.valueOf(it.size) }, offset = { layoutInfo.orientation.valueOf(it.offset) }, nextItemOnMainAxis = { first -> when (layoutInfo.orientation) { - Orientation.Vertical -> visibleItems.find { + Orientation.Vertical -> visibleItemsInfo.find { it != first && it.row != first.row } - Orientation.Horizontal -> visibleItems.find { + Orientation.Horizontal -> visibleItemsInfo.find { it != first && it.column != first.column } } }, itemIndex = itemIndex, - ) - }, - itemPercentVisible = itemPercentVisible@{ itemInfo -> + ), + b = itemsAvailable.toFloat(), + ) + if (firstIndex.isNaN()) return@snapshotFlow null + + val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo -> itemVisibilityPercentage( itemSize = layoutInfo.orientation.valueOf(itemInfo.size), itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), viewportStartOffset = layoutInfo.viewportStartOffset, viewportEndOffset = layoutInfo.viewportEndOffset, ) - }, - reverseLayout = { layoutInfo.reverseLayout }, - ) + } + + val thumbTravelPercent = min( + a = firstIndex / itemsAvailable, + b = 1f, + ) + val thumbSizePercent = min( + a = itemsVisible / itemsAvailable, + b = 1f, + ) + ScrollbarState( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = when { + layoutInfo.reverseLayout -> 1f - thumbTravelPercent + else -> thumbTravelPercent + }, + ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { value = it } +}.value /** * Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState] @@ -116,28 +177,62 @@ fun LazyGridState.scrollbarState( fun LazyStaggeredGridState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index, -): ScrollbarState = - scrollbarState( - itemsAvailable = itemsAvailable, - visibleItems = { layoutInfo.visibleItemsInfo }, - firstVisibleItemIndex = { visibleItems -> - interpolateFirstItemIndex( - visibleItems = visibleItems, +): ScrollbarState = produceState( + initialValue = ScrollbarState.FULL, + key1 = this, + key2 = itemsAvailable, +) { + snapshotFlow { + if (itemsAvailable == 0) return@snapshotFlow null + + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isEmpty()) return@snapshotFlow null + + val firstIndex = min( + a = interpolateFirstItemIndex( + visibleItems = visibleItemsInfo, itemSize = { layoutInfo.orientation.valueOf(it.size) }, offset = { layoutInfo.orientation.valueOf(it.offset) }, nextItemOnMainAxis = { first -> - visibleItems.find { it != first && it.lane == first.lane } + visibleItemsInfo.find { it != first && it.lane == first.lane } }, itemIndex = itemIndex, - ) - }, - itemPercentVisible = itemPercentVisible@{ itemInfo -> + ), + b = itemsAvailable.toFloat(), + ) + if (firstIndex.isNaN()) return@snapshotFlow null + + val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo -> itemVisibilityPercentage( itemSize = layoutInfo.orientation.valueOf(itemInfo.size), itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), viewportStartOffset = layoutInfo.viewportStartOffset, viewportEndOffset = layoutInfo.viewportEndOffset, ) - }, - reverseLayout = { false }, - ) + } + + val thumbTravelPercent = min( + a = firstIndex / itemsAvailable, + b = 1f, + ) + val thumbSizePercent = min( + a = itemsVisible / itemsAvailable, + b = 1f, + ) + ScrollbarState( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = thumbTravelPercent, + ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { value = it } +}.value + +private inline fun List.floatSumOf(selector: (T) -> Float): Float { + var sum = 0f + for (element in this) { + sum += selector(element) + } + return sum +} \ No newline at end of file