diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt new file mode 100644 index 000000000..bed9e6b44 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -0,0 +1,216 @@ +/* + * 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 + * + * 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. + */ + +package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.Orientation.Horizontal +import androidx.compose.foundation.gestures.Orientation.Vertical +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive +import kotlinx.coroutines.delay + +/** + * A [Scrollbar] that allows for fast scrolling of content. + * Its thumb disappears when the scrolling container is dormant. + * @param modifier a [Modifier] for the [Scrollbar] + * @param state the driving state for the [Scrollbar] + * @param scrollInProgress a flag indicating if the scrolling container for the scrollbar is + * currently scrolling + * @param orientation the orientation of the scrollbar + * @param onThumbMoved the fast scroll implementation + */ +@Composable +fun FastScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + scrollInProgress: Boolean, + orientation: Orientation, + onThumbMoved: (Float) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + FastScrollbarThumb( + scrollInProgress = scrollInProgress, + interactionSource = interactionSource, + orientation = orientation, + ) + }, + onThumbMoved = onThumbMoved, + ) +} + +/** + * A simple [Scrollbar]. + * Its thumb disappears when the scrolling container is dormant. + * @param modifier a [Modifier] for the [Scrollbar] + * @param state the driving state for the [Scrollbar] + * @param scrollInProgress a flag indicating if the scrolling container for the scrollbar is + * currently scrolling + * @param orientation the orientation of the scrollbar + */ +@Composable +fun DecorativeScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + scrollInProgress: Boolean, + orientation: Orientation, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + DecorativeScrollbarThumb( + interactionSource = interactionSource, + scrollInProgress = scrollInProgress, + orientation = orientation, + ) + }, + ) +} + +/** + * A scrollbar thumb that is intended to also be a touch target for fast scrolling. + */ +@Composable +private fun FastScrollbarThumb( + scrollInProgress: Boolean, + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Vertical -> width(12.dp).fillMaxHeight() + Horizontal -> height(12.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + scrollInProgress = scrollInProgress, + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * A decorative scrollbar thumb for communicating a user's position in a list solely. + */ +@Composable +private fun DecorativeScrollbarThumb( + scrollInProgress: Boolean, + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Vertical -> width(2.dp).fillMaxHeight() + Horizontal -> height(2.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + scrollInProgress = scrollInProgress, + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * The color of the scrollbar thumb as a function of its interaction state. + * @param scrollInProgress if the scrolling container is currently scrolling + * @param interactionSource source of interactions in the scrolling container + */ +@Composable +private fun scrollbarThumbColor( + scrollInProgress: Boolean, + interactionSource: InteractionSource, +): Color { + var state by remember { mutableStateOf(Active) } + val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + val dragged by interactionSource.collectIsDraggedAsState() + val active = pressed || hovered || dragged || scrollInProgress + + val color by animateColorAsState( + targetValue = when (state) { + Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f) + Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + Dormant -> Color.Transparent + }, + animationSpec = SpringSpec( + stiffness = Spring.StiffnessLow, + ), + label = "Scrollbar thumb color", + ) + LaunchedEffect(active) { + when (active) { + true -> state = Active + false -> { + state = Inactive + delay(2_000) + state = Dormant + } + } + } + + return color +} + +private enum class ThumbState { + Active, Inactive, Dormant +} diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt new file mode 100644 index 000000000..d45c5781a --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt @@ -0,0 +1,156 @@ +/* + * 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 + * + * 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. + */ + +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 firstItemIndex 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 firstItemIndex: 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 + + // Add the item offset for interpolation between scroll indices + val firstIndex = min( + a = firstItemIndex(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, + thumbTravelPercent = 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 + * progression. + * @param visibleItems a list of items currently visible in the layout. + * @param itemSize a lookup function for the size of an item in the layout. + * @param offset a lookup function for the offset of an item relative to the start of the view port. + * @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction + * of the scroll. + * @param itemIndex a lookup function for index of an item in the layout relative to + * the total amount of items available. + * */ +internal inline fun LazyState.interpolateFirstItemIndex( + visibleItems: List, + crossinline itemSize: LazyState.(LazyStateItem) -> Int, + crossinline offset: LazyState.(LazyStateItem) -> Int, + crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?, + crossinline itemIndex: (LazyStateItem) -> Int, +): Float { + if (visibleItems.isEmpty()) return 0f + + val firstItem = visibleItems.first() + val firstItemIndex = itemIndex(firstItem) + + if (firstItemIndex < 0) return Float.NaN + + val itemOffset = offset(firstItem).toFloat() + val offsetPercentage = abs(itemOffset) / itemSize(firstItem) + + val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage + + val nextItemIndex = itemIndex(nextItem) + + return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage) +} + +/** + * Returns the percentage of an item that is currently visible in the view port. + * @param itemSize the size of the item + * @param itemStartOffset the start offset of the item relative to the view port start + * @param viewportStartOffset the start offset of the view port + * @param viewportEndOffset the end offset of the view port + */ +internal fun itemVisibilityPercentage( + itemSize: Int, + itemStartOffset: Int, + viewportStartOffset: Int, + viewportEndOffset: Int, +): Float { + if (itemSize == 0) return 0f + val itemEnd = itemStartOffset + itemSize + val startOffset = when { + itemStartOffset > viewportStartOffset -> 0 + else -> abs(abs(viewportStartOffset) - abs(itemStartOffset)) + } + val endOffset = when { + itemEnd < viewportEndOffset -> 0 + else -> abs(abs(itemEnd) - abs(viewportEndOffset)) + } + val size = itemSize.toFloat() + return (size - startOffset - endOffset) / size +} diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt new file mode 100644 index 000000000..4984946bc --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -0,0 +1,340 @@ +/* + * Copyright 2021 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 + * + * 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. + */ + +package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlinx.coroutines.delay +import kotlin.math.max +import kotlin.math.min + +/** + * Class definition for the core properties of a scroll bar + */ +@Immutable +@JvmInline +value class ScrollbarState internal constructor( + internal val packedValue: Long, +) { + companion object { + val FULL = ScrollbarState( + thumbSizePercent = 1f, + thumbTravelPercent = 0f, + ) + } +} + +/** + * Class definition for the core properties of a scroll bar track + */ +@Immutable +@JvmInline +private value class ScrollbarTrack( + val packedValue: Long, +) { + constructor( + max: Float, + min: Float, + ) : this(packFloats(max, min)) +} + +/** + * Creates a scrollbar state with the listed properties + * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size + * @param thumbTravelPercent the distance the thumb has traveled as a percentage of total track size + */ +fun ScrollbarState( + thumbSizePercent: Float, + thumbTravelPercent: Float, +) = ScrollbarState( + packFloats( + val1 = thumbSizePercent, + val2 = thumbTravelPercent, + ), +) + +/** + * Returns the thumb size of the scrollbar as a percentage of the total track size + */ +val ScrollbarState.thumbSizePercent + get() = unpackFloat1(packedValue) + +/** + * Returns the distance the thumb has traveled as a percentage of total track size + */ +val ScrollbarState.thumbTravelPercent + get() = unpackFloat2(packedValue) + +/** + * Returns the size of the scrollbar track in pixels + */ +private val ScrollbarTrack.size + get() = unpackFloat2(packedValue) - unpackFloat1(packedValue) + +/** + * Returns the position of the scrollbar thumb on the track as a percentage + */ +private fun ScrollbarTrack.thumbPosition( + dimension: Float, +): Float = max( + a = min( + a = dimension / size, + b = 1f, + ), + b = 0f, +) + +/** + * Returns the value of [offset] along the axis specified by [this] + */ +internal fun Orientation.valueOf(offset: Offset) = when (this) { + Orientation.Horizontal -> offset.x + Orientation.Vertical -> offset.y +} + +/** + * Returns the value of [intSize] along the axis specified by [this] + */ +internal fun Orientation.valueOf(intSize: IntSize) = when (this) { + Orientation.Horizontal -> intSize.width + Orientation.Vertical -> intSize.height +} + +/** + * Returns the value of [intOffset] along the axis specified by [this] + */ +internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { + Orientation.Horizontal -> intOffset.x + Orientation.Vertical -> intOffset.y +} + +/** + * A Composable for drawing a Scrollbar + * @param orientation the scroll direction of the scrollbar + * @param state the state describing the position of the scrollbar + * @param minThumbSize the minimum size of the scrollbar thumb + * @param interactionSource allows for observing the state of the scroll bar + * @param thumb a composable for drawing the scrollbar thumb + * @param onThumbMoved an function for reacting to scroll bar interactions, for example implementing + * a fast scroll + */ +@Composable +fun Scrollbar( + modifier: Modifier = Modifier, + orientation: Orientation, + state: ScrollbarState, + minThumbSize: Dp = 40.dp, + interactionSource: MutableInteractionSource? = null, + thumb: @Composable () -> Unit, + onThumbMoved: ((Float) -> Unit)? = null, +) { + val localDensity = LocalDensity.current + var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } + var pressedOffset by remember { mutableStateOf(Offset.Unspecified) } + var draggedOffset by remember { mutableStateOf(Offset.Unspecified) } + + var track by remember { mutableStateOf(ScrollbarTrack(0)) } + val updatedState by rememberUpdatedState(state) + val updatedTrack by rememberUpdatedState(track) + + val thumbSizePercent = state.thumbSizePercent + val thumbTravelPercent = when { + interactionThumbTravelPercent.isNaN() -> state.thumbTravelPercent + else -> interactionThumbTravelPercent + } + val thumbSizePx = max( + a = thumbSizePercent * track.size, + b = with(localDensity) { minThumbSize.toPx() }, + ) + + val thumbSizeDp by animateDpAsState( + targetValue = with(localDensity) { thumbSizePx.toDp() }, + label = "thumb size", + ) + + val thumbTravelPx = min( + a = track.size * thumbTravelPercent, + b = track.size - thumbSizePx, + ) + + val draggableState = rememberDraggableState { delta -> + if (draggedOffset == Offset.Unspecified) return@rememberDraggableState + + draggedOffset = when (orientation) { + Orientation.Vertical -> draggedOffset.copy( + y = draggedOffset.y + delta, + ) + + Orientation.Horizontal -> draggedOffset.copy( + x = draggedOffset.x + delta, + ) + } + } + Box( + modifier = modifier + .run { + when (orientation) { + Orientation.Vertical -> fillMaxHeight() + Orientation.Horizontal -> fillMaxWidth() + } + } + .onGloballyPositioned { coordinates -> + val position = orientation.valueOf(coordinates.positionInRoot()) + track = ScrollbarTrack( + max = position, + min = position + orientation.valueOf(coordinates.size), + ) + } + // Process scrollbar presses + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + val initialPress = PressInteraction.Press(offset) + + interactionSource?.tryEmit(initialPress) + pressedOffset = offset + + interactionSource?.tryEmit( + if (tryAwaitRelease()) { + PressInteraction.Release(initialPress) + } else { + PressInteraction.Cancel(initialPress) + }, + ) + pressedOffset = Offset.Unspecified + }, + ) + } + // Process scrollbar drags + .draggable( + state = draggableState, + orientation = orientation, + interactionSource = interactionSource, + onDragStarted = { startedPosition: Offset -> + draggedOffset = startedPosition + }, + onDragStopped = { + draggedOffset = Offset.Unspecified + }, + ), + ) { + val offset = max( + a = with(localDensity) { thumbTravelPx.toDp() }, + b = 0.dp, + ) + Box( + modifier = Modifier + .align(Alignment.TopStart) + .run { + when (orientation) { + Orientation.Horizontal -> width(thumbSizeDp) + Orientation.Vertical -> height(thumbSizeDp) + } + } + .offset( + y = when (orientation) { + Orientation.Horizontal -> 0.dp + Orientation.Vertical -> offset + }, + x = when (orientation) { + Orientation.Horizontal -> offset + Orientation.Vertical -> 0.dp + }, + ), + ) { + thumb() + } + } + + if (onThumbMoved == null) return + + // Process presses + LaunchedEffect(pressedOffset) { + if (pressedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + + var currentTravel = updatedState.thumbTravelPercent + val destinationTravel = updatedTrack.thumbPosition( + dimension = orientation.valueOf(pressedOffset), + ) + val isPositive = currentTravel < destinationTravel + // TODO: Come up with a better heuristic for jumps + val delta = if (isPositive) 0.1f else -0.1f + + while (currentTravel != destinationTravel) { + currentTravel = + if (isPositive) { + min(currentTravel + delta, destinationTravel) + } else { + max(currentTravel + delta, destinationTravel) + } + onThumbMoved(currentTravel) + interactionThumbTravelPercent = currentTravel + // TODO: Define this more thoroughly + delay(100) + } + } + + // Process drags + LaunchedEffect(draggedOffset) { + if (draggedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + val currentTravel = updatedTrack.thumbPosition( + dimension = orientation.valueOf(draggedOffset), + ) + onThumbMoved(currentTravel) + interactionThumbTravelPercent = currentTravel + } +} diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt new file mode 100644 index 000000000..aea4cd661 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2021 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 + * + * 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. + */ + +package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable + +/** + * Calculates a [ScrollbarState] driven by the changes in a [LazyListState]. + * + * @param itemsAvailable the total amount of items available to scroll in the lazy list. + * @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable]. + */ +@Composable +fun LazyListState.scrollbarState( + itemsAvailable: Int, + itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, +): ScrollbarState = + scrollbarState( + itemsAvailable = itemsAvailable, + visibleItems = { layoutInfo.visibleItemsInfo }, + firstItemIndex = { visibleItems -> + interpolateFirstItemIndex( + visibleItems = visibleItems, + itemSize = { it.size }, + offset = { it.offset }, + nextItemOnMainAxis = { first -> visibleItems.find { it != first } }, + itemIndex = itemIndex, + ) + }, + itemPercentVisible = itemPercentVisible@{ itemInfo -> + itemVisibilityPercentage( + itemSize = itemInfo.size, + itemStartOffset = itemInfo.offset, + viewportStartOffset = layoutInfo.viewportStartOffset, + viewportEndOffset = layoutInfo.viewportEndOffset, + ) + }, + reverseLayout = { layoutInfo.reverseLayout }, + ) + +/** + * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] + * + * @param itemsAvailable the total amount of items available to scroll in the grid. + * @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable]. + */ +@Composable +fun LazyGridState.scrollbarState( + itemsAvailable: Int, + itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, +): ScrollbarState = + scrollbarState( + itemsAvailable = itemsAvailable, + visibleItems = { layoutInfo.visibleItemsInfo }, + firstItemIndex = { visibleItems -> + interpolateFirstItemIndex( + visibleItems = visibleItems, + itemSize = { + layoutInfo.orientation.valueOf(it.size) + }, + offset = { layoutInfo.orientation.valueOf(it.offset) }, + nextItemOnMainAxis = { first -> + when (layoutInfo.orientation) { + Orientation.Vertical -> visibleItems.find { + it != first && it.row != first.row + } + + Orientation.Horizontal -> visibleItems.find { + it != first && it.column != first.column + } + } + }, + itemIndex = itemIndex, + ) + }, + itemPercentVisible = itemPercentVisible@{ itemInfo -> + itemVisibilityPercentage( + itemSize = layoutInfo.orientation.valueOf(itemInfo.size), + itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), + viewportStartOffset = layoutInfo.viewportStartOffset, + viewportEndOffset = layoutInfo.viewportEndOffset, + ) + }, + reverseLayout = { layoutInfo.reverseLayout }, + ) diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt new file mode 100644 index 000000000..f03e21c85 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt @@ -0,0 +1,74 @@ +/* + * 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 + * + * 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. + */ + +package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +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.rememberUpdatedState +import androidx.compose.runtime.setValue + +/** + * Remembers a function to react to [Scrollbar] thumb position movements for a [LazyListState] + * @param itemsAvailable the amount of items in the list. + */ +@Composable +fun LazyListState.rememberThumbInteractions( + itemsAvailable: Int, +): (Float) -> Unit = rememberThumbInteractions( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Remembers a function to react to [Scrollbar] thumb position movements for a [LazyGridState] + * @param itemsAvailable the amount of items in the grid. + */ +@Composable +fun LazyGridState.rememberThumbInteractions( + itemsAvailable: Int, +): (Float) -> Unit = rememberThumbInteractions( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Generic function to react to [Scrollbar] thumb interactions in a lazy layout. + * @param itemsAvailable the total amount of items available to scroll in the layout. + * @param scroll a function to be invoked when an index has been identified to scroll to. + */ +@Composable +private inline fun rememberThumbInteractions( + itemsAvailable: Int, + crossinline scroll: suspend (index: Int) -> Unit, +): (Float) -> Unit { + var percentage by remember { mutableStateOf(Float.NaN) } + val itemCount by rememberUpdatedState(itemsAvailable) + + LaunchedEffect(percentage) { + if (percentage.isNaN()) return@LaunchedEffect + val indexToFind = (itemCount * percentage).toInt() + scroll(indexToFind) + } + return remember { + { percentage = it } + } +} diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 0f15e29b0..08776b210 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -18,11 +18,13 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -57,6 +59,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberThumbInteractions +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource @@ -112,9 +117,13 @@ internal fun BookmarksScreen( if (shouldDisplayUndoBookmark) { val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText) if (snackBarResult) { - undoBookmarkRemoval() - } else { - clearUndoState() + + undoBookmarkRemoval() + } + + else { + clearUndoState() + } } } @@ -130,18 +139,20 @@ internal fun BookmarksScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - when (feedState) { - Loading -> LoadingState(modifier) - is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid( - feedState, - removeFromBookmarks, - onNewsResourceViewed, - onTopicClick, - modifier, - ) - } else { - EmptyState(modifier) + + when (feedState) { + Loading -> LoadingState(modifier) + is Success -> if (feedState.feed.isNotEmpty()) { + BookmarksGrid( + feedState, + removeFromBookmarks, + onNewsResourceViewed, + onTopicClick, + modifier, + ) + } else { + EmptyState(modifier) + } } @@ -169,25 +180,49 @@ private fun BookmarksGrid( ) { val scrollableState = rememberLazyGridState() TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid") - LazyVerticalGrid( - columns = Adaptive(300.dp), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - state = scrollableState, + Box( modifier = modifier - .fillMaxSize() - .testTag("bookmarks:feed"), + .fillMaxSize(), ) { - newsFeed( - feedState = feedState, - onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, - onNewsResourceViewed = onNewsResourceViewed, - onTopicClick = onTopicClick, - ) - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + state = scrollableState, + modifier = Modifier + .fillMaxSize() + .testTag("bookmarks:feed"), + ) { + newsFeed( + feedState = feedState, + onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, + ) + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } + val itemsAvailable = when (feedState) { + Loading -> 1 + is Success -> feedState.feed.size + } + val scrollbarState = scrollableState.scrollbarState( + itemsAvailable = itemsAvailable, + ) + FastScrollbar( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + scrollInProgress = scrollableState.isScrollInProgress, + onThumbMoved = scrollableState.rememberThumbInteractions( + itemsAvailable = itemsAvailable, + ), + ) } } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index eaa0c58fa..db33d9e93 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -87,6 +89,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicA import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DecorativeScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberThumbInteractions +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource @@ -144,75 +150,96 @@ internal fun ForYouScreen( // This code should be called when the UI is ready for use and relates to Time To Full Display. ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading } + val itemsAvailable = feedItemsSize(feedState, onboardingUiState) + val state = rememberLazyGridState() + val scrollbarState = state.scrollbarState( + itemsAvailable = itemsAvailable, + ) TrackScrollJank(scrollableState = state, stateName = "forYou:feed") - LazyVerticalGrid( - columns = Adaptive(300.dp), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + Box( modifier = modifier - .fillMaxSize() - .testTag("forYou:feed"), - state = state, + .fillMaxSize(), ) { - onboarding( - onboardingUiState = onboardingUiState, - onTopicCheckedChanged = onTopicCheckedChanged, - saveFollowedTopics = saveFollowedTopics, - // Custom LayoutModifier to remove the enforced parent 16.dp contentPadding - // from the LazyVerticalGrid and enable edge-to-edge scrolling for this section - interestsItemModifier = Modifier.layout { measurable, constraints -> - val placeable = measurable.measure( - constraints.copy( - maxWidth = constraints.maxWidth + 32.dp.roundToPx(), - ), - ) - layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } - }, - ) + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .testTag("forYou:feed"), + state = state, + ) { + onboarding( + onboardingUiState = onboardingUiState, + onTopicCheckedChanged = onTopicCheckedChanged, + saveFollowedTopics = saveFollowedTopics, + // Custom LayoutModifier to remove the enforced parent 16.dp contentPadding + // from the LazyVerticalGrid and enable edge-to-edge scrolling for this section + interestsItemModifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + maxWidth = constraints.maxWidth + 32.dp.roundToPx(), + ), + ) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + }, + ) - newsFeed( - feedState = feedState, - onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, - onNewsResourceViewed = onNewsResourceViewed, - onTopicClick = onTopicClick, - ) + newsFeed( + feedState = feedState, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, + ) - item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") { - Column { - Spacer(modifier = Modifier.height(8.dp)) - // Add space for the content to clear the "offline" snackbar. - // TODO: Check that the Scaffold handles this correctly in NiaApp - // if (isOffline) Spacer(modifier = Modifier.height(48.dp)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") { + Column { + Spacer(modifier = Modifier.height(8.dp)) + // Add space for the content to clear the "offline" snackbar. + // TODO: Check that the Scaffold handles this correctly in NiaApp + // if (isOffline) Spacer(modifier = Modifier.height(48.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } - } - AnimatedVisibility( - visible = isSyncing || isFeedLoading || isOnboardingLoading, - enter = slideInVertically( - initialOffsetY = { fullHeight -> -fullHeight }, - ) + fadeIn(), - exit = slideOutVertically( - targetOffsetY = { fullHeight -> -fullHeight }, - ) + fadeOut(), - ) { - val loadingContentDescription = stringResource(id = R.string.for_you_loading) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + AnimatedVisibility( + visible = isSyncing || isFeedLoading || isOnboardingLoading, + enter = slideInVertically( + initialOffsetY = { fullHeight -> -fullHeight }, + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> -fullHeight }, + ) + fadeOut(), ) { - NiaOverlayLoadingWheel( + val loadingContentDescription = stringResource(id = R.string.for_you_loading) + Box( modifier = Modifier - .align(Alignment.Center), - contentDesc = loadingContentDescription, - ) + .fillMaxWidth() + .padding(top = 8.dp), + ) { + NiaOverlayLoadingWheel( + modifier = Modifier + .align(Alignment.Center), + contentDesc = loadingContentDescription, + ) + } } + FastScrollbar( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + scrollInProgress = state.isScrollInProgress, + onThumbMoved = state.rememberThumbInteractions( + itemsAvailable = itemsAvailable, + ), + ) } TrackScreenViewEvent(screenName = "ForYou") NotificationPermissionEffect() @@ -298,38 +325,52 @@ private fun TopicSelection( TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag) - LazyHorizontalGrid( - state = lazyGridState, - rows = GridCells.Fixed(3), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(24.dp), + Box( modifier = modifier - // LazyHorizontalGrid has to be constrained in height. - // However, we can't set a fixed height because the horizontal grid contains - // vertical text that can be rescaled. - // When the fontScale is at most 1, we know that the horizontal grid will be at most - // 240dp tall, so this is an upper bound for when the font scale is at most 1. - // When the fontScale is greater than 1, the height required by the text inside the - // horizontal grid will increase by at most the same factor, so 240sp is a valid - // upper bound for how much space we need in that case. - // The maximum of these two bounds is therefore a valid upper bound in all cases. - .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) - .fillMaxWidth() - .testTag(topicSelectionTestTag), + .fillMaxWidth(), ) { - items( - items = onboardingUiState.topics, - key = { it.topic.id }, + LazyHorizontalGrid( + state = lazyGridState, + rows = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(24.dp), + modifier = Modifier + // LazyHorizontalGrid has to be constrained in height. + // However, we can't set a fixed height because the horizontal grid contains + // vertical text that can be rescaled. + // When the fontScale is at most 1, we know that the horizontal grid will be at most + // 240dp tall, so this is an upper bound for when the font scale is at most 1. + // When the fontScale is greater than 1, the height required by the text inside the + // horizontal grid will increase by at most the same factor, so 240sp is a valid + // upper bound for how much space we need in that case. + // The maximum of these two bounds is therefore a valid upper bound in all cases. + .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() })) + .fillMaxWidth() + .testTag(topicSelectionTestTag), ) { - SingleTopicButton( - name = it.topic.name, - topicId = it.topic.id, - imageUrl = it.topic.imageUrl, - isSelected = it.isFollowed, - onClick = onTopicCheckedChanged, - ) + items( + items = onboardingUiState.topics, + key = { it.topic.id }, + ) { + SingleTopicButton( + name = it.topic.name, + topicId = it.topic.id, + imageUrl = it.topic.imageUrl, + isSelected = it.isFollowed, + onClick = onTopicCheckedChanged, + ) + } } + DecorativeScrollbar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .align(Alignment.BottomStart), + state = lazyGridState.scrollbarState(itemsAvailable = onboardingUiState.topics.size), + orientation = Orientation.Horizontal, + scrollInProgress = lazyGridState.isScrollInProgress, + ) } } @@ -442,6 +483,26 @@ private fun DeepLinkEffect( } } +private fun feedItemsSize( + feedState: NewsFeedUiState, + onboardingUiState: OnboardingUiState, +): Int { + val feedSize = when (feedState) { + NewsFeedUiState.Loading -> 1 + is NewsFeedUiState.Success -> feedState.feed.size + } + val onboardingSize = when (onboardingUiState) { + OnboardingUiState.LoadFailed, + OnboardingUiState.NotShown, + -> 0 + + OnboardingUiState.Loading, + is OnboardingUiState.Shown, + -> 1 + } + return feedSize + onboardingSize +} + @DevicePreviews @Composable fun ForYouScreenPopulatedFeed( diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index d55cd9a38..0755f4e5a 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -16,17 +16,26 @@ package com.google.samples.apps.nowinandroid.feature.interests +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberThumbInteractions +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @Composable @@ -37,30 +46,52 @@ fun TopicsTabContent( modifier: Modifier = Modifier, withBottomSpacer: Boolean = true, ) { - LazyColumn( + Box( modifier = modifier - .padding(horizontal = 24.dp) - .testTag("interests:topics"), - contentPadding = PaddingValues(vertical = 16.dp), + .fillMaxWidth(), ) { - topics.forEach { followableTopic -> - val topicId = followableTopic.topic.id - item(key = topicId) { - InterestsItem( - name = followableTopic.topic.name, - following = followableTopic.isFollowed, - description = followableTopic.topic.shortDescription, - topicImageUrl = followableTopic.topic.imageUrl, - onClick = { onTopicClick(topicId) }, - onFollowButtonClick = { onFollowButtonClick(topicId, it) }, - ) + val scrollableState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .padding(horizontal = 24.dp) + .testTag("interests:topics"), + contentPadding = PaddingValues(vertical = 16.dp), + state = scrollableState, + ) { + topics.forEach { followableTopic -> + val topicId = followableTopic.topic.id + item(key = topicId) { + InterestsItem( + name = followableTopic.topic.name, + following = followableTopic.isFollowed, + description = followableTopic.topic.shortDescription, + topicImageUrl = followableTopic.topic.imageUrl, + onClick = { onTopicClick(topicId) }, + onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + ) + } } - } - if (withBottomSpacer) { - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } + val scrollbarState = scrollableState.scrollbarState( + itemsAvailable = topics.size, + ) + FastScrollbar( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + scrollInProgress = scrollableState.isScrollInProgress, + onThumbMoved = scrollableState.rememberThumbInteractions( + itemsAvailable = topics.size, + ), + ) } } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index b987a2752..e2a14c06f 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -17,16 +17,21 @@ package com.google.samples.apps.nowinandroid.feature.topic import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -49,6 +54,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicA import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberThumbInteractions +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @@ -97,45 +105,78 @@ internal fun TopicScreen( ) { val state = rememberLazyListState() TrackScrollJank(scrollableState = state, stateName = "topic:screen") - LazyColumn( - state = state, + Box( modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, ) { - item { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) - } - when (topicUiState) { - TopicUiState.Loading -> item { - NiaLoadingWheel( - modifier = modifier, - contentDesc = stringResource(id = string.topic_loading), - ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) } + when (topicUiState) { + TopicUiState.Loading -> item { + NiaLoadingWheel( + modifier = modifier, + contentDesc = stringResource(id = string.topic_loading), + ) + } - TopicUiState.Error -> TODO() - is TopicUiState.Success -> { - item { - TopicToolbar( - onBackClick = onBackClick, - onFollowClick = onFollowClick, - uiState = topicUiState.followableTopic, + TopicUiState.Error -> TODO() + is TopicUiState.Success -> { + item { + TopicToolbar( + onBackClick = onBackClick, + onFollowClick = onFollowClick, + uiState = topicUiState.followableTopic, + ) + } + TopicBody( + name = topicUiState.followableTopic.topic.name, + description = topicUiState.followableTopic.topic.longDescription, + news = newsUiState, + imageUrl = topicUiState.followableTopic.topic.imageUrl, + onBookmarkChanged = onBookmarkChanged, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, ) } - TopicBody( - name = topicUiState.followableTopic.topic.name, - description = topicUiState.followableTopic.topic.longDescription, - news = newsUiState, - imageUrl = topicUiState.followableTopic.topic.imageUrl, - onBookmarkChanged = onBookmarkChanged, - onNewsResourceViewed = onNewsResourceViewed, - onTopicClick = onTopicClick, - ) + } + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) - } + val itemsAvailable = topicItemsSize(topicUiState, newsUiState) + val scrollbarState = state.scrollbarState( + itemsAvailable = itemsAvailable, + ) + FastScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + scrollInProgress = state.isScrollInProgress, + onThumbMoved = state.rememberThumbInteractions( + itemsAvailable = itemsAvailable, + ), + ) + } +} + +private fun topicItemsSize( + topicUiState: TopicUiState, + newsUiState: NewsUiState, +) = when (topicUiState) { + TopicUiState.Error -> 0 // Nothing + TopicUiState.Loading -> 1 // Loading bar + is TopicUiState.Success -> when (newsUiState) { + NewsUiState.Error -> 0 // Nothing + NewsUiState.Loading -> 1 // Loading bar + is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header } }