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..fa913cb27 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -0,0 +1,209 @@ +/* + * 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.gestures.ScrollableState +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 + +/** + * The time period for showing the scrollbar thumb after interacting with it, before it fades away + */ +private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L + +/** + * A [Scrollbar] that allows for fast scrolling of content by dragging its thumb. + * 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 orientation the orientation of the scrollbar + * @param onThumbMoved the fast scroll implementation + */ +@Composable +fun ScrollableState.DraggableScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + orientation: Orientation, + onThumbMoved: (Float) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + DraggableScrollbarThumb( + 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 orientation the orientation of the scrollbar + */ +@Composable +fun ScrollableState.DecorativeScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + orientation: Orientation, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + DecorativeScrollbarThumb( + interactionSource = interactionSource, + orientation = orientation, + ) + }, + ) +} + +/** + * A scrollbar thumb that is intended to also be a touch target for fast scrolling. + */ +@Composable +private fun ScrollableState.DraggableScrollbarThumb( + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Vertical -> width(12.dp).fillMaxHeight() + Horizontal -> height(12.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * A decorative scrollbar thumb used solely for communicating a user's position in a list. + */ +@Composable +private fun ScrollableState.DecorativeScrollbarThumb( + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Vertical -> width(2.dp).fillMaxHeight() + Horizontal -> height(2.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * The color of the scrollbar thumb as a function of its interaction state. + * @param interactionSource source of interactions in the scrolling container + */ +@Composable +private fun ScrollableState.scrollbarThumbColor( + interactionSource: InteractionSource, +): Color { + var state by remember { mutableStateOf(Dormant) } + val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + val dragged by interactionSource.collectIsDraggedAsState() + val active = (canScrollForward || canScrollForward) && + (pressed || hovered || dragged || isScrollInProgress) + + 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 -> if (state == Active) { + state = Inactive + delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS) + 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..8c4063b15 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt @@ -0,0 +1,160 @@ +/* + * 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 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 + * 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. + * + * @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition + * is the index of the consecutive item along the major axis. + * */ +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 firstItemSize = itemSize(firstItem) + if (firstItemSize == 0) return Float.NaN + + val itemOffset = offset(firstItem).toFloat() + val offsetPercentage = abs(itemOffset) / firstItemSize + + 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..74d9e0467 --- /dev/null +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -0,0 +1,402 @@ +/* + * 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.core.animateDpAsState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.DragInteraction +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.PointerInputChange +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.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.math.max +import kotlin.math.min + +/** + * The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll + * instead of dragging the scrollbar thumb. + */ +private const val SCROLLBAR_PRESS_DELAY_MS = 10L + +/** + * The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar + * track. + */ +private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f + +/** + * 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, + thumbMovedPercent = 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 [ScrollbarState] with the listed properties + * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size. + * Refers to either the thumb width (for horizontal scrollbars) + * or height (for vertical scrollbars). + * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total + * track size. + */ +fun ScrollbarState( + thumbSizePercent: Float, + thumbMovedPercent: Float, +) = ScrollbarState( + packFloats( + val1 = thumbSizePercent, + val2 = thumbMovedPercent, + ), +) + +/** + * 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.thumbMovedPercent + 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 displacements caused by direct + * interactions on the scrollbar thumb by the user, 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 + + // Using Offset.Unspecified and Float.NaN instead of null + // to prevent unnecessary boxing of primitives + var pressedOffset by remember { mutableStateOf(Offset.Unspecified) } + var draggedOffset by remember { mutableStateOf(Offset.Unspecified) } + + // Used to immediately show drag feedback in the UI while the scrolling implementation + // catches up + var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } + + var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } + + val thumbTravelPercent = when { + interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent + else -> interactionThumbTravelPercent + } + val thumbSizePx = max( + a = state.thumbSizePercent * track.size, + b = with(localDensity) { minThumbSize.toPx() }, + ) + val thumbSizeDp by animateDpAsState( + targetValue = with(localDensity) { thumbSizePx.toDp() }, + label = "scrollbar thumb size", + ) + val thumbMovedPx = min( + a = track.size * thumbTravelPercent, + b = track.size - thumbSizePx, + ) + + // scrollbar track container + Box( + modifier = modifier + .run { + val withHover = interactionSource?.let(::hoverable) ?: this + when (orientation) { + Orientation.Vertical -> withHover.fillMaxHeight() + Orientation.Horizontal -> withHover.fillMaxWidth() + } + } + .onGloballyPositioned { coordinates -> + val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot()) + track = ScrollbarTrack( + max = scrollbarStartCoordinate, + min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size), + ) + } + // Process scrollbar presses + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + try { + // Wait for a long press before scrolling + withTimeout(viewConfiguration.longPressTimeoutMillis) { + tryAwaitRelease() + } + } catch (e: TimeoutCancellationException) { + // Start the press triggered scroll + val initialPress = PressInteraction.Press(offset) + interactionSource?.tryEmit(initialPress) + + pressedOffset = offset + interactionSource?.tryEmit( + when { + tryAwaitRelease() -> PressInteraction.Release(initialPress) + else -> PressInteraction.Cancel(initialPress) + }, + ) + + // End the press + pressedOffset = Offset.Unspecified + } + }, + ) + } + // Process scrollbar drags + .pointerInput(Unit) { + var dragInteraction: DragInteraction.Start? = null + val onDragStart: (Offset) -> Unit = { offset -> + val start = DragInteraction.Start() + dragInteraction = start + interactionSource?.tryEmit(start) + draggedOffset = offset + } + val onDragEnd: () -> Unit = { + dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) } + draggedOffset = Offset.Unspecified + } + val onDragCancel: () -> Unit = { + dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) } + draggedOffset = Offset.Unspecified + } + val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit = + onDrag@{ _, delta -> + if (draggedOffset == Offset.Unspecified) return@onDrag + draggedOffset = when (orientation) { + Orientation.Vertical -> draggedOffset.copy( + y = draggedOffset.y + delta, + ) + + Orientation.Horizontal -> draggedOffset.copy( + x = draggedOffset.x + delta, + ) + } + } + + when (orientation) { + Orientation.Horizontal -> detectHorizontalDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onHorizontalDrag = onDrag, + ) + + Orientation.Vertical -> detectVerticalDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onVerticalDrag = onDrag, + ) + } + }, + ) { + val scrollbarThumbMovedDp = max( + a = with(localDensity) { thumbMovedPx.toDp() }, + b = 0.dp, + ) + // scrollbar thumb container + 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 -> scrollbarThumbMovedDp + }, + x = when (orientation) { + Orientation.Horizontal -> scrollbarThumbMovedDp + Orientation.Vertical -> 0.dp + }, + ), + ) { + thumb() + } + } + + if (onThumbMoved == null) return + + // State that will be read inside the effects that follow + // but will not cause re-triggering of them + val updatedState by rememberUpdatedState(state) + + // Process presses + LaunchedEffect(pressedOffset) { + // Press ended, reset interactionThumbTravelPercent + if (pressedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + + var currentThumbMovedPercent = updatedState.thumbMovedPercent + val destinationThumbMovedPercent = track.thumbPosition( + dimension = orientation.valueOf(pressedOffset), + ) + val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent + val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f + + while (currentThumbMovedPercent != destinationThumbMovedPercent) { + currentThumbMovedPercent = when { + isPositive -> min( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + + else -> max( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + } + onThumbMoved(currentThumbMovedPercent) + interactionThumbTravelPercent = currentThumbMovedPercent + delay(SCROLLBAR_PRESS_DELAY_MS) + } + } + + // Process drags + LaunchedEffect(draggedOffset) { + if (draggedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + val currentTravel = track.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..26f0bb2ae --- /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 }, + firstVisibleItemIndex = { 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 }, + firstVisibleItemIndex = { 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..4d187e269 --- /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 displacements for a [LazyListState] + * @param itemsAvailable the amount of items in the list. + */ +@Composable +fun LazyListState.rememberDraggableScroller( + itemsAvailable: Int, +): (Float) -> Unit = rememberDraggableScroller( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState] + * @param itemsAvailable the amount of items in the grid. + */ +@Composable +fun LazyGridState.rememberDraggableScroller( + itemsAvailable: Int, +): (Float) -> Unit = rememberDraggableScroller( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Generic function to react to [Scrollbar] thumb displacements 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 rememberDraggableScroller( + 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 { + { newPercentage -> percentage = newPercentage } + } +} 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..e46ada015 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,17 +18,22 @@ 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.Box 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 import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -57,6 +62,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.DraggableScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller +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 @@ -169,25 +177,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, + ) + scrollableState.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = scrollableState.rememberDraggableScroller( + 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 145a7dbbc..807fc8f03 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 @@ -40,9 +42,11 @@ import androidx.compose.foundation.layout.heightIn 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells.Adaptive @@ -87,6 +91,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.DraggableScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller +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 +152,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, + ) + } } + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller( + itemsAvailable = itemsAvailable, + ), + ) } TrackScreenViewEvent(screenName = "ForYou") NotificationPermissionEffect() @@ -298,38 +327,51 @@ 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, + ) + } } + lazyGridState.DecorativeScrollbar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .align(Alignment.BottomStart), + state = lazyGridState.scrollbarState(itemsAvailable = onboardingUiState.topics.size), + orientation = Orientation.Horizontal, + ) } } @@ -441,6 +483,25 @@ private fun DeepLinkEffect( } } +private fun feedItemsSize( + feedState: NewsFeedUiState, + onboardingUiState: OnboardingUiState, +): Int { + val feedSize = when (feedState) { + NewsFeedUiState.Loading -> 0 + is NewsFeedUiState.Success -> feedState.feed.size + } + val onboardingSize = when (onboardingUiState) { + OnboardingUiState.Loading, + OnboardingUiState.LoadFailed, + OnboardingUiState.NotShown, + -> 0 + + 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..d865f5e1a 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,28 @@ 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.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding 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.DraggableScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @Composable @@ -37,30 +48,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, + ) + scrollableState.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = scrollableState.rememberDraggableScroller( + itemsAvailable = topics.size, + ), + ) } } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index dc5ee45a8..fede7766b 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -17,17 +17,22 @@ package com.google.samples.apps.nowinandroid.feature.search import androidx.compose.foundation.clickable +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.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.padding import androidx.compose.foundation.layout.safeDrawing +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.grid.GridCells.Adaptive @@ -75,12 +80,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller +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 import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews -import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -289,81 +297,102 @@ private fun SearchResultBody( searchQuery: String = "", ) { val state = rememberLazyGridState() - LazyVerticalGrid( - columns = Adaptive(300.dp), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + Box( modifier = Modifier - .fillMaxSize() - .testTag("search:newsResources"), - state = state, + .fillMaxSize(), ) { - if (topics.isNotEmpty()) { - item( - span = { - GridItemSpan(maxLineSpan) - }, - ) { - Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.topics)) - } + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:newsResources"), + state = state, + ) { + if (topics.isNotEmpty()) { + item( + span = { + GridItemSpan(maxLineSpan) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) + ) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.topics)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + topics.forEach { followableTopic -> + val topicId = followableTopic.topic.id + item( + key = "topic-$topicId", // Append a prefix to distinguish a key for news resources + span = { + GridItemSpan(maxLineSpan) + }, + ) { + InterestsItem( + name = followableTopic.topic.name, + following = followableTopic.isFollowed, + description = followableTopic.topic.shortDescription, + topicImageUrl = followableTopic.topic.imageUrl, + onClick = { + // Pass the current search query to ViewModel to save it as recent searches + onSearchTriggered(searchQuery) + onTopicClick(topicId) + }, + onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + ) + } + } } - topics.forEach { followableTopic -> - val topicId = followableTopic.topic.id + + if (newsResources.isNotEmpty()) { item( - key = "topic-$topicId", // Append a prefix to distinguish a key for news resources span = { GridItemSpan(maxLineSpan) }, ) { - InterestsItem( - name = followableTopic.topic.name, - following = followableTopic.isFollowed, - description = followableTopic.topic.shortDescription, - topicImageUrl = followableTopic.topic.imageUrl, - onClick = { - // Pass the current search query to ViewModel to save it as recent searches - onSearchTriggered(searchQuery) - onTopicClick(topicId) + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.updates)) + } }, - onFollowButtonClick = { onFollowButtonClick(topicId, it) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } - } - } - if (newsResources.isNotEmpty()) { - item( - span = { - GridItemSpan(maxLineSpan) - }, - ) { - Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.updates)) - } + newsFeed( + feedState = Success(feed = newsResources), + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, + onExpandedCardClick = { + onSearchTriggered(searchQuery) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } - - newsFeed( - feedState = NewsFeedUiState.Success(feed = newsResources), - onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, - onNewsResourceViewed = onNewsResourceViewed, - onTopicClick = onTopicClick, - onExpandedCardClick = { - onSearchTriggered(searchQuery) - }, - ) } + val itemsAvailable = topics.size + newsResources.size + val scrollbarState = state.scrollbarState( + itemsAvailable = itemsAvailable, + ) + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller( + itemsAvailable = itemsAvailable, + ), + ) } } 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..075e7f881 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.DraggableScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller +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,77 @@ 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, + ) + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller( + 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 } }