Add scrollbars to app

pull/722/head
TJ Dahunsi 2 years ago
parent 0c542b42f9
commit 31b4841cb2

@ -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
}

@ -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 : ScrollableState, LazyStateItem> LazyState.scrollbarState(
itemsAvailable: Int,
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
crossinline firstItemIndex: LazyState.(List<LazyStateItem>) -> 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 : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
visibleItems: List<LazyStateItem>,
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
}

@ -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
}
}

@ -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 },
)

@ -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 }
}
}

@ -18,11 +18,13 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -57,6 +59,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel 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.LocalTintTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -112,9 +117,13 @@ internal fun BookmarksScreen(
if (shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) {
val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText) val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText)
if (snackBarResult) { if (snackBarResult) {
undoBookmarkRemoval()
} else { undoBookmarkRemoval()
clearUndoState() }
else {
clearUndoState()
} }
} }
} }
@ -130,18 +139,20 @@ internal fun BookmarksScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
when (feedState) {
Loading -> LoadingState(modifier) when (feedState) {
is Success -> if (feedState.feed.isNotEmpty()) { Loading -> LoadingState(modifier)
BookmarksGrid( is Success -> if (feedState.feed.isNotEmpty()) {
feedState, BookmarksGrid(
removeFromBookmarks, feedState,
onNewsResourceViewed, removeFromBookmarks,
onTopicClick, onNewsResourceViewed,
modifier, onTopicClick,
) modifier,
} else { )
EmptyState(modifier) } else {
EmptyState(modifier)
} }
} }
@ -169,25 +180,49 @@ private fun BookmarksGrid(
) { ) {
val scrollableState = rememberLazyGridState() val scrollableState = rememberLazyGridState()
TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid") TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid")
LazyVerticalGrid( Box(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
state = scrollableState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize(),
.testTag("bookmarks:feed"),
) { ) {
newsFeed( LazyVerticalGrid(
feedState = feedState, columns = Adaptive(300.dp),
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, contentPadding = PaddingValues(16.dp),
onNewsResourceViewed = onNewsResourceViewed, horizontalArrangement = Arrangement.spacedBy(16.dp),
onTopicClick = onTopicClick, verticalArrangement = Arrangement.spacedBy(24.dp),
) state = scrollableState,
item(span = { GridItemSpan(maxLineSpan) }) { modifier = Modifier
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) .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,
),
)
} }
} }

