From 04563887b7fbabbafe454ec62e5275e88e2885f2 Mon Sep 17 00:00:00 2001 From: kagg886 Date: Sun, 15 Dec 2024 20:08:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20=E4=B8=BA=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0=E6=BB=91=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pmf/ui/component/scroll/scrollbar.kt | 1006 +++++++++++++++++ .../kagg886/pmf/ui/component/scroll/v2/v2.kt | 560 +++++++++ .../main/detail/illust/IllustDetailScreen.kt | 199 ++-- .../main/detail/novel/NovelDetailScreen.kt | 18 +- .../top/kagg886/pmf/ui/util/comment-screen.kt | 10 +- .../pmf/ui/util/illust-fetch-screen.kt | 11 +- .../kagg886/pmf/ui/util/novel-fetch-screen.kt | 10 +- 7 files changed, 1713 insertions(+), 101 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/scrollbar.kt create mode 100644 composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/v2/v2.kt diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/scrollbar.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/scrollbar.kt new file mode 100644 index 0000000..08c77cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/scrollbar.kt @@ -0,0 +1,1006 @@ +/* + * 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 + * + * http://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. + */ + +@file:JvmName("Scrollbar_desktopKt") + +package top.kagg886.pmf.ui.component.scroll + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocal +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import kotlin.jvm.JvmName +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.kagg886.pmf.ui.component.scroll.v2.* +import top.kagg886.pmf.ui.component.scroll.v2.LazyGridScrollbarAdapter +import top.kagg886.pmf.ui.component.scroll.v2.LazyListScrollbarAdapter +import top.kagg886.pmf.ui.component.scroll.v2.ScrollableScrollbarAdapter +import top.kagg886.pmf.ui.component.scroll.v2.SliderAdapter + +/** + * [CompositionLocal] used to pass [ScrollbarStyle] down the tree. + * This value is typically set in some "Theme" composable function + * (DesktopTheme, MaterialTheme) + */ +val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() } + +/** + * Defines visual style of scrollbars (thickness, shapes, colors, etc). + * Can be passed as a parameter of scrollbar through [LocalScrollbarStyle] + */ +@Immutable +data class ScrollbarStyle( + val minimalHeight: Dp, + val thickness: Dp, + val shape: Shape, + val hoverDurationMillis: Int, + val unhoverColor: Color, + val hoverColor: Color +) + +/** + * Simple default [ScrollbarStyle] without applying MaterialTheme. + */ +fun defaultScrollbarStyle() = ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = Color.Black.copy(alpha = 0.12f), + hoverColor = Color.Black.copy(alpha = 0.50f) +) + +/** + * Vertical scrollbar that can be attached to some scrollable + * component (ScrollableColumn, LazyColumn) and share common state with it. + * + * Can be placed independently. + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.verticalScroll(state)) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + * + * @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component + * @param modifier the modifier to apply to this layout + * @param reverseLayout reverse the direction of scrolling and layout, when `true` + * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar + * will be at the bottom of the container. + * It is usually used in pair with `LazyColumn(reverseLayout = true)` + * @param style [ScrollbarStyle] to define visual style of scrollbar + * @param interactionSource [MutableInteractionSource] that will be used to dispatch + * [DragInteraction.Start] when this Scrollbar is being dragged. + */ +@Deprecated("Use VerticalScrollbar(" + + "adapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter)" + + " instead") +@Composable +fun VerticalScrollbar( + @Suppress("DEPRECATION") adapter: ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + style: ScrollbarStyle = LocalScrollbarStyle.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) = OldScrollbar( + adapter, + modifier, + reverseLayout, + style, + interactionSource, + isVertical = true +) + +/** + * Horizontal scrollbar that can be attached to some scrollable + * component (Modifier.verticalScroll(), LazyRow) and share common state with it. + * + * Can be placed independently. + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.horizontalScroll(state)) { + * ... + * } + * + * HorizontalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxWidth(), + * ) + * } + * + * @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component + * @param modifier the modifier to apply to this layout + * @param reverseLayout reverse the direction of scrolling and layout, when `true` + * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar + * will be at the end of the container. + * It is usually used in pair with `LazyRow(reverseLayout = true)` + * @param style [ScrollbarStyle] to define visual style of scrollbar + * @param interactionSource [MutableInteractionSource] that will be used to dispatch + * [DragInteraction.Start] when this Scrollbar is being dragged. + */ +@Deprecated("Use HorizontalScrollbar(" + + "adapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter) instead") +@Composable +fun HorizontalScrollbar( + @Suppress("DEPRECATION") adapter: ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + style: ScrollbarStyle = LocalScrollbarStyle.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) = OldScrollbar( + adapter, + modifier, + if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout, + style, + interactionSource, + isVertical = false +) + +@Suppress("DEPRECATION") +@Composable +private fun OldScrollbar( + oldAdapter: ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean, + style: ScrollbarStyle, + interactionSource: MutableInteractionSource, + isVertical: Boolean +) = OldOrNewScrollbar( + oldOrNewAdapter = oldAdapter, + newScrollbarAdapterFactory = ScrollbarAdapter::asNewAdapter, + modifier = modifier, + reverseLayout = reverseLayout, + style = style, + interactionSource = interactionSource, + isVertical = isVertical +) + +/** + * Vertical scrollbar that can be attached to some scrollable + * component (ScrollableColumn, LazyColumn) and share common state with it. + * + * Can be placed independently. + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.verticalScroll(state)) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + * + * @param adapter [androidx.compose.foundation.v2.ScrollbarAdapter] that will be used to + * communicate with scrollable component + * @param modifier the modifier to apply to this layout + * @param reverseLayout reverse the direction of scrolling and layout, when `true` + * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar + * will be at the bottom of the container. + * It is usually used in pair with `LazyColumn(reverseLayout = true)` + * @param style [ScrollbarStyle] to define visual style of scrollbar + * @param interactionSource [MutableInteractionSource] that will be used to dispatch + * [DragInteraction.Start] when this Scrollbar is being dragged. + */ +@Composable +fun VerticalScrollbar( + adapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + style: ScrollbarStyle = LocalScrollbarStyle.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) = NewScrollbar( + newAdapter = adapter, + modifier, + reverseLayout, + style, + interactionSource, + isVertical = true +) + +/** + * Horizontal scrollbar that can be attached to some scrollable + * component (Modifier.verticalScroll(), LazyRow) and share common state with it. + * + * Can be placed independently. + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.verticalScroll(state)) { + * ... + * } + * + * HorizontalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxWidth(), + * ) + * } + * + * @param adapter [androidx.compose.foundation.v2.ScrollbarAdapter] that will be used to + * communicate with scrollable component + * @param modifier the modifier to apply to this layout + * @param reverseLayout reverse the direction of scrolling and layout, when `true` + * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar + * will be at the end of the container. + * It is usually used in pair with `LazyRow(reverseLayout = true)` + * @param style [ScrollbarStyle] to define visual style of scrollbar + * @param interactionSource [MutableInteractionSource] that will be used to dispatch + * [DragInteraction.Start] when this Scrollbar is being dragged. + */ +@Composable +fun HorizontalScrollbar( + adapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + style: ScrollbarStyle = LocalScrollbarStyle.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) = NewScrollbar( + newAdapter = adapter, + modifier, + if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout, + style, + interactionSource, + isVertical = false +) + +@Composable +private fun NewScrollbar( + newAdapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter, + modifier: Modifier = Modifier, + reverseLayout: Boolean, + style: ScrollbarStyle, + interactionSource: MutableInteractionSource, + isVertical: Boolean +) = OldOrNewScrollbar( + oldOrNewAdapter = newAdapter, + newScrollbarAdapterFactory = { adapter, _ -> adapter }, + modifier = modifier, + reverseLayout = reverseLayout, + style = style, + interactionSource = interactionSource, + isVertical = isVertical +) + +private typealias NewScrollbarAdapterFactory = ( + adapter: T, + trackSize: Int, +) -> top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter + +/** + * The actual implementation of the scrollbar. + * + * Takes the scroll adapter (old or new) and a function that converts it to the new scrollbar + * adapter interface. This allows both the old (left for backwards compatibility) and new + * implementations to use the same code. + */ +@Composable +internal fun OldOrNewScrollbar( + oldOrNewAdapter: T, + // We need an adapter factory because we can't convert an old to a new + // adapter until we have the track/container size + newScrollbarAdapterFactory: NewScrollbarAdapterFactory, + modifier: Modifier = Modifier, + reverseLayout: Boolean, + style: ScrollbarStyle, + interactionSource: MutableInteractionSource, + isVertical: Boolean, +) = with(LocalDensity.current) { + val dragInteraction = remember { mutableStateOf(null) } + DisposableEffect(interactionSource) { + onDispose { + dragInteraction.value?.let { interaction -> + interactionSource.tryEmit(DragInteraction.Cancel(interaction)) + dragInteraction.value = null + } + } + } + + var containerSize by remember { mutableStateOf(0) } + val isHovered by interactionSource.collectIsHoveredAsState() + + val isHighlighted by remember { + derivedStateOf { + isHovered || dragInteraction.value is DragInteraction.Start + } + } + + val minimalHeight = style.minimalHeight.toPx() + + val adapter = remember(oldOrNewAdapter, containerSize){ + newScrollbarAdapterFactory(oldOrNewAdapter, containerSize) + } + val coroutineScope = rememberCoroutineScope() + val sliderAdapter = remember( + adapter, containerSize, minimalHeight, reverseLayout, isVertical, coroutineScope + ) { + SliderAdapter(adapter, containerSize, minimalHeight, reverseLayout, isVertical, coroutineScope) + } + + val scrollThickness = style.thickness.roundToPx() + val measurePolicy = if (isVertical) { + remember(sliderAdapter, scrollThickness) { + verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness) + } + } else { + remember(sliderAdapter, scrollThickness) { + horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness) + } + } + + val color by animateColorAsState( + if (isHighlighted) style.hoverColor else style.unhoverColor, + animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis) + ) + + val isVisible = sliderAdapter.thumbSize < containerSize + + Layout( + { + Box( + Modifier + .background(if (isVisible) color else Color.Transparent, style.shape) + .scrollbarDrag( + interactionSource = interactionSource, + draggedInteraction = dragInteraction, + sliderAdapter = sliderAdapter, + ) + ) + }, + modifier + .hoverable(interactionSource = interactionSource) + .scrollOnPressTrack(isVertical, reverseLayout, sliderAdapter), + measurePolicy + ) +} + +/** + * Adapts an old [ScrollbarAdapter] to the new interface, under the assumption that the + * track size is equal to the viewport size. + */ +private class OldScrollbarAdapterAsNew( + @Suppress("DEPRECATION") val oldAdapter: ScrollbarAdapter, + private val trackSize: Int +) : top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter { + + override val scrollOffset: Double + get() = oldAdapter.scrollOffset.toDouble() + + override val contentSize: Double + get() = (oldAdapter.maxScrollOffset(trackSize) + trackSize).toDouble() + + override val viewportSize: Double + get() = trackSize.toDouble() + + override suspend fun scrollTo(scrollOffset: Double) { + oldAdapter.scrollTo(trackSize, scrollOffset.toFloat()) + } + +} + +/** + * Converts an instance of the old scrollbar adapter to a new one. + * + * If the old one is in fact just a [NewScrollbarAdapterAsOld], then simply unwrap it. + * This allows users that simply passed our own (old) implementations back to + * us to seamlessly use the new implementations, and enjoy all their benefits. + */ +@Suppress("DEPRECATION") +private fun ScrollbarAdapter.asNewAdapter( + trackSize: Int +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = + if (this is NewScrollbarAdapterAsOld) + this.newAdapter // Just unwrap + else + OldScrollbarAdapterAsNew(this, trackSize) + +/** + * Adapts a new scrollbar adapter to the old interface. + */ +@Suppress("DEPRECATION") +private class NewScrollbarAdapterAsOld( + val newAdapter: top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter +): ScrollbarAdapter { + + override val scrollOffset: Float + get() = newAdapter.scrollOffset.toFloat() + + override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) { + newAdapter.scrollTo(scrollOffset.toDouble()) + } + + override fun maxScrollOffset(containerSize: Int): Float { + return newAdapter.maxScrollOffset.toFloat() + } + +} + +/** + * Converts an instance of the new scrollbar adapter to an old one. + */ +@Suppress("DEPRECATION") +private fun top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter.asOldAdapter(): ScrollbarAdapter = + if (this is OldScrollbarAdapterAsNew) + this.oldAdapter // Just unwrap + else + NewScrollbarAdapterAsOld(this) + +/** + * Create and [remember] (old) [ScrollbarAdapter] for scrollable container and current instance of + * [scrollState] + */ +@Deprecated( + message = "Use rememberScrollbarAdapter instead", + replaceWith = ReplaceWith( + expression = "rememberScrollbarAdapter(scrollState)", + "androidx.compose.foundation.rememberScrollbarAdapter" + ) +) +@JvmName("rememberScrollbarAdapter") +@Suppress("DEPRECATION") +@Composable +fun rememberOldScrollbarAdapter( + scrollState: ScrollState +): ScrollbarAdapter = remember(scrollState) { + OldScrollbarAdapter(scrollState) +} + +/** + * Create and [remember] (old) [ScrollbarAdapter] for lazy scrollable container and current instance + * of [scrollState] + */ +@Deprecated( + message ="Use rememberScrollbarAdapter instead", + replaceWith = ReplaceWith( + expression = "rememberScrollbarAdapter(scrollState)", + "androidx.compose.foundation.rememberScrollbarAdapter" + ) +) +@JvmName("rememberScrollbarAdapter") +@Suppress("DEPRECATION") +@Composable +fun rememberOldScrollbarAdapter( + scrollState: LazyListState, +): ScrollbarAdapter { + return remember(scrollState) { + OldScrollbarAdapter(scrollState) + } +} + +/** + * ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll + * + * [scrollState] is instance of [ScrollState] which is used by scrollable component + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.verticalScroll(state)) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + */ +@Deprecated( + message = "Use ScrollbarAdapter() instead", + replaceWith = ReplaceWith( + expression = "ScrollbarAdapter(scrollState)", + "androidx.compose.foundation.ScrollbarAdapter" + ) +) +@JvmName("ScrollbarAdapter") +@Suppress("DEPRECATION") +fun OldScrollbarAdapter( + scrollState: ScrollState +): ScrollbarAdapter = ScrollbarAdapter(scrollState).asOldAdapter() + +/** + * ScrollbarAdapter for lazy lists. + * + * [scrollState] is instance of [LazyListState] which is used by scrollable component + * + * Example: + * Box(Modifier.fillMaxSize()) { + * val state = rememberLazyListState() + * + * LazyColumn(state = state) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + */ +@Deprecated( + message = "Use ScrollbarAdapter() instead", + replaceWith = ReplaceWith( + expression = "ScrollbarAdapter(scrollState)", + "androidx.compose.foundation.ScrollbarAdapter" + ) +) +@JvmName("ScrollbarAdapter") +@Suppress("DEPRECATION") +fun OldScrollbarAdapter( + scrollState: LazyListState +): ScrollbarAdapter = ScrollbarAdapter(scrollState).asOldAdapter() + +/** + * Create and [remember] [androidx.compose.foundation.v2.ScrollbarAdapter] for + * scrollable container with the given instance [ScrollState]. + */ +@JvmName("rememberScrollbarAdapter2") +@Composable +fun rememberScrollbarAdapter( + scrollState: ScrollState +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = remember(scrollState) { + ScrollbarAdapter(scrollState) +} + +/** + * Create and [remember] [androidx.compose.foundation.v2.ScrollbarAdapter] for + * lazy scrollable container with the given instance [LazyListState]. + */ +@JvmName("rememberScrollbarAdapter2") +@Composable +fun rememberScrollbarAdapter( + scrollState: LazyListState, +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = remember(scrollState) { + ScrollbarAdapter(scrollState) +} + +@JvmName("rememberScrollbarAdapter2") +@Composable +fun rememberScrollbarAdapter( + scrollState: LazyStaggeredGridState, +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = remember(scrollState) { + ScrollbarAdapter(scrollState) +} + +/** + * Create and [remember] [androidx.compose.foundation.v2.ScrollbarAdapter] for lazy grid with + * the given instance of [LazyGridState]. + */ +@JvmName("rememberScrollbarAdapter2") +@Composable +fun rememberScrollbarAdapter( + scrollState: LazyGridState, +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = remember(scrollState) { + ScrollbarAdapter(scrollState) +} + +/** + * ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll + * + * [scrollState] is instance of [ScrollState] which is used by scrollable component + * + * Example: + * val state = rememberScrollState(0) + * + * Box(Modifier.fillMaxSize()) { + * Box(modifier = Modifier.verticalScroll(state)) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + */ +@JvmName("ScrollbarAdapter2") +fun ScrollbarAdapter( + scrollState: ScrollState +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState) + +/** + * ScrollbarAdapter for lazy lists. + * + * [scrollState] is instance of [LazyListState] which is used by scrollable component + * + * Example: + * Box(Modifier.fillMaxSize()) { + * val state = rememberLazyListState() + * + * LazyColumn(state = state) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + */ +@JvmName("ScrollbarAdapter2") +fun ScrollbarAdapter( + scrollState: LazyListState +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = LazyListScrollbarAdapter(scrollState) + +@JvmName("ScrollbarAdapter2") +fun ScrollbarAdapter( + scrollState: LazyStaggeredGridState +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = LazyStaggerGirdListScrollbarAdapter(scrollState) + + +/** + * ScrollbarAdapter for lazy grids. + * + * [scrollState] is instance of [LazyGridState] which is used by scrollable component + * + * Example: + * Box(Modifier.fillMaxSize()) { + * val state = rememberLazyGridState() + * + * LazyVerticalGrid(columns = ..., state = state) { + * ... + * } + * + * VerticalScrollbar( + * adapter = rememberScrollbarAdapter(state) + * modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + * ) + * } + */ +@JvmName("ScrollbarAdapter2") +fun ScrollbarAdapter( + scrollState: LazyGridState +): top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter = LazyGridScrollbarAdapter(scrollState) + +/** + * Defines how to scroll the scrollable component + */ +@Deprecated("Use top.kagg886.pmf.ui.component.scroll.v2.ScrollbarAdapter instead") +interface ScrollbarAdapter { + + /** + * Scroll offset of the content inside the scrollable component. + * Offset "100" means that the content is scrolled by 100 pixels from the start. + */ + val scrollOffset: Float + + /** + * Instantly jump to [scrollOffset] in pixels + * + * @param containerSize size of the scrollable container + * (for example, it is height of ScrollableColumn if we use VerticalScrollbar) + * @param scrollOffset target value in pixels to jump to, + * value will be coerced to 0..maxScrollOffset + */ + suspend fun scrollTo(containerSize: Int, scrollOffset: Float) + + /** + * Maximum scroll offset of the content inside the scrollable component + * + * @param containerSize size of the scrollable component + * (for example, it is height of ScrollableColumn if we use VerticalScrollbar) + */ + fun maxScrollOffset(containerSize: Int): Float + +} + +private val SliderAdapter.thumbPixelRange: IntRange + get() { + val start = position.roundToInt() + val endExclusive = start + thumbSize.roundToInt() + + return (start until endExclusive) + } + +private val IntRange.size get() = last + 1 - first + +private fun verticalMeasurePolicy( + sliderAdapter: SliderAdapter, + setContainerSize: (Int) -> Unit, + scrollThickness: Int +) = MeasurePolicy { measurables, constraints -> + setContainerSize(constraints.maxHeight) + val pixelRange = sliderAdapter.thumbPixelRange + val placeable = measurables.first().measure( + Constraints.fixed( + constraints.constrainWidth(scrollThickness), + pixelRange.size + ) + ) + layout(placeable.width, constraints.maxHeight) { + placeable.place(0, pixelRange.first) + } +} + +private fun horizontalMeasurePolicy( + sliderAdapter: SliderAdapter, + setContainerSize: (Int) -> Unit, + scrollThickness: Int +) = MeasurePolicy { measurables, constraints -> + setContainerSize(constraints.maxWidth) + val pixelRange = sliderAdapter.thumbPixelRange + val placeable = measurables.first().measure( + Constraints.fixed( + pixelRange.size, + constraints.constrainHeight(scrollThickness) + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(pixelRange.first, 0) + } +} + +private fun Modifier.scrollbarDrag( + interactionSource: MutableInteractionSource, + draggedInteraction: MutableState, + sliderAdapter: SliderAdapter, +): Modifier = composed { + val currentInteractionSource by rememberUpdatedState(interactionSource) + val currentDraggedInteraction by rememberUpdatedState(draggedInteraction) + val currentSliderAdapter by rememberUpdatedState(sliderAdapter) + + pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val interaction = DragInteraction.Start() + currentInteractionSource.tryEmit(interaction) + currentDraggedInteraction.value = interaction + currentSliderAdapter.onDragStarted() + val isSuccess = drag(down.id) { change -> + currentSliderAdapter.onDragDelta(change.positionChange()) + change.consume() + } + val finishInteraction = if (isSuccess) { + DragInteraction.Stop(interaction) + } else { + DragInteraction.Cancel(interaction) + } + currentInteractionSource.tryEmit(finishInteraction) + currentDraggedInteraction.value = null + } + } +} + +private fun Modifier.scrollOnPressTrack( + isVertical: Boolean, + reverseLayout: Boolean, + sliderAdapter: SliderAdapter, +) = composed { + val coroutineScope = rememberCoroutineScope() + val scroller = remember(sliderAdapter, coroutineScope, reverseLayout) { + TrackPressScroller(coroutineScope, sliderAdapter, reverseLayout) + } + Modifier.pointerInput(scroller) { + detectScrollViaTrackGestures( + isVertical = isVertical, + scroller = scroller + ) + } +} + +/** + * Responsible for scrolling when the scrollbar track is pressed (outside the thumb). + */ +private class TrackPressScroller( + private val coroutineScope: CoroutineScope, + private val sliderAdapter: SliderAdapter, + private val reverseLayout: Boolean, +) { + + /** + * The current direction of scroll (1: down/right, -1: up/left, 0: not scrolling) + */ + private var direction = 0 + + /** + * The currently pressed location (in pixels) on the scrollable axis. + */ + private var offset: Float? = null + + /** + * The job that keeps scrolling while the track is pressed. + */ + private var job: Job? = null + + /** + * Calculates the direction of scrolling towards the given offset (in pixels). + */ + private fun directionOfScrollTowards(offset: Float): Int { + val pixelRange = sliderAdapter.thumbPixelRange + return when { + offset < pixelRange.first -> if (reverseLayout) 1 else -1 + offset > pixelRange.last -> if (reverseLayout) -1 else 1 + else -> 0 + } + } + + /** + * Scrolls once towards the current offset, if it matches the direction of the current gesture. + */ + private suspend fun scrollTowardsCurrentOffset() { + offset?.let { + val currentDirection = directionOfScrollTowards(it) + if (currentDirection != direction) + return + with(sliderAdapter.adapter) { + scrollTo(scrollOffset + currentDirection * viewportSize) + } + } + } + + /** + * Starts the job that scrolls continuously towards the current offset. + */ + private fun startScrolling() { + job?.cancel() + job = coroutineScope.launch { + scrollTowardsCurrentOffset() + delay(DelayBeforeSecondScrollOnTrackPress) + while (true) { + scrollTowardsCurrentOffset() + delay(DelayBetweenScrollsOnTrackPress) + } + } + } + + /** + * Invoked on the first press for a gesture. + */ + fun onPress(offset: Float) { + this.offset = offset + this.direction = directionOfScrollTowards(offset) + + if (direction != 0) + startScrolling() + } + + /** + * Invoked when the pointer moves while pressed during the gesture. + */ + fun onMovePressed(offset: Float) { + this.offset = offset + } + + /** + * Cleans up when the gesture finishes. + */ + private fun cleanupAfterGesture(){ + job?.cancel() + direction = 0 + offset = null + } + + /** + * Invoked when the button is released. + */ + fun onRelease() { + cleanupAfterGesture() + } + + /** + * Invoked when the gesture is cancelled. + */ + fun onGestureCancelled() { + cleanupAfterGesture() + // Maybe revert to the initial position? + } + +} + +/** + * Detects the pointer events relevant for the "scroll by pressing on the track outside the thumb" + * gesture and calls the corresponding methods in the [scroller]. + */ +private suspend fun PointerInputScope.detectScrollViaTrackGestures( + isVertical: Boolean, + scroller: TrackPressScroller +) { + fun Offset.onScrollAxis() = if (isVertical) y else x + + awaitEachGesture { + val down = awaitFirstDown() + scroller.onPress(down.position.onScrollAxis()) + + while (true) { + val drag = + if (isVertical) + awaitVerticalDragOrCancellation(down.id) + else + awaitHorizontalDragOrCancellation(down.id) + + if (drag == null) { + scroller.onGestureCancelled() + break + } else if (!drag.pressed) { + scroller.onRelease() + break + } else + scroller.onMovePressed(drag.position.onScrollAxis()) + } + } +} + +/** + * The delay between the 1st and 2nd scroll while the scrollbar track is pressed outside the thumb. + */ +internal const val DelayBeforeSecondScrollOnTrackPress: Long = 300L + +/** + * The delay between each subsequent (after the 2nd) scroll while the scrollbar track is pressed + * outside the thumb. + */ +internal const val DelayBetweenScrollsOnTrackPress: Long = 100L diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/v2/v2.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/v2/v2.kt new file mode 100644 index 0000000..e56a788 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/component/scroll/v2/v2.kt @@ -0,0 +1,560 @@ +/* + * 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 + * + * http://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. + */ +@file:JvmName("Scrollbar_desktopKt") + +package top.kagg886.pmf.ui.component.scroll.v2 + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.geometry.Offset +import kotlin.jvm.JvmName +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Defines how to scroll the scrollable component and how to display a scrollbar for it. + * + * The values of this interface are typically in pixels, but do not have to be. + * It's possible to create an adapter with any scroll range of `Double` values. + */ +interface ScrollbarAdapter { + + // We use `Double` values here in order to allow scrolling both very large (think LazyList with + // millions of items) and very small (think something whose natural coordinates are less than 1) + // content. + + /** + * Scroll offset of the content inside the scrollable component. + * + * For example, a value of `100` could mean the content is scrolled by 100 pixels from the + * start. + */ + val scrollOffset: Double + + /** + * The size of the scrollable content, on the scrollable axis. + */ + val contentSize: Double + + /** + * The size of the viewport, on the scrollable axis. + */ + val viewportSize: Double + + /** + * Instantly jump to [scrollOffset]. + * + * @param scrollOffset target offset to jump to, value will be coerced to the valid + * scroll range. + */ + suspend fun scrollTo(scrollOffset: Double) + +} + +/** + * The maximum scroll offset of the scrollable content. + */ +val ScrollbarAdapter.maxScrollOffset: Double + get() = (contentSize - viewportSize).coerceAtLeast(0.0) + +internal class ScrollableScrollbarAdapter( + private val scrollState: ScrollState +) : ScrollbarAdapter { + + override val scrollOffset: Double get() = scrollState.value.toDouble() + + override suspend fun scrollTo(scrollOffset: Double) { + scrollState.scrollTo(scrollOffset.roundToInt()) + } + + override val contentSize: Double + // This isn't strictly correct, as the actual content can be smaller + // than the viewport when scrollState.maxValue is 0, but the scrollbar + // doesn't really care as long as contentSize <= viewportSize; it's + // just not showing itself + get() = scrollState.maxValue + viewportSize + + override val viewportSize: Double + get() = scrollState.viewportSize.toDouble() + +} + +/** + * Base class for [LazyListScrollbarAdapter] and [LazyGridScrollbarAdapter], + * and in the future maybe other lazy widgets that lay out their content in lines. + */ +internal abstract class LazyLineContentAdapter : ScrollbarAdapter { + + // Implement the adapter in terms of "lines", which means either rows, + // (for a vertically scrollable widget) or columns (for a horizontally + // scrollable one). + // For LazyList this translates directly to items; for LazyGrid, it + // translates to rows/columns of items. + + class VisibleLine( + val index: Int, + val offset: Int + ) + + /** + * Return the first visible line, if any. + */ + protected abstract fun firstVisibleLine(): VisibleLine? + + /** + * Return the total number of lines. + */ + protected abstract fun totalLineCount(): Int + + /** + * The sum of content padding (before+after) on the scrollable axis. + */ + protected abstract fun contentPadding(): Int + + /** + * Scroll immediately to the given line, and offset it by [scrollOffset] pixels. + */ + protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) + + /** + * Scroll from the current position by the given amount of pixels. + */ + protected abstract suspend fun scrollBy(value: Float) + + /** + * Return the average size (on the scrollable axis) of the visible lines. + */ + protected abstract fun averageVisibleLineSize(): Double + + /** + * The spacing between lines. + */ + protected abstract val lineSpacing: Int + + private val averageVisibleLineSize by derivedStateOf { + if (totalLineCount() == 0) + 0.0 + else + averageVisibleLineSize() + } + + private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing + + override val scrollOffset: Double + get() { + val firstVisibleLine = firstVisibleLine() + return if (firstVisibleLine == null) + 0.0 + else + firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset + } + + override val contentSize: Double + get() { + val totalLineCount = totalLineCount() + return averageVisibleLineSize * totalLineCount + + lineSpacing * (totalLineCount - 1).coerceAtLeast(0) + + contentPadding() + } + + override suspend fun scrollTo(scrollOffset: Double) { + val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset + + // if we scroll less than viewport we need to use scrollBy function to avoid + // undesirable scroll jumps (when an item size is different) + // + // if we scroll more than viewport we should immediately jump to this position + // without recreating all items between the current and the new position + if (abs(distance) <= viewportSize) { + scrollBy(distance.toFloat()) + } else { + snapTo(scrollOffset) + } + } + + private suspend fun snapTo(scrollOffset: Double) { + val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset) + + val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing) + .toInt() + .coerceAtLeast(0) + .coerceAtMost(totalLineCount() - 1) + + val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing) + .toInt() + .coerceAtLeast(0) + + snapToLine(lineIndex = index, scrollOffset = offset) + } + +} + +internal class LazyListScrollbarAdapter( + private val scrollState: LazyListState +) : LazyLineContentAdapter() { + + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { + if (orientation == Orientation.Vertical) + viewportSize.height + else + viewportSize.width + }.toDouble() + + /** + * A heuristic that tries to ignore the "currently stickied" header because it breaks the other + * computations in this adapter: + * - The currently stickied header always appears in the list of visible items, with its + * regular index. This makes [firstVisibleLine] always return its index, even if the list has + * been scrolled far beyond it. + * - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't + * overlap, and the stickied item breaks this assumption. + * + * Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it + * could be sticky, but not stickied to the top of the list right now) item, if there is one. + * + * Note that this heuristic breaks down if the sticky header covers the entire list, so that + * it's the only visible item for some portion of the scroll range. But there's currently no + * known better way to solve it, and it's a relatively unusual case. + */ + private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo) { + when (size) { + 0 -> null + 1 -> 0 + else -> { + val first = this[0] + val second = this[1] + // If either the indices or the offsets aren't continuous, then the first item is + // sticky, so we return 1 + if ((first.index < second.index - 1) || + (first.offset + first.size + lineSpacing > second.offset) + ) + 1 + else + 0 + } + } + } + + override fun firstVisibleLine(): VisibleLine? { + val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null + val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex] + return VisibleLine( + index = firstFloatingItem.index, + offset = firstFloatingItem.offset + ) + } + + override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount + + override fun contentPadding() = with(scrollState.layoutInfo) { + beforeContentPadding + afterContentPadding + } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToItem(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.scrollBy(value) + } + + override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) { + val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0 + val first = this[firstFloatingIndex] + val last = last() + val count = size - firstFloatingIndex + (last.offset + last.size - first.offset - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing + +} + +internal class LazyStaggerGirdListScrollbarAdapter( + private val scrollState: LazyStaggeredGridState +) : LazyLineContentAdapter() { + + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { + if (orientation == Orientation.Vertical) + viewportSize.height + else + viewportSize.width + }.toDouble() + + /** + * A heuristic that tries to ignore the "currently stickied" header because it breaks the other + * computations in this adapter: + * - The currently stickied header always appears in the list of visible items, with its + * regular index. This makes [firstVisibleLine] always return its index, even if the list has + * been scrolled far beyond it. + * - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't + * overlap, and the stickied item breaks this assumption. + * + * Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it + * could be sticky, but not stickied to the top of the list right now) item, if there is one. + * + * Note that this heuristic breaks down if the sticky header covers the entire list, so that + * it's the only visible item for some portion of the scroll range. But there's currently no + * known better way to solve it, and it's a relatively unusual case. + */ + private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo) { + when (size) { + 0 -> null + 1 -> 0 + else -> { + val first = this[0] + val second = this[1] + // If either the indices or the offsets aren't continuous, then the first item is + // sticky, so we return 1 + if ((first.index < second.index - 1) || + (first.offset.y + first.size.height + lineSpacing > second.offset.y) + ) + 1 + else + 0 + } + } + } + + override fun firstVisibleLine(): VisibleLine? { + val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null + val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex] + return VisibleLine( + index = firstFloatingItem.index, + offset = firstFloatingItem.offset.y + ) + } + + override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount + + override fun contentPadding() = with(scrollState.layoutInfo) { + beforeContentPadding + afterContentPadding + } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToItem(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.scrollBy(value) + } + + override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) { + val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0 + val first = this[firstFloatingIndex] + val last = last() + val count = size - firstFloatingIndex + (last.offset.y + last.size.height - first.offset.y - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing + +} + + +internal class LazyGridScrollbarAdapter( + private val scrollState: LazyGridState +) : LazyLineContentAdapter() { + + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { + if (orientation == Orientation.Vertical) + viewportSize.height + else + viewportSize.width + }.toDouble() + + private val isVertical = scrollState.layoutInfo.orientation == Orientation.Vertical + + private val unknownLine = with(LazyGridItemInfo) { + if (isVertical) UnknownRow else UnknownColumn + } + + private fun LazyGridItemInfo.line() = if (isVertical) row else column + + private fun LazyGridItemInfo.mainAxisSize() = with(size) { + if (isVertical) height else width + } + + private fun LazyGridItemInfo.mainAxisOffset() = with(offset) { + if (isVertical) y else x + } + + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + private fun lineOfIndex(index: Int) = index / scrollState.slotsPerLine + + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + private fun indexOfFirstInLine(line: Int) = line * scrollState.slotsPerLine + + override fun firstVisibleLine(): VisibleLine? { + return scrollState.layoutInfo.visibleItemsInfo + .firstOrNull { it.line() != unknownLine } // Skip exiting items + ?.let { firstVisibleItem -> + VisibleLine( + index = firstVisibleItem.line(), + offset = firstVisibleItem.mainAxisOffset() + ) + } + } + + override fun totalLineCount(): Int { + val itemCount = scrollState.layoutInfo.totalItemsCount + return if (itemCount == 0) + 0 + else + lineOfIndex(itemCount - 1) + 1 + } + + override fun contentPadding() = with(scrollState.layoutInfo) { + beforeContentPadding + afterContentPadding + } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToItem( + index = indexOfFirstInLine(lineIndex), + scrollOffset = scrollOffset + ) + } + + override suspend fun scrollBy(value: Float) { + scrollState.scrollBy(value) + } + + override fun averageVisibleLineSize(): Double { + val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo + val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine } + if (indexOfFirstKnownLineItem == -1) + return 0.0 + val reallyVisibleItemsInfo = // Non-exiting visible items + visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size) + + // Compute the size of the last line + val lastLine = reallyVisibleItemsInfo.last().line() + val lastLineSize = reallyVisibleItemsInfo + .asReversed() + .asSequence() + .takeWhile { it.line() == lastLine } + .maxOf { it.mainAxisSize() } + + val first = reallyVisibleItemsInfo.first() + val last = reallyVisibleItemsInfo.last() + val lineCount = last.line() - first.line() + 1 + val lineSpacingSum = (lineCount - 1) * lineSpacing + return ( + last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum + ).toDouble() / lineCount + } + + override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing + +} + +internal class SliderAdapter( + val adapter: ScrollbarAdapter, + private val trackSize: Int, + private val minHeight: Float, + private val reverseLayout: Boolean, + private val isVertical: Boolean, + private val coroutineScope: CoroutineScope +) { + + private val contentSize get() = adapter.contentSize + private val visiblePart: Double + get() { + val contentSize = contentSize + return if (contentSize == 0.0) + 1.0 + else + (adapter.viewportSize / contentSize).coerceAtMost(1.0) + } + + val thumbSize + get() = (trackSize * visiblePart).coerceAtLeast(minHeight.toDouble()) + + private val scrollScale: Double + get() { + val extraScrollbarSpace = trackSize - thumbSize + val extraContentSpace = adapter.maxScrollOffset // == contentSize - viewportSize + return if (extraContentSpace == 0.0) 1.0 else extraScrollbarSpace / extraContentSpace + } + + private val rawPosition: Double + get() = scrollScale * adapter.scrollOffset + + val position: Double + get() = if (reverseLayout) trackSize - thumbSize - rawPosition else rawPosition + + val bounds get() = position..position + thumbSize + + // How much of the current drag was ignored because we've reached the end of the scrollbar area + private var unscrolledDragDistance = 0.0 + + /** Called when the thumb dragging starts */ + fun onDragStarted() { + unscrolledDragDistance = 0.0 + } + + private suspend fun setPosition(value: Double) { + val rawPosition = if (reverseLayout) { + trackSize - thumbSize - value + } else { + value + } + adapter.scrollTo(rawPosition / scrollScale) + } + + private val dragMutex = Mutex() + + /** Called on every movement while dragging the thumb */ + fun onDragDelta(offset: Offset) { + coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { + // Mutex is used to ensure that all earlier drag deltas were applied + // before calculating a new raw position + dragMutex.withLock { + val dragDelta = if (isVertical) offset.y else offset.x + val maxScrollPosition = adapter.maxScrollOffset * scrollScale + val currentPosition = position + val targetPosition = + (currentPosition + dragDelta + unscrolledDragDistance).coerceIn( + 0.0, + maxScrollPosition + ) + val sliderDelta = targetPosition - currentPosition + + // Have to add to position for smooth content scroll if the items are of different size + val newPos = position + sliderDelta + setPosition(newPos) + unscrolledDragDistance += dragDelta - sliderDelta + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/illust/IllustDetailScreen.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/illust/IllustDetailScreen.kt index f9e967a..f5b7f3b 100644 --- a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/illust/IllustDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/illust/IllustDetailScreen.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit @@ -34,6 +36,8 @@ import top.kagg886.pmf.backend.currentPlatform import top.kagg886.pmf.backend.useWideScreenMode import top.kagg886.pmf.ui.component.* import top.kagg886.pmf.ui.component.icon.Download +import top.kagg886.pmf.ui.component.scroll.VerticalScrollbar +import top.kagg886.pmf.ui.component.scroll.rememberScrollbarAdapter import top.kagg886.pmf.ui.route.main.download.DownloadScreenModel import top.kagg886.pmf.ui.route.main.search.SearchScreen import top.kagg886.pmf.ui.util.* @@ -204,117 +208,124 @@ class IllustDetailScreen(illust: SerializableWrapper) : Screen, KoinComp val show = remember(expand) { if (expand) img else img.take(3) } - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(show) { - ProgressedAsyncImage( - url = it, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - .aspectRatio(illust.width.toFloat() / illust.height) - .clickable { - startIndex = img.indexOf(it) - preview = true - } - ) - Spacer(Modifier.height(16.dp)) - } - if (needExpand && !expand) { - item { - TextButton( - onClick = { - expand = true - }, + + Box(modifier = Modifier.fillMaxSize()) { + val scroll = rememberLazyListState() + LazyColumn(state = scroll, modifier = Modifier.padding(end = 8.dp)) { + items(show) { + ProgressedAsyncImage( + url = it, + contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() - ) { - Text("展开更多", textAlign = TextAlign.Center) + .aspectRatio(illust.width.toFloat() / illust.height) + .clickable { + startIndex = img.indexOf(it) + preview = true + } + ) + Spacer(Modifier.height(16.dp)) + } + if (needExpand && !expand) { + item { + TextButton( + onClick = { + expand = true + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("展开更多", textAlign = TextAlign.Center) + } } } - } - item { - AuthorCard( - modifier = Modifier.fillMaxWidth(), - user = illust.user - ) { - if (it) { - model.followUser().join() - } else { - model.unFollowUser().join() + item { + AuthorCard( + modifier = Modifier.fillMaxWidth(), + user = illust.user + ) { + if (it) { + model.followUser().join() + } else { + model.unFollowUser().join() + } } + Spacer(Modifier.height(16.dp)) } - Spacer(Modifier.height(16.dp)) - } - - item { - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - ListItem( - overlineContent = { - Text(illust.id.toString(), style = MaterialTheme.typography.labelSmall) - }, - headlineContent = { - Text(illust.title) - }, - supportingContent = { - Text(illust.caption.ifEmpty { "没有简介" }, style = MaterialTheme.typography.labelLarge) - }, - trailingContent = { - Row( - Modifier.size(120.dp, 68.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - val downloadModel = koinScreenModel() - IconButton( - onClick = { - downloadModel.startDownload(illust) - }, - enabled = illust.contentImages[IllustImagesType.ORIGIN] != null, - modifier = Modifier.size(30.dp) - ) { - Icon(Download, null) - } - FavoriteButton( - isFavorite = illust.isBookMarked, - modifier = Modifier.size(30.dp) + item { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + ListItem( + overlineContent = { + Text(illust.id.toString(), style = MaterialTheme.typography.labelSmall) + }, + headlineContent = { + Text(illust.title) + }, + supportingContent = { + Text(illust.caption.ifEmpty { "没有简介" }, style = MaterialTheme.typography.labelLarge) + }, + trailingContent = { + Row( + Modifier.size(120.dp, 68.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - if (it == FavoriteState.Favorite) { - model.likeIllust().join() - return@FavoriteButton + val downloadModel = koinScreenModel() + IconButton( + onClick = { + downloadModel.startDownload(illust) + }, + enabled = illust.contentImages[IllustImagesType.ORIGIN] != null, + modifier = Modifier.size(30.dp) + ) { + Icon(Download, null) } - if (it == FavoriteState.NotFavorite) { - model.disLikeIllust().join() - return@FavoriteButton + FavoriteButton( + isFavorite = illust.isBookMarked, + modifier = Modifier.size(30.dp) + ) { + if (it == FavoriteState.Favorite) { + model.likeIllust().join() + return@FavoriteButton + } + if (it == FavoriteState.NotFavorite) { + model.disLikeIllust().join() + return@FavoriteButton + } } } } - } - ) + ) + } } - } - - item { - FlowRow { - for (tag in illust.tags) { - AssistChip( - modifier = Modifier.padding(4.dp), - onClick = { - nav.push( - SearchScreen( - initialKeyWords = tag.name + item { + FlowRow { + for (tag in illust.tags) { + AssistChip( + modifier = Modifier.padding(4.dp), + onClick = { + nav.push( + SearchScreen( + initialKeyWords = tag.name + ) ) - ) - }, - label = { - Column { - Text(tag.name, style = MaterialTheme.typography.labelMedium) - tag.translatedName?.let { - Text(it, style = MaterialTheme.typography.labelSmall) + }, + label = { + Column { + Text(tag.name, style = MaterialTheme.typography.labelMedium) + tag.translatedName?.let { + Text(it, style = MaterialTheme.typography.labelSmall) + } } } - } - ) + ) + } } } } + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight() + ) } } diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/novel/NovelDetailScreen.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/novel/NovelDetailScreen.kt index 8bc7414..9747637 100644 --- a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/novel/NovelDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/route/main/detail/novel/NovelDetailScreen.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.launch import top.kagg886.pixko.module.novel.Novel import top.kagg886.pmf.LocalSnackBarHost import top.kagg886.pmf.ui.component.* +import top.kagg886.pmf.ui.component.scroll.VerticalScrollbar +import top.kagg886.pmf.ui.component.scroll.rememberScrollbarAdapter import top.kagg886.pmf.ui.route.main.detail.author.AuthorScreen import top.kagg886.pmf.ui.route.main.search.SearchScreen import top.kagg886.pmf.ui.route.main.search.SearchTab @@ -270,10 +272,18 @@ class NovelDetailScreen(private val id: Long) : Screen { is NovelDetailViewState.Success -> { - RichText( - state = state.nodeMap.toSortedMap().map { it.value }, - modifier = modifier.fillMaxWidth().padding(15.dp).verticalScroll(rememberScrollState()) - ) + Box(modifier.fillMaxWidth().padding(start = 15.dp, end = 8.dp)) { + val scroll = rememberScrollState() + RichText( + state = state.nodeMap.toSortedMap().map { it.value }, + modifier = Modifier.padding(end = 7.dp).verticalScroll(scroll) + ) + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight() + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/comment-screen.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/comment-screen.kt index af67632..8b910fb 100644 --- a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/comment-screen.kt +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/comment-screen.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import top.kagg886.pmf.LocalSnackBarHost import top.kagg886.pmf.ui.component.* +import top.kagg886.pmf.ui.component.scroll.VerticalScrollbar +import top.kagg886.pmf.ui.component.scroll.rememberScrollbarAdapter @Composable fun CommentPanel(model: CommentViewModel, modifier: Modifier = Modifier) { @@ -69,7 +71,7 @@ private fun CommentPanelContainer(model: CommentViewModel, state: CommentViewSta } return@PullToRefreshBox } - LazyColumn(state = state.scrollerState) { + LazyColumn(state = state.scrollerState, modifier = Modifier.padding(end = 8.dp)) { items(state.comments) { comment -> OutlinedCard( modifier = Modifier.fillMaxWidth().padding(5.dp) @@ -176,6 +178,12 @@ private fun CommentPanelContainer(model: CommentViewModel, state: CommentViewSta ) } } + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = 4.dp), + ) + BackToTopOrRefreshButton( isNotInTop = scroll.canScrollBackward, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/illust-fetch-screen.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/illust-fetch-screen.kt index 8f7b3db..3725a59 100644 --- a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/illust-fetch-screen.kt +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/illust-fetch-screen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card @@ -26,6 +27,8 @@ import top.kagg886.pmf.backend.AppConfig import top.kagg886.pmf.ui.component.* import top.kagg886.pmf.ui.component.icon.Disabled import top.kagg886.pmf.ui.component.icon.Robot +import top.kagg886.pmf.ui.component.scroll.VerticalScrollbar +import top.kagg886.pmf.ui.component.scroll.rememberScrollbarAdapter import top.kagg886.pmf.ui.route.main.detail.illust.IllustDetailScreen @Composable @@ -69,7 +72,7 @@ private fun IllustFetchContent0(state: IllustFetchViewState, model: IllustFetchV } LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(AppConfig.defaultGalleryWidth), - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(end = 8.dp), state = scroll ) { items( @@ -146,6 +149,12 @@ private fun IllustFetchContent0(state: IllustFetchViewState, model: IllustFetchV ) } } + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = 4.dp) + ) + BackToTopOrRefreshButton( isNotInTop = scroll.canScrollBackward, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), diff --git a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/novel-fetch-screen.kt b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/novel-fetch-screen.kt index 909f869..f965eed 100644 --- a/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/novel-fetch-screen.kt +++ b/composeApp/src/commonMain/kotlin/top/kagg886/pmf/ui/util/novel-fetch-screen.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.launch import top.kagg886.pmf.ui.component.* import top.kagg886.pmf.ui.component.icon.Disabled import top.kagg886.pmf.ui.component.icon.Robot +import top.kagg886.pmf.ui.component.scroll.VerticalScrollbar +import top.kagg886.pmf.ui.component.scroll.rememberScrollbarAdapter import top.kagg886.pmf.ui.route.main.detail.novel.NovelDetailScreen @Composable @@ -73,7 +75,7 @@ private fun NovelFetchContent0(state: NovelFetchViewState, model: NovelFetchView return@PullToRefreshBox } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(end = 8.dp), state = scroll ) { items(state.novels, key = { it.id }) { @@ -155,6 +157,12 @@ private fun NovelFetchContent0(state: NovelFetchViewState, model: NovelFetchView ) } } + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = 4.dp) + ) + BackToTopOrRefreshButton( isNotInTop = scroll.canScrollBackward, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),