From 31b4841cb222110815361bc33815ef680a6423a5 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 11 May 2023 09:31:34 +0100 Subject: [PATCH 01/14] Add scrollbars to app --- .../component/scrollbar/AppScrollbars.kt | 216 +++++++++++ .../scrollbar/LazyScrollbarUtilities.kt | 156 ++++++++ .../component/scrollbar/Scrollbar.kt | 340 ++++++++++++++++++ .../component/scrollbar/ScrollbarExt.kt | 104 ++++++ .../component/scrollbar/ThumbExt.kt | 74 ++++ .../feature/bookmarks/BookmarksScreen.kt | 97 +++-- .../feature/foryou/ForYouScreen.kt | 231 +++++++----- .../feature/interests/TabContent.kt | 69 +++- .../nowinandroid/feature/topic/TopicScreen.kt | 103 ++++-- 9 files changed, 1224 insertions(+), 166 deletions(-) create mode 100644 core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt create mode 100644 core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt create mode 100644 core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt create mode 100644 core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt create mode 100644 core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt 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 } } From 795c5e32a9bcf3afc53f683763eb127083a6b813 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 11 May 2023 09:54:05 +0100 Subject: [PATCH 02/14] Add hoverable to scrollbar --- .../core/designsystem/component/scrollbar/Scrollbar.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 4984946bc..dfe0ce1f0 100644 --- 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 @@ -21,6 +21,7 @@ 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.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box @@ -221,9 +222,10 @@ fun Scrollbar( Box( modifier = modifier .run { + val withHover = interactionSource?.let(::hoverable) ?: this when (orientation) { - Orientation.Vertical -> fillMaxHeight() - Orientation.Horizontal -> fillMaxWidth() + Orientation.Vertical -> withHover.fillMaxHeight() + Orientation.Horizontal -> withHover.fillMaxWidth() } } .onGloballyPositioned { coordinates -> From 3aec70504873b9ec3bd838823ae1c4f53c7e93cc Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 11 May 2023 11:25:48 +0100 Subject: [PATCH 03/14] Add padding to scrollbars --- .../apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt | 3 +++ .../samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt | 3 +++ .../samples/apps/nowinandroid/feature/interests/TabContent.kt | 3 +++ 3 files changed, 9 insertions(+) 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 08776b210..cb65984e6 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 @@ -30,7 +30,9 @@ 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 @@ -214,6 +216,7 @@ private fun BookmarksGrid( FastScrollbar( modifier = Modifier .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 2.dp) .align(Alignment.CenterEnd), state = scrollbarState, 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 db33d9e93..67551f4b0 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 @@ -42,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 @@ -231,6 +233,7 @@ internal fun ForYouScreen( FastScrollbar( modifier = Modifier .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 2.dp) .align(Alignment.CenterEnd), state = scrollbarState, 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 0755f4e5a..85d7c20e8 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 @@ -25,7 +25,9 @@ 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 @@ -84,6 +86,7 @@ fun TopicsTabContent( FastScrollbar( modifier = Modifier .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 2.dp) .align(Alignment.CenterEnd), state = scrollbarState, From d6a265093d75440d0cce803cbc3e9d30c1f3877d Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 11 May 2023 13:58:44 +0100 Subject: [PATCH 04/14] Extract scrollbar values to constants --- .../core/designsystem/component/scrollbar/Scrollbar.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index dfe0ce1f0..3d33247d9 100644 --- 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 @@ -57,6 +57,9 @@ import kotlinx.coroutines.delay import kotlin.math.max import kotlin.math.min +private const val SCROLLBAR_PRESS_DELAY = 100L +private const val SCROLLBAR_PRESS_DELTA = 0.1f + /** * Class definition for the core properties of a scroll bar */ @@ -310,8 +313,7 @@ fun Scrollbar( 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 + val delta = SCROLLBAR_PRESS_DELTA * if (isPositive) 1f else -1f while (currentTravel != destinationTravel) { currentTravel = @@ -322,8 +324,7 @@ fun Scrollbar( } onThumbMoved(currentTravel) interactionThumbTravelPercent = currentTravel - // TODO: Define this more thoroughly - delay(100) + delay(SCROLLBAR_PRESS_DELAY) } } From bda31e9d153c574128cff19f472ac6b00aca4553 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Fri, 12 May 2023 10:33:49 +0100 Subject: [PATCH 05/14] Default scrollbars to dormant --- .../core/designsystem/component/scrollbar/AppScrollbars.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bed9e6b44..2a8827c3d 100644 --- 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 @@ -180,7 +180,7 @@ private fun scrollbarThumbColor( scrollInProgress: Boolean, interactionSource: InteractionSource, ): Color { - var state by remember { mutableStateOf(Active) } + var state by remember { mutableStateOf(Dormant) } val pressed by interactionSource.collectIsPressedAsState() val hovered by interactionSource.collectIsHoveredAsState() val dragged by interactionSource.collectIsDraggedAsState() From 4858167b24ced98b26ec069c0f3bc6540b47a418 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Sat, 13 May 2023 12:21:34 +0100 Subject: [PATCH 06/14] Added clarifying comments to code and used better variable names --- .../component/scrollbar/AppScrollbars.kt | 6 +- .../scrollbar/LazyScrollbarUtilities.kt | 10 +- .../component/scrollbar/Scrollbar.kt | 122 ++++++++++-------- .../component/scrollbar/ScrollbarExt.kt | 4 +- 4 files changed, 75 insertions(+), 67 deletions(-) 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 index 2a8827c3d..2786a09fe 100644 --- 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 @@ -49,6 +49,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollba import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive import kotlinx.coroutines.delay +private const val INACTIVE_TO_DORMANT_COOL_DOWN = 2_000L + /** * A [Scrollbar] that allows for fast scrolling of content. * Its thumb disappears when the scrolling container is dormant. @@ -80,7 +82,7 @@ fun FastScrollbar( orientation = orientation, ) }, - onThumbMoved = onThumbMoved, + onThumbDisplaced = onThumbMoved, ) } @@ -202,7 +204,7 @@ private fun scrollbarThumbColor( true -> state = Active false -> { state = Inactive - delay(2_000) + delay(INACTIVE_TO_DORMANT_COOL_DOWN) state = 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 index d45c5781a..c4ce8c22d 100644 --- 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 @@ -33,7 +33,7 @@ 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 + * @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. @@ -42,7 +42,7 @@ import kotlin.math.min internal inline fun LazyState.scrollbarState( itemsAvailable: Int, crossinline visibleItems: LazyState.() -> List, - crossinline firstItemIndex: LazyState.(List) -> Float, + crossinline firstVisibleItemIndex: LazyState.(List) -> Float, crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float, crossinline reverseLayout: LazyState.() -> Boolean, ): ScrollbarState { @@ -58,9 +58,8 @@ internal inline fun LazyState.scrol 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), + a = firstVisibleItemIndex(visibleItemsInfo), b = itemsAvailable.toFloat(), ) if (firstIndex.isNaN()) return@snapshotFlow null @@ -77,10 +76,9 @@ internal inline fun LazyState.scrol a = itemsVisible / itemsAvailable, b = 1f, ) - ScrollbarState( thumbSizePercent = thumbSizePercent, - thumbTravelPercent = when { + thumbDisplacementPercent = when { reverseLayout() -> 1f - thumbTravelPercent else -> thumbTravelPercent }, 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 index 3d33247d9..5baf003e0 100644 --- 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 @@ -57,8 +57,8 @@ import kotlinx.coroutines.delay import kotlin.math.max import kotlin.math.min -private const val SCROLLBAR_PRESS_DELAY = 100L -private const val SCROLLBAR_PRESS_DELTA = 0.1f +private const val SCROLLBAR_PRESS_DELAY = 10L +private const val SCROLLBAR_PRESS_DELTA = 0.02f /** * Class definition for the core properties of a scroll bar @@ -71,7 +71,7 @@ value class ScrollbarState internal constructor( companion object { val FULL = ScrollbarState( thumbSizePercent = 1f, - thumbTravelPercent = 0f, + thumbDisplacementPercent = 0f, ) } } @@ -93,15 +93,16 @@ private value class ScrollbarTrack( /** * 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 + * @param thumbDisplacementPercent the distance the thumb has traveled as a percentage of total + * track size */ fun ScrollbarState( thumbSizePercent: Float, - thumbTravelPercent: Float, + thumbDisplacementPercent: Float, ) = ScrollbarState( packFloats( val1 = thumbSizePercent, - val2 = thumbTravelPercent, + val2 = thumbDisplacementPercent, ), ) @@ -114,7 +115,7 @@ val ScrollbarState.thumbSizePercent /** * Returns the distance the thumb has traveled as a percentage of total track size */ -val ScrollbarState.thumbTravelPercent +val ScrollbarState.thumbDisplacementPercent get() = unpackFloat2(packedValue) /** @@ -167,8 +168,8 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { * @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 + * @param onThumbDisplaced 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( @@ -178,50 +179,47 @@ fun Scrollbar( minThumbSize: Dp = 40.dp, interactionSource: MutableInteractionSource? = null, thumb: @Composable () -> Unit, - onThumbMoved: ((Float) -> Unit)? = null, + onThumbDisplaced: ((Float) -> Unit)? = null, ) { val localDensity = LocalDensity.current - var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } + + // 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) } - var track by remember { mutableStateOf(ScrollbarTrack(0)) } - val updatedState by rememberUpdatedState(state) - val updatedTrack by rememberUpdatedState(track) + // 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 thumbSizePercent = state.thumbSizePercent val thumbTravelPercent = when { - interactionThumbTravelPercent.isNaN() -> state.thumbTravelPercent + interactionThumbTravelPercent.isNaN() -> state.thumbDisplacementPercent else -> interactionThumbTravelPercent } val thumbSizePx = max( - a = thumbSizePercent * track.size, + a = state.thumbSizePercent * track.size, b = with(localDensity) { minThumbSize.toPx() }, ) - val thumbSizeDp by animateDpAsState( targetValue = with(localDensity) { thumbSizePx.toDp() }, - label = "thumb size", + label = "scrollbar thumb size", ) - - val thumbTravelPx = min( + val thumbDisplacementPx = 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, - ) + Orientation.Vertical -> draggedOffset.copy(y = draggedOffset.y + delta) + Orientation.Horizontal -> draggedOffset.copy(x = draggedOffset.x + delta) } } + + // Scrollbar track container Box( modifier = modifier .run { @@ -232,10 +230,10 @@ fun Scrollbar( } } .onGloballyPositioned { coordinates -> - val position = orientation.valueOf(coordinates.positionInRoot()) + val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot()) track = ScrollbarTrack( - max = position, - min = position + orientation.valueOf(coordinates.size), + max = scrollbarStartCoordinate, + min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size), ) } // Process scrollbar presses @@ -243,17 +241,17 @@ fun Scrollbar( detectTapGestures( onPress = { offset -> val initialPress = PressInteraction.Press(offset) - interactionSource?.tryEmit(initialPress) + + // Start the press pressedOffset = offset interactionSource?.tryEmit( - if (tryAwaitRelease()) { - PressInteraction.Release(initialPress) - } else { - PressInteraction.Cancel(initialPress) - }, + if (tryAwaitRelease()) PressInteraction.Release(initialPress) + else PressInteraction.Cancel(initialPress), ) + + // End the press pressedOffset = Offset.Unspecified }, ) @@ -271,10 +269,11 @@ fun Scrollbar( }, ), ) { - val offset = max( - a = with(localDensity) { thumbTravelPx.toDp() }, + val scrollbarThumbDisplacement = max( + a = with(localDensity) { thumbDisplacementPx.toDp() }, b = 0.dp, ) + // Scrollbar thumb container Box( modifier = Modifier .align(Alignment.TopStart) @@ -287,10 +286,10 @@ fun Scrollbar( .offset( y = when (orientation) { Orientation.Horizontal -> 0.dp - Orientation.Vertical -> offset + Orientation.Vertical -> scrollbarThumbDisplacement }, x = when (orientation) { - Orientation.Horizontal -> offset + Orientation.Horizontal -> scrollbarThumbDisplacement Orientation.Vertical -> 0.dp }, ), @@ -299,31 +298,40 @@ fun Scrollbar( } } - if (onThumbMoved == null) return + if (onThumbDisplaced == 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 currentTravel = updatedState.thumbTravelPercent - val destinationTravel = updatedTrack.thumbPosition( + var currentThumbDisplacement = updatedState.thumbDisplacementPercent + val destinationThumbDisplacement = track.thumbPosition( dimension = orientation.valueOf(pressedOffset), ) - val isPositive = currentTravel < destinationTravel + val isPositive = currentThumbDisplacement < destinationThumbDisplacement val delta = SCROLLBAR_PRESS_DELTA * if (isPositive) 1f else -1f - while (currentTravel != destinationTravel) { - currentTravel = - if (isPositive) { - min(currentTravel + delta, destinationTravel) - } else { - max(currentTravel + delta, destinationTravel) - } - onThumbMoved(currentTravel) - interactionThumbTravelPercent = currentTravel + while (currentThumbDisplacement != destinationThumbDisplacement) { + currentThumbDisplacement = when { + isPositive -> min( + a = currentThumbDisplacement + delta, + b = destinationThumbDisplacement, + ) + else -> max( + a = currentThumbDisplacement + delta, + b = destinationThumbDisplacement, + ) + } + onThumbDisplaced(currentThumbDisplacement) + interactionThumbTravelPercent = currentThumbDisplacement delay(SCROLLBAR_PRESS_DELAY) } } @@ -334,10 +342,10 @@ fun Scrollbar( interactionThumbTravelPercent = Float.NaN return@LaunchedEffect } - val currentTravel = updatedTrack.thumbPosition( + val currentTravel = track.thumbPosition( dimension = orientation.valueOf(draggedOffset), ) - onThumbMoved(currentTravel) + onThumbDisplaced(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 index aea4cd661..26f0bb2ae 100644 --- 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 @@ -37,7 +37,7 @@ fun LazyListState.scrollbarState( scrollbarState( itemsAvailable = itemsAvailable, visibleItems = { layoutInfo.visibleItemsInfo }, - firstItemIndex = { visibleItems -> + firstVisibleItemIndex = { visibleItems -> interpolateFirstItemIndex( visibleItems = visibleItems, itemSize = { it.size }, @@ -71,7 +71,7 @@ fun LazyGridState.scrollbarState( scrollbarState( itemsAvailable = itemsAvailable, visibleItems = { layoutInfo.visibleItemsInfo }, - firstItemIndex = { visibleItems -> + firstVisibleItemIndex = { visibleItems -> interpolateFirstItemIndex( visibleItems = visibleItems, itemSize = { From ce6eaa9d64415b84bd230850c65e02f1372da6ab Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Sat, 13 May 2023 13:19:43 +0100 Subject: [PATCH 07/14] Fix spotless --- .../core/designsystem/component/scrollbar/Scrollbar.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 5baf003e0..4e28d340e 100644 --- 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 @@ -247,8 +247,10 @@ fun Scrollbar( pressedOffset = offset interactionSource?.tryEmit( - if (tryAwaitRelease()) PressInteraction.Release(initialPress) - else PressInteraction.Cancel(initialPress), + when { + tryAwaitRelease() -> PressInteraction.Release(initialPress) + else -> PressInteraction.Cancel(initialPress) + }, ) // End the press From cb1d50e65ed95795673c2aa02af8d12325fdc4a4 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Sat, 13 May 2023 20:23:56 +0100 Subject: [PATCH 08/14] Even better names Change-Id: Ia699c3ce8fd1ce7a6e406b00c81dc196b487ed65 --- .../component/scrollbar/AppScrollbars.kt | 36 ++--- .../component/scrollbar/ThumbExt.kt | 18 +-- .../feature/bookmarks/BookmarksScreen.kt | 42 +++-- .../feature/foryou/ForYouScreen.kt | 10 +- .../feature/interests/TabContent.kt | 7 +- .../feature/search/SearchScreen.kt | 147 +++++++++++------- .../nowinandroid/feature/topic/TopicScreen.kt | 7 +- 7 files changed, 139 insertions(+), 128 deletions(-) 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 index 2786a09fe..373bcb7d9 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -56,18 +57,15 @@ private const val INACTIVE_TO_DORMANT_COOL_DOWN = 2_000L * 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 + * @param onThumbDisplaced the fast scroll implementation */ @Composable -fun FastScrollbar( +fun ScrollableState.FastScrollbar( modifier: Modifier = Modifier, state: ScrollbarState, - scrollInProgress: Boolean, orientation: Orientation, - onThumbMoved: (Float) -> Unit, + onThumbDisplaced: (Float) -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( @@ -77,12 +75,11 @@ fun FastScrollbar( state = state, thumb = { FastScrollbarThumb( - scrollInProgress = scrollInProgress, interactionSource = interactionSource, orientation = orientation, ) }, - onThumbDisplaced = onThumbMoved, + onThumbDisplaced = onThumbDisplaced, ) } @@ -91,15 +88,12 @@ fun FastScrollbar( * 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( +fun ScrollableState.DecorativeScrollbar( modifier: Modifier = Modifier, state: ScrollbarState, - scrollInProgress: Boolean, orientation: Orientation, ) { val interactionSource = remember { MutableInteractionSource() } @@ -111,7 +105,6 @@ fun DecorativeScrollbar( thumb = { DecorativeScrollbarThumb( interactionSource = interactionSource, - scrollInProgress = scrollInProgress, orientation = orientation, ) }, @@ -122,8 +115,7 @@ fun DecorativeScrollbar( * A scrollbar thumb that is intended to also be a touch target for fast scrolling. */ @Composable -private fun FastScrollbarThumb( - scrollInProgress: Boolean, +private fun ScrollableState.FastScrollbarThumb( interactionSource: InteractionSource, orientation: Orientation, ) { @@ -137,7 +129,6 @@ private fun FastScrollbarThumb( } .background( color = scrollbarThumbColor( - scrollInProgress = scrollInProgress, interactionSource = interactionSource, ), shape = RoundedCornerShape(16.dp), @@ -149,8 +140,7 @@ private fun FastScrollbarThumb( * A decorative scrollbar thumb for communicating a user's position in a list solely. */ @Composable -private fun DecorativeScrollbarThumb( - scrollInProgress: Boolean, +private fun ScrollableState.DecorativeScrollbarThumb( interactionSource: InteractionSource, orientation: Orientation, ) { @@ -164,7 +154,6 @@ private fun DecorativeScrollbarThumb( } .background( color = scrollbarThumbColor( - scrollInProgress = scrollInProgress, interactionSource = interactionSource, ), shape = RoundedCornerShape(16.dp), @@ -174,19 +163,18 @@ private fun DecorativeScrollbarThumb( /** * 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, +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 = pressed || hovered || dragged || scrollInProgress + val active = (canScrollForward || canScrollForward) && + (pressed || hovered || dragged || isScrollInProgress) val color by animateColorAsState( targetValue = when (state) { @@ -202,7 +190,7 @@ private fun scrollbarThumbColor( LaunchedEffect(active) { when (active) { true -> state = Active - false -> { + false -> if (state == Active) { state = Inactive delay(INACTIVE_TO_DORMANT_COOL_DOWN) state = Dormant 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 index f03e21c85..4ed966da4 100644 --- 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 @@ -27,36 +27,36 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue /** - * Remembers a function to react to [Scrollbar] thumb position movements for a [LazyListState] + * 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.rememberThumbInteractions( +fun LazyListState.rememberFastScroller( itemsAvailable: Int, -): (Float) -> Unit = rememberThumbInteractions( +): (Float) -> Unit = rememberFastScroller( itemsAvailable = itemsAvailable, scroll = ::scrollToItem, ) /** - * Remembers a function to react to [Scrollbar] thumb position movements for a [LazyGridState] + * 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.rememberThumbInteractions( +fun LazyGridState.rememberFastScroller( itemsAvailable: Int, -): (Float) -> Unit = rememberThumbInteractions( +): (Float) -> Unit = rememberFastScroller( itemsAvailable = itemsAvailable, scroll = ::scrollToItem, ) /** - * Generic function to react to [Scrollbar] thumb interactions in a lazy layout. + * 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 rememberThumbInteractions( +private inline fun rememberFastScroller( itemsAvailable: Int, crossinline scroll: suspend (index: Int) -> Unit, ): (Float) -> Unit { @@ -69,6 +69,6 @@ private inline fun rememberThumbInteractions( scroll(indexToFind) } return remember { - { percentage = it } + { 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 cb65984e6..8360607a8 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 @@ -20,6 +20,7 @@ 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 @@ -62,7 +63,7 @@ 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.rememberFastScroller 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 @@ -120,12 +121,9 @@ internal fun BookmarksScreen( val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText) if (snackBarResult) { - undoBookmarkRemoval() - } - - else { - clearUndoState() - + undoBookmarkRemoval() + } else { + clearUndoState() } } } @@ -142,19 +140,18 @@ internal fun BookmarksScreen( } - 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) } } @@ -213,7 +210,7 @@ private fun BookmarksGrid( val scrollbarState = scrollableState.scrollbarState( itemsAvailable = itemsAvailable, ) - FastScrollbar( + scrollableState.FastScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) @@ -221,8 +218,7 @@ private fun BookmarksGrid( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - scrollInProgress = scrollableState.isScrollInProgress, - onThumbMoved = scrollableState.rememberThumbInteractions( + onThumbDisplaced = scrollableState.rememberFastScroller( 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 67551f4b0..ffbda5de3 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 @@ -93,7 +93,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT 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.rememberFastScroller 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 @@ -230,7 +230,7 @@ internal fun ForYouScreen( ) } } - FastScrollbar( + state.FastScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) @@ -238,8 +238,7 @@ internal fun ForYouScreen( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - scrollInProgress = state.isScrollInProgress, - onThumbMoved = state.rememberThumbInteractions( + onThumbDisplaced = state.rememberFastScroller( itemsAvailable = itemsAvailable, ), ) @@ -365,14 +364,13 @@ private fun TopicSelection( ) } } - DecorativeScrollbar( + lazyGridState.DecorativeScrollbar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) .align(Alignment.BottomStart), state = lazyGridState.scrollbarState(itemsAvailable = onboardingUiState.topics.size), orientation = Orientation.Horizontal, - scrollInProgress = lazyGridState.isScrollInProgress, ) } } 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 85d7c20e8..acc84e895 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 @@ -36,7 +36,7 @@ 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.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @@ -83,7 +83,7 @@ fun TopicsTabContent( val scrollbarState = scrollableState.scrollbarState( itemsAvailable = topics.size, ) - FastScrollbar( + scrollableState.FastScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) @@ -91,8 +91,7 @@ fun TopicsTabContent( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - scrollInProgress = scrollableState.isScrollInProgress, - onThumbMoved = scrollableState.rememberThumbInteractions( + onThumbDisplaced = scrollableState.rememberFastScroller( 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..3e34c3fdd 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,17 @@ 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.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller +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.Loading +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 +299,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.FastScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbDisplaced = state.rememberFastScroller( + 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 e2a14c06f..f81c5b51c 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 @@ -55,7 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackg 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.rememberFastScroller 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 @@ -151,7 +151,7 @@ internal fun TopicScreen( val scrollbarState = state.scrollbarState( itemsAvailable = itemsAvailable, ) - FastScrollbar( + state.FastScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) @@ -159,8 +159,7 @@ internal fun TopicScreen( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - scrollInProgress = state.isScrollInProgress, - onThumbMoved = state.rememberThumbInteractions( + onThumbDisplaced = state.rememberFastScroller( itemsAvailable = itemsAvailable, ), ) From bdf3ee3cb0b36d3431b6d7f7801729690a2c9d31 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 29 Jun 2023 15:00:53 +0100 Subject: [PATCH 09/14] PR feedback Change-Id: Ie22aa3c7fdf4c8ca8d8118837cfd279de8c6d80b --- .../apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt | 2 -- .../samples/apps/nowinandroid/feature/search/SearchScreen.kt | 2 -- 2 files changed, 4 deletions(-) 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 8360607a8..da4d32469 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 @@ -120,7 +120,6 @@ internal fun BookmarksScreen( if (shouldDisplayUndoBookmark) { val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText) if (snackBarResult) { - undoBookmarkRemoval() } else { clearUndoState() @@ -139,7 +138,6 @@ internal fun BookmarksScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - when (feedState) { Loading -> LoadingState(modifier) is Success -> if (feedState.feed.isNotEmpty()) { 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 3e34c3fdd..14b60e564 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 @@ -88,8 +88,6 @@ 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.Loading 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 From 2dba52266332a58ae6a539491716a2f356202e27 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 29 Jun 2023 15:03:28 +0100 Subject: [PATCH 10/14] Check if first item size == 0 Change-Id: I41acffad84d9db1cf407f77b04530643933f98bf --- .../component/scrollbar/LazyScrollbarUtilities.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index c4ce8c22d..2c1df0c66 100644 --- 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 @@ -116,8 +116,11 @@ internal inline fun LazyState.inter 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) / itemSize(firstItem) + val offsetPercentage = abs(itemOffset) / firstItemSize val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage From eeb49732657e8cd711c3bec252b4e413251252bb Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Wed, 5 Jul 2023 14:18:29 +0100 Subject: [PATCH 11/14] PR feedback Change-Id: I48492e3c121ff8b2ee6bbbac08aa1829f6a6467f --- .../component/scrollbar/AppScrollbars.kt | 17 ++++++----- .../scrollbar/LazyScrollbarUtilities.kt | 3 ++ .../component/scrollbar/Scrollbar.kt | 30 ++++++++++++------- .../feature/bookmarks/BookmarksScreen.kt | 4 +-- .../feature/foryou/ForYouScreen.kt | 4 +-- .../feature/interests/TabContent.kt | 4 +-- .../feature/search/SearchScreen.kt | 4 +-- .../nowinandroid/feature/topic/TopicScreen.kt | 4 +-- 8 files changed, 43 insertions(+), 27 deletions(-) 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 index 373bcb7d9..ca282aa7a 100644 --- 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 @@ -50,10 +50,13 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollba import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive import kotlinx.coroutines.delay -private const val INACTIVE_TO_DORMANT_COOL_DOWN = 2_000L +/** + * 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. + * 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] @@ -61,7 +64,7 @@ private const val INACTIVE_TO_DORMANT_COOL_DOWN = 2_000L * @param onThumbDisplaced the fast scroll implementation */ @Composable -fun ScrollableState.FastScrollbar( +fun ScrollableState.DraggableScrollbar( modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, @@ -74,7 +77,7 @@ fun ScrollableState.FastScrollbar( interactionSource = interactionSource, state = state, thumb = { - FastScrollbarThumb( + DraggableScrollbarThumb( interactionSource = interactionSource, orientation = orientation, ) @@ -115,7 +118,7 @@ fun ScrollableState.DecorativeScrollbar( * A scrollbar thumb that is intended to also be a touch target for fast scrolling. */ @Composable -private fun ScrollableState.FastScrollbarThumb( +private fun ScrollableState.DraggableScrollbarThumb( interactionSource: InteractionSource, orientation: Orientation, ) { @@ -137,7 +140,7 @@ private fun ScrollableState.FastScrollbarThumb( } /** - * A decorative scrollbar thumb for communicating a user's position in a list solely. + * A decorative scrollbar thumb used solely for communicating a user's position in a list. */ @Composable private fun ScrollableState.DecorativeScrollbarThumb( @@ -192,7 +195,7 @@ private fun ScrollableState.scrollbarThumbColor( true -> state = Active false -> if (state == Active) { state = Inactive - delay(INACTIVE_TO_DORMANT_COOL_DOWN) + delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS) state = 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 index 2c1df0c66..c7ef8fe91 100644 --- 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 @@ -101,6 +101,9 @@ internal inline fun LazyState.scrol * 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, 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 index 4e28d340e..0571ff6d1 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,8 +57,17 @@ import kotlinx.coroutines.delay import kotlin.math.max import kotlin.math.min -private const val SCROLLBAR_PRESS_DELAY = 10L -private const val SCROLLBAR_PRESS_DELTA = 0.02f +/** + * 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 @@ -91,10 +100,11 @@ private value class ScrollbarTrack( } /** - * Creates a scrollbar state with the listed properties + * Creates a [ScrollbarState] with the listed properties * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size * @param thumbDisplacementPercent the distance the thumb has traveled as a percentage of total - * track size + * track size. Refers to either the thumb width (for horizontal scrollbars) + * or height (for vertical scrollbars). */ fun ScrollbarState( thumbSizePercent: Float, @@ -162,7 +172,7 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { } /** - * A Composable for drawing a Scrollbar + * 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 @@ -219,7 +229,7 @@ fun Scrollbar( } } - // Scrollbar track container + // scrollbar track container Box( modifier = modifier .run { @@ -275,7 +285,7 @@ fun Scrollbar( a = with(localDensity) { thumbDisplacementPx.toDp() }, b = 0.dp, ) - // Scrollbar thumb container + // scrollbar thumb container Box( modifier = Modifier .align(Alignment.TopStart) @@ -319,7 +329,7 @@ fun Scrollbar( dimension = orientation.valueOf(pressedOffset), ) val isPositive = currentThumbDisplacement < destinationThumbDisplacement - val delta = SCROLLBAR_PRESS_DELTA * if (isPositive) 1f else -1f + val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f while (currentThumbDisplacement != destinationThumbDisplacement) { currentThumbDisplacement = when { @@ -334,7 +344,7 @@ fun Scrollbar( } onThumbDisplaced(currentThumbDisplacement) interactionThumbTravelPercent = currentThumbDisplacement - delay(SCROLLBAR_PRESS_DELAY) + delay(SCROLLBAR_PRESS_DELAY_MS) } } 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 da4d32469..d4363f12c 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 @@ -62,7 +62,7 @@ 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.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme @@ -208,7 +208,7 @@ private fun BookmarksGrid( val scrollbarState = scrollableState.scrollbarState( itemsAvailable = itemsAvailable, ) - scrollableState.FastScrollbar( + scrollableState.DraggableScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) 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 ffbda5de3..dbcfe7eeb 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 @@ -92,7 +92,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButto 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.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons @@ -230,7 +230,7 @@ internal fun ForYouScreen( ) } } - state.FastScrollbar( + state.DraggableScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) 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 acc84e895..7ae652344 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 @@ -35,7 +35,7 @@ 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.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @@ -83,7 +83,7 @@ fun TopicsTabContent( val scrollbarState = scrollableState.scrollbarState( itemsAvailable = topics.size, ) - scrollableState.FastScrollbar( + scrollableState.DraggableScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) 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 14b60e564..08356d938 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 @@ -80,7 +80,7 @@ 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.FastScrollbar +import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons @@ -381,7 +381,7 @@ private fun SearchResultBody( val scrollbarState = state.scrollbarState( itemsAvailable = itemsAvailable, ) - state.FastScrollbar( + state.DraggableScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) 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 f81c5b51c..84975b4ea 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 @@ -54,7 +54,7 @@ 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.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberFastScroller import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons @@ -151,7 +151,7 @@ internal fun TopicScreen( val scrollbarState = state.scrollbarState( itemsAvailable = itemsAvailable, ) - state.FastScrollbar( + state.DraggableScrollbar( modifier = Modifier .fillMaxHeight() .windowInsetsPadding(WindowInsets.systemBars) From 806726a7f0804948b26943357996ffce8364bdad Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Wed, 5 Jul 2023 14:21:06 +0100 Subject: [PATCH 12/14] PR feedback Change-Id: I6e9f4a2ba53e81d32d07e229eb848541801eca18 --- .../core/designsystem/component/scrollbar/Scrollbar.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 0571ff6d1..f4710f2c6 100644 --- 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 @@ -101,10 +101,11 @@ private value class ScrollbarTrack( /** * Creates a [ScrollbarState] with the listed properties - * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size + * @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 thumbDisplacementPercent the distance the thumb has traveled as a percentage of total - * track size. Refers to either the thumb width (for horizontal scrollbars) - * or height (for vertical scrollbars). + * track size. */ fun ScrollbarState( thumbSizePercent: Float, From e13e84c155b26d12c87b922362054984eee418fa Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Wed, 5 Jul 2023 16:56:15 +0100 Subject: [PATCH 13/14] Replace usages of 'displacement' to 'moved' in parameter names Change-Id: I5b3da60041b046454e848d187f6dd2bfadf22b9a --- .../component/scrollbar/AppScrollbars.kt | 6 +-- .../scrollbar/LazyScrollbarUtilities.kt | 2 +- .../component/scrollbar/Scrollbar.kt | 52 +++++++++---------- .../component/scrollbar/ThumbExt.kt | 10 ++-- .../feature/bookmarks/BookmarksScreen.kt | 4 +- .../feature/foryou/ForYouScreen.kt | 4 +- .../feature/interests/TabContent.kt | 4 +- .../feature/search/SearchScreen.kt | 4 +- .../nowinandroid/feature/topic/TopicScreen.kt | 4 +- 9 files changed, 45 insertions(+), 45 deletions(-) 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 index ca282aa7a..fa913cb27 100644 --- 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 @@ -61,14 +61,14 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L * @param modifier a [Modifier] for the [Scrollbar] * @param state the driving state for the [Scrollbar] * @param orientation the orientation of the scrollbar - * @param onThumbDisplaced the fast scroll implementation + * @param onThumbMoved the fast scroll implementation */ @Composable fun ScrollableState.DraggableScrollbar( modifier: Modifier = Modifier, state: ScrollbarState, orientation: Orientation, - onThumbDisplaced: (Float) -> Unit, + onThumbMoved: (Float) -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Scrollbar( @@ -82,7 +82,7 @@ fun ScrollableState.DraggableScrollbar( orientation = orientation, ) }, - onThumbDisplaced = onThumbDisplaced, + onThumbMoved = onThumbMoved, ) } 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 index c7ef8fe91..8c4063b15 100644 --- 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 @@ -78,7 +78,7 @@ internal inline fun LazyState.scrol ) ScrollbarState( thumbSizePercent = thumbSizePercent, - thumbDisplacementPercent = when { + thumbMovedPercent = when { reverseLayout() -> 1f - thumbTravelPercent else -> thumbTravelPercent }, 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 index f4710f2c6..11997468c 100644 --- 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 @@ -80,7 +80,7 @@ value class ScrollbarState internal constructor( companion object { val FULL = ScrollbarState( thumbSizePercent = 1f, - thumbDisplacementPercent = 0f, + thumbMovedPercent = 0f, ) } } @@ -104,16 +104,16 @@ private value class ScrollbarTrack( * @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 thumbDisplacementPercent the distance the thumb has traveled as a percentage of total + * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total * track size. */ fun ScrollbarState( thumbSizePercent: Float, - thumbDisplacementPercent: Float, + thumbMovedPercent: Float, ) = ScrollbarState( packFloats( val1 = thumbSizePercent, - val2 = thumbDisplacementPercent, + val2 = thumbMovedPercent, ), ) @@ -126,7 +126,7 @@ val ScrollbarState.thumbSizePercent /** * Returns the distance the thumb has traveled as a percentage of total track size */ -val ScrollbarState.thumbDisplacementPercent +val ScrollbarState.thumbMovedPercent get() = unpackFloat2(packedValue) /** @@ -179,7 +179,7 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { * @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 onThumbDisplaced an function for reacting to scroll bar displacements caused by direct + * @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 @@ -190,7 +190,7 @@ fun Scrollbar( minThumbSize: Dp = 40.dp, interactionSource: MutableInteractionSource? = null, thumb: @Composable () -> Unit, - onThumbDisplaced: ((Float) -> Unit)? = null, + onThumbMoved: ((Float) -> Unit)? = null, ) { val localDensity = LocalDensity.current @@ -206,7 +206,7 @@ fun Scrollbar( var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } val thumbTravelPercent = when { - interactionThumbTravelPercent.isNaN() -> state.thumbDisplacementPercent + interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent else -> interactionThumbTravelPercent } val thumbSizePx = max( @@ -217,7 +217,7 @@ fun Scrollbar( targetValue = with(localDensity) { thumbSizePx.toDp() }, label = "scrollbar thumb size", ) - val thumbDisplacementPx = min( + val thumbMovedPx = min( a = track.size * thumbTravelPercent, b = track.size - thumbSizePx, ) @@ -282,8 +282,8 @@ fun Scrollbar( }, ), ) { - val scrollbarThumbDisplacement = max( - a = with(localDensity) { thumbDisplacementPx.toDp() }, + val scrollbarThumbMovedDp = max( + a = with(localDensity) { thumbMovedPx.toDp() }, b = 0.dp, ) // scrollbar thumb container @@ -299,10 +299,10 @@ fun Scrollbar( .offset( y = when (orientation) { Orientation.Horizontal -> 0.dp - Orientation.Vertical -> scrollbarThumbDisplacement + Orientation.Vertical -> scrollbarThumbMovedDp }, x = when (orientation) { - Orientation.Horizontal -> scrollbarThumbDisplacement + Orientation.Horizontal -> scrollbarThumbMovedDp Orientation.Vertical -> 0.dp }, ), @@ -311,7 +311,7 @@ fun Scrollbar( } } - if (onThumbDisplaced == null) return + if (onThumbMoved == null) return // State that will be read inside the effects that follow // but will not cause re-triggering of them @@ -325,26 +325,26 @@ fun Scrollbar( return@LaunchedEffect } - var currentThumbDisplacement = updatedState.thumbDisplacementPercent - val destinationThumbDisplacement = track.thumbPosition( + var currentThumbMovedPercent = updatedState.thumbMovedPercent + val destinationThumbMovedPercent = track.thumbPosition( dimension = orientation.valueOf(pressedOffset), ) - val isPositive = currentThumbDisplacement < destinationThumbDisplacement + val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f - while (currentThumbDisplacement != destinationThumbDisplacement) { - currentThumbDisplacement = when { + while (currentThumbMovedPercent != destinationThumbMovedPercent) { + currentThumbMovedPercent = when { isPositive -> min( - a = currentThumbDisplacement + delta, - b = destinationThumbDisplacement, + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, ) else -> max( - a = currentThumbDisplacement + delta, - b = destinationThumbDisplacement, + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, ) } - onThumbDisplaced(currentThumbDisplacement) - interactionThumbTravelPercent = currentThumbDisplacement + onThumbMoved(currentThumbMovedPercent) + interactionThumbTravelPercent = currentThumbMovedPercent delay(SCROLLBAR_PRESS_DELAY_MS) } } @@ -358,7 +358,7 @@ fun Scrollbar( val currentTravel = track.thumbPosition( dimension = orientation.valueOf(draggedOffset), ) - onThumbDisplaced(currentTravel) + onThumbMoved(currentTravel) interactionThumbTravelPercent = currentTravel } } 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 index 4ed966da4..4d187e269 100644 --- 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 @@ -31,9 +31,9 @@ import androidx.compose.runtime.setValue * @param itemsAvailable the amount of items in the list. */ @Composable -fun LazyListState.rememberFastScroller( +fun LazyListState.rememberDraggableScroller( itemsAvailable: Int, -): (Float) -> Unit = rememberFastScroller( +): (Float) -> Unit = rememberDraggableScroller( itemsAvailable = itemsAvailable, scroll = ::scrollToItem, ) @@ -43,9 +43,9 @@ fun LazyListState.rememberFastScroller( * @param itemsAvailable the amount of items in the grid. */ @Composable -fun LazyGridState.rememberFastScroller( +fun LazyGridState.rememberDraggableScroller( itemsAvailable: Int, -): (Float) -> Unit = rememberFastScroller( +): (Float) -> Unit = rememberDraggableScroller( itemsAvailable = itemsAvailable, scroll = ::scrollToItem, ) @@ -56,7 +56,7 @@ fun LazyGridState.rememberFastScroller( * @param scroll a function to be invoked when an index has been identified to scroll to. */ @Composable -private inline fun rememberFastScroller( +private inline fun rememberDraggableScroller( itemsAvailable: Int, crossinline scroll: suspend (index: Int) -> Unit, ): (Float) -> Unit { 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 d4363f12c..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 @@ -63,7 +63,7 @@ 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.rememberFastScroller +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 @@ -216,7 +216,7 @@ private fun BookmarksGrid( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - onThumbDisplaced = scrollableState.rememberFastScroller( + 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 dbcfe7eeb..f536c3385 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 @@ -93,7 +93,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT 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.rememberFastScroller +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 @@ -238,7 +238,7 @@ internal fun ForYouScreen( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - onThumbDisplaced = state.rememberFastScroller( + onThumbMoved = state.rememberDraggableScroller( itemsAvailable = itemsAvailable, ), ) 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 7ae652344..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 @@ -36,7 +36,7 @@ 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.rememberFastScroller +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 @@ -91,7 +91,7 @@ fun TopicsTabContent( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - onThumbDisplaced = scrollableState.rememberFastScroller( + 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 08356d938..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 @@ -81,7 +81,7 @@ 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.rememberFastScroller +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 @@ -389,7 +389,7 @@ private fun SearchResultBody( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - onThumbDisplaced = state.rememberFastScroller( + 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 84975b4ea..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 @@ -55,7 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackg 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.rememberFastScroller +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 @@ -159,7 +159,7 @@ internal fun TopicScreen( .align(Alignment.CenterEnd), state = scrollbarState, orientation = Orientation.Vertical, - onThumbDisplaced = state.rememberFastScroller( + onThumbMoved = state.rememberDraggableScroller( itemsAvailable = itemsAvailable, ), ) From 9babb50c58727f731c8a5dfdacf33e88a3ad2ca9 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Sat, 22 Jul 2023 00:17:45 +0100 Subject: [PATCH 14/14] Interact with scrollbar only if drag direction matches scrollbar orientation Change-Id: I8fb5a64098ea83458d25fd2b7363a6b5dd3fef2f --- .../component/scrollbar/Scrollbar.kt | 104 ++++++++++++------ 1 file changed, 71 insertions(+), 33 deletions(-) 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 index 11997468c..74d9e0467 100644 --- 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 @@ -18,10 +18,11 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollb 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.draggable -import androidx.compose.foundation.gestures.rememberDraggableState +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 @@ -41,6 +42,7 @@ 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 @@ -53,7 +55,9 @@ 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 @@ -221,14 +225,6 @@ fun Scrollbar( 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) - } - } // scrollbar track container Box( @@ -251,36 +247,77 @@ fun Scrollbar( .pointerInput(Unit) { detectTapGestures( onPress = { offset -> - val initialPress = PressInteraction.Press(offset) - interactionSource?.tryEmit(initialPress) - - // Start the press - pressedOffset = 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) - interactionSource?.tryEmit( - when { - tryAwaitRelease() -> PressInteraction.Release(initialPress) - else -> PressInteraction.Cancel(initialPress) - }, - ) + pressedOffset = offset + interactionSource?.tryEmit( + when { + tryAwaitRelease() -> PressInteraction.Release(initialPress) + else -> PressInteraction.Cancel(initialPress) + }, + ) - // End the press - pressedOffset = Offset.Unspecified + // End the press + pressedOffset = Offset.Unspecified + } }, ) } // Process scrollbar drags - .draggable( - state = draggableState, - orientation = orientation, - interactionSource = interactionSource, - onDragStarted = { startedPosition: Offset -> - draggedOffset = startedPosition - }, - onDragStopped = { + .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() }, @@ -338,6 +375,7 @@ fun Scrollbar( a = currentThumbMovedPercent + delta, b = destinationThumbMovedPercent, ) + else -> max( a = currentThumbMovedPercent + delta, b = destinationThumbMovedPercent,