@ -25,6 +25,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton 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.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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource 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. // This code should be called when the UI is ready for use and relates to Time To Full Display.
ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading } ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading }
val itemsAvailable = feedItemsSize(feedState, onboardingUiState)
val state = rememberLazyGridState() val state = rememberLazyGridState()
val scrollbarState = state.scrollbarState(
itemsAvailable = itemsAvailable,
)
TrackScrollJank(scrollableState = state, stateName = "forYou:feed") TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
LazyVerticalGrid( Box(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize(),
.testTag("forYou:feed"),
state = state,
) { ) {
onboarding( LazyVerticalGrid(
onboardingUiState = onboardingUiState, columns = Adaptive(300.dp),
onTopicCheckedChanged = onTopicCheckedChanged, contentPadding = PaddingValues(16.dp),
saveFollowedTopics = saveFollowedTopics, horizontalArrangement = Arrangement.spacedBy(16.dp),
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding verticalArrangement = Arrangement.spacedBy(24.dp),
// from the LazyVerticalGrid and enable edge-to-edge scrolling for this section modifier = Modifier
interestsItemModifier = Modifier.layout { measurable, constraints -> .testTag("forYou:feed"),
val placeable = measurable.measure( state = state,
constraints.copy( ) {
maxWidth = constraints.maxWidth + 32.dp.roundToPx(), onboarding(
), onboardingUiState = onboardingUiState,
) onTopicCheckedChanged = onTopicCheckedChanged,
layout(placeable.width, placeable.height) { saveFollowedTopics = saveFollowedTopics,
placeable.place(0, 0) // 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( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") { item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") {
Column { Column {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar. // Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp // TODO: Check that the Scaffold handles this correctly in NiaApp
// if (isOffline) Spacer(modifier = Modifier.height(48.dp)) // if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
} }
} }
} AnimatedVisibility(
AnimatedVisibility( visible = isSyncing || isFeedLoading || isOnboardingLoading,
visible = isSyncing || isFeedLoading || isOnboardingLoading, enter = slideInVertically(
enter = slideInVertically( initialOffsetY = { fullHeight -> -fullHeight },
initialOffsetY = { fullHeight -> -fullHeight }, ) + fadeIn(),
) + fadeIn(), exit = slideOutVertically(
exit = slideOutVertically( targetOffsetY = { fullHeight -> -fullHeight },
targetOffsetY = { fullHeight -> -fullHeight }, ) + fadeOut(),
) + fadeOut(),
) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) { ) {
NiaOverlayLoadingWheel( val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
modifier = Modifier modifier = Modifier
.align(Alignment.Center), .fillMaxWidth()
contentDesc = loadingContentDescription, .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") TrackScreenViewEvent(screenName = "ForYou")
NotificationPermissionEffect() NotificationPermissionEffect()
@ -298,38 +325,52 @@ private fun TopicSelection(
TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag) TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag)
LazyHorizontalGrid( Box(
state = lazyGridState,
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(24.dp),
modifier = modifier modifier = modifier
// LazyHorizontalGrid has to be constrained in height. .fillMaxWidth(),
// 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),
) { ) {
items( LazyHorizontalGrid(
items = onboardingUiState.topics, state = lazyGridState,
key = { it.topic.id }, 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( items(
name = it.topic.name, items = onboardingUiState.topics,
topicId = it.topic.id, key = { it.topic.id },
imageUrl = it.topic.imageUrl, ) {
isSelected = it.isFollowed, SingleTopicButton(
onClick = onTopicCheckedChanged, 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 @DevicePreviews
@Composable @Composable
fun ForYouScreenPopulatedFeed( fun ForYouScreenPopulatedFeed(

@ -16,17 +16,26 @@
package com.google.samples.apps.nowinandroid.feature.interests 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.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp 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 import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@Composable @Composable
@ -37,30 +46,52 @@ fun TopicsTabContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true, withBottomSpacer: Boolean = true,
) { ) {
LazyColumn( Box(
modifier = modifier modifier = modifier
.padding(horizontal = 24.dp) .fillMaxWidth(),
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
) { ) {
topics.forEach { followableTopic -> val scrollableState = rememberLazyListState()
val topicId = followableTopic.topic.id LazyColumn(
item(key = topicId) { modifier = Modifier
InterestsItem( .padding(horizontal = 24.dp)
name = followableTopic.topic.name, .testTag("interests:topics"),
following = followableTopic.isFollowed, contentPadding = PaddingValues(vertical = 16.dp),
description = followableTopic.topic.shortDescription, state = scrollableState,
topicImageUrl = followableTopic.topic.imageUrl, ) {
onClick = { onTopicClick(topicId) }, topics.forEach { followableTopic ->
onFollowButtonClick = { onFollowButtonClick(topicId, it) }, 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) { if (withBottomSpacer) {
item { item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) 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,
),
)
} }
} }

@ -17,16 +17,21 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope 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.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip 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.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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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.FollowableTopic
@ -97,45 +105,78 @@ internal fun TopicScreen(
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
TrackScrollJank(scrollableState = state, stateName = "topic:screen") TrackScrollJank(scrollableState = state, stateName = "topic:screen")
LazyColumn( Box(
state = state,
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
item { LazyColumn(
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) state = state,
} horizontalAlignment = Alignment.CenterHorizontally,
when (topicUiState) { ) {
TopicUiState.Loading -> item { item {
NiaLoadingWheel( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
} }
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
}
TopicUiState.Error -> TODO() TopicUiState.Error -> TODO()
is TopicUiState.Success -> { is TopicUiState.Success -> {
item { item {
TopicToolbar( TopicToolbar(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
uiState = topicUiState.followableTopic, 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, item {
description = topicUiState.followableTopic.topic.longDescription, Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick,
)
} }
} }
item { val itemsAvailable = topicItemsSize(topicUiState, newsUiState)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) 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
} }
} }

Loading…
Cancel
Save