mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-12 03:58:56 +01:00
Move more components to presentation-core module
This commit is contained in:
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Straight copy from Compose M3 for Button fork
|
||||
*/
|
||||
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Animates the [Dp] value of [this] between [from] and [to] [Interaction]s, to [target]. The
|
||||
* [AnimationSpec] used depends on the values for [from] and [to], see
|
||||
* [ElevationDefaults.incomingAnimationSpecForInteraction] and
|
||||
* [ElevationDefaults.outgoingAnimationSpecForInteraction] for more details.
|
||||
*
|
||||
* @param target the [Dp] target elevation for this component, corresponding to the elevation
|
||||
* desired for the [to] state.
|
||||
* @param from the previous [Interaction] that was used to calculate elevation. `null` if there
|
||||
* was no previous [Interaction], such as when the component is in its default state.
|
||||
* @param to the [Interaction] that this component is moving to, such as [PressInteraction.Press]
|
||||
* when this component is being pressed. `null` if this component is moving back to its default
|
||||
* state.
|
||||
*/
|
||||
internal suspend fun Animatable<Dp, *>.animateElevation(
|
||||
target: Dp,
|
||||
from: Interaction? = null,
|
||||
to: Interaction? = null,
|
||||
) {
|
||||
val spec = when {
|
||||
// Moving to a new state
|
||||
to != null -> ElevationDefaults.incomingAnimationSpecForInteraction(to)
|
||||
// Moving to default, from a previous state
|
||||
from != null -> ElevationDefaults.outgoingAnimationSpecForInteraction(from)
|
||||
// Loading the initial state, or moving back to the baseline state from a disabled /
|
||||
// unknown state, so just snap to the final value.
|
||||
else -> null
|
||||
}
|
||||
if (spec != null) animateTo(target, spec) else snapTo(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains default [AnimationSpec]s used for animating elevation between different [Interaction]s.
|
||||
*
|
||||
* Typically you should use [animateElevation] instead, which uses these [AnimationSpec]s
|
||||
* internally. [animateElevation] in turn is used by the defaults for cards and buttons.
|
||||
*
|
||||
* @see animateElevation
|
||||
*/
|
||||
private object ElevationDefaults {
|
||||
/**
|
||||
* Returns the [AnimationSpec]s used when animating elevation to [interaction], either from a
|
||||
* previous [Interaction], or from the default state. If [interaction] is unknown, then
|
||||
* returns `null`.
|
||||
*
|
||||
* @param interaction the [Interaction] that is being animated to
|
||||
*/
|
||||
fun incomingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec<Dp>? {
|
||||
return when (interaction) {
|
||||
is PressInteraction.Press -> DefaultIncomingSpec
|
||||
is DragInteraction.Start -> DefaultIncomingSpec
|
||||
is HoverInteraction.Enter -> DefaultIncomingSpec
|
||||
is FocusInteraction.Focus -> DefaultIncomingSpec
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [AnimationSpec]s used when animating elevation away from [interaction], to the
|
||||
* default state. If [interaction] is unknown, then returns `null`.
|
||||
*
|
||||
* @param interaction the [Interaction] that is being animated away from
|
||||
*/
|
||||
fun outgoingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec<Dp>? {
|
||||
return when (interaction) {
|
||||
is PressInteraction.Press -> DefaultOutgoingSpec
|
||||
is DragInteraction.Start -> DefaultOutgoingSpec
|
||||
is HoverInteraction.Enter -> HoveredOutgoingSpec
|
||||
is FocusInteraction.Focus -> DefaultOutgoingSpec
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val OutgoingSpecEasing: Easing = CubicBezierEasing(0.40f, 0.00f, 0.60f, 1.00f)
|
||||
|
||||
private val DefaultIncomingSpec = TweenSpec<Dp>(
|
||||
durationMillis = 120,
|
||||
easing = FastOutSlowInEasing,
|
||||
)
|
||||
|
||||
private val DefaultOutgoingSpec = TweenSpec<Dp>(
|
||||
durationMillis = 150,
|
||||
easing = OutgoingSpecEasing,
|
||||
)
|
||||
|
||||
private val HoveredOutgoingSpec = TweenSpec<Dp>(
|
||||
durationMillis = 120,
|
||||
easing = OutgoingSpecEasing,
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isScrolledToStart(): Boolean {
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
val firstItem = layoutInfo.visibleItemsInfo.firstOrNull()
|
||||
firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isScrolledToEnd(): Boolean {
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isScrollingUp(): Boolean {
|
||||
var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
|
||||
var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
if (previousIndex != firstVisibleItemIndex) {
|
||||
previousIndex > firstVisibleItemIndex
|
||||
} else {
|
||||
previousScrollOffset >= firstVisibleItemScrollOffset
|
||||
}.also {
|
||||
previousIndex = firstVisibleItemIndex
|
||||
previousScrollOffset = firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isScrollingDown(): Boolean {
|
||||
var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
|
||||
var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
|
||||
return remember {
|
||||
derivedStateOf {
|
||||
if (previousIndex != firstVisibleItemIndex) {
|
||||
previousIndex < firstVisibleItemIndex
|
||||
} else {
|
||||
previousScrollOffset <= firstVisibleItemScrollOffset
|
||||
}.also {
|
||||
previousIndex = firstVisibleItemIndex
|
||||
previousScrollOffset = firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
}.value
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.LayoutModifier
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Modifier.selectedBackground(isSelected: Boolean): Modifier = composed {
|
||||
if (isSelected) {
|
||||
val alpha = if (isSystemInDarkTheme()) 0.16f else 0.22f
|
||||
background(MaterialTheme.colorScheme.secondary.copy(alpha = alpha))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
|
||||
|
||||
fun Modifier.clickableNoIndication(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
): Modifier = composed {
|
||||
this.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* For TextField, the provided [action] will be invoked when
|
||||
* physical enter key is pressed.
|
||||
*
|
||||
* Naturally, the TextField should be set to single line only.
|
||||
*/
|
||||
fun Modifier.runOnEnterKeyPressed(action: () -> Unit): Modifier = this.onPreviewKeyEvent {
|
||||
when (it.key) {
|
||||
Key.Enter, Key.NumPadEnter -> {
|
||||
action()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierInspectorInfo")
|
||||
fun Modifier.minimumTouchTargetSize(): Modifier = composed(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "minimumTouchTargetSize"
|
||||
properties["README"] = "Adds outer padding to measure at least 48.dp (default) in " +
|
||||
"size to disambiguate touch interactions if the element would measure smaller"
|
||||
},
|
||||
) {
|
||||
if (LocalMinimumTouchTargetEnforcement.current) {
|
||||
val size = LocalViewConfiguration.current.minimumTouchTargetSize
|
||||
MinimumTouchTargetModifier(size)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
|
||||
private class MinimumTouchTargetModifier(val size: DpSize) : LayoutModifier {
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
// Be at least as big as the minimum dimension in both dimensions
|
||||
val width = maxOf(placeable.width, size.width.roundToPx())
|
||||
val height = maxOf(placeable.height, size.height.roundToPx())
|
||||
|
||||
return layout(width, height) {
|
||||
val centerX = ((width - placeable.width) / 2f).roundToInt()
|
||||
val centerY = ((height - placeable.height) / 2f).roundToInt()
|
||||
placeable.place(centerX, centerY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
val otherModifier = other as? MinimumTouchTargetModifier ?: return false
|
||||
return size == otherModifier.size
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return size.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
operator fun PaddingValues.plus(other: PaddingValues): PaddingValues {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
return PaddingValues(
|
||||
start = calculateStartPadding(layoutDirection) +
|
||||
other.calculateStartPadding(layoutDirection),
|
||||
end = calculateEndPadding(layoutDirection) +
|
||||
other.calculateEndPadding(layoutDirection),
|
||||
top = calculateTopPadding() + other.calculateTopPadding(),
|
||||
bottom = calculateBottomPadding() + other.calculateBottomPadding(),
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(
|
||||
name = "Light",
|
||||
showBackground = true,
|
||||
)
|
||||
@Preview(
|
||||
name = "Dark",
|
||||
showBackground = true,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
)
|
||||
annotation class ThemePreviews
|
||||
@@ -1,60 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.animateDecay
|
||||
import androidx.compose.animation.rememberSplineBasedDecay
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.MotionDurationScale
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* FlingBehavior that always uses the default motion scale.
|
||||
*
|
||||
* This makes the scrolling animation works like View's lists
|
||||
* when "Remove animation" settings is on.
|
||||
*/
|
||||
@Composable
|
||||
fun flingBehaviorIgnoringMotionScale(): FlingBehavior {
|
||||
val flingSpec = rememberSplineBasedDecay<Float>()
|
||||
return remember(flingSpec) {
|
||||
DefaultFlingBehavior(flingSpec)
|
||||
}
|
||||
}
|
||||
|
||||
private val DefaultMotionDurationScale = object : MotionDurationScale {
|
||||
// Use default motion scale factor
|
||||
override val scaleFactor: Float = 1f
|
||||
}
|
||||
|
||||
private class DefaultFlingBehavior(
|
||||
private val flingDecay: DecayAnimationSpec<Float>,
|
||||
) : FlingBehavior {
|
||||
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
|
||||
// come up with the better threshold, but we need it since spline curve gives us NaNs
|
||||
return if (abs(initialVelocity) > 1f) {
|
||||
var velocityLeft = initialVelocity
|
||||
var lastValue = 0f
|
||||
withContext(DefaultMotionDurationScale) {
|
||||
AnimationState(
|
||||
initialValue = 0f,
|
||||
initialVelocity = initialVelocity,
|
||||
).animateDecay(flingDecay) {
|
||||
val delta = value - lastValue
|
||||
val consumed = scrollBy(delta)
|
||||
lastValue = value
|
||||
velocityLeft = this.velocity
|
||||
// avoid rounding errors and stop if anything is unconsumed
|
||||
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
|
||||
}
|
||||
}
|
||||
velocityLeft
|
||||
} else {
|
||||
initialVelocity
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Albert Chang
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Code taken from https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a
|
||||
* with some modifications to handle contentPadding.
|
||||
*
|
||||
* Modifiers for regular scrollable list is omitted.
|
||||
*/
|
||||
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastSumBy
|
||||
import eu.kanade.presentation.components.Scroller.STICKY_HEADER_KEY_PREFIX
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
/**
|
||||
* Draws horizontal scrollbar to a LazyList.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||
*/
|
||||
fun Modifier.drawHorizontalScrollbar(
|
||||
state: LazyListState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the top of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx)
|
||||
|
||||
/**
|
||||
* Draws vertical scrollbar to a LazyList.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||
*/
|
||||
fun Modifier.drawVerticalScrollbar(
|
||||
state: LazyListState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the start of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx)
|
||||
|
||||
private fun Modifier.drawScrollbar(
|
||||
state: LazyListState,
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
positionOffset: Float,
|
||||
): Modifier = drawScrollbar(
|
||||
orientation,
|
||||
reverseScrolling,
|
||||
) { reverseDirection, atEnd, thickness, color, alpha ->
|
||||
val layoutInfo = state.layoutInfo
|
||||
val viewportSize = if (orientation == Orientation.Horizontal) {
|
||||
layoutInfo.viewportSize.width
|
||||
} else {
|
||||
layoutInfo.viewportSize.height
|
||||
} - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding
|
||||
val items = layoutInfo.visibleItemsInfo
|
||||
val itemsSize = items.fastSumBy { it.size }
|
||||
val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize
|
||||
val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size
|
||||
val totalSize = estimatedItemSize * layoutInfo.totalItemsCount
|
||||
val thumbSize = viewportSize / totalSize * viewportSize
|
||||
val startOffset = if (items.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
items
|
||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
||||
.run {
|
||||
val startPadding = if (reverseDirection) layoutInfo.afterContentPadding else layoutInfo.beforeContentPadding
|
||||
startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize)
|
||||
}
|
||||
}
|
||||
val drawScrollbar = onDrawScrollbar(
|
||||
orientation, reverseDirection, atEnd, showScrollbar,
|
||||
thickness, color, alpha, thumbSize, startOffset, positionOffset,
|
||||
)
|
||||
drawContent()
|
||||
drawScrollbar()
|
||||
}
|
||||
|
||||
private fun ContentDrawScope.onDrawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
showScrollbar: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
thumbSize: Float,
|
||||
scrollOffset: Float,
|
||||
positionOffset: Float,
|
||||
): DrawScope.() -> Unit {
|
||||
val topLeft = if (orientation == Orientation.Horizontal) {
|
||||
Offset(
|
||||
if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset,
|
||||
if (atEnd) size.height - positionOffset - thickness else positionOffset,
|
||||
)
|
||||
} else {
|
||||
Offset(
|
||||
if (atEnd) size.width - positionOffset - thickness else positionOffset,
|
||||
if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset,
|
||||
)
|
||||
}
|
||||
val size = if (orientation == Orientation.Horizontal) {
|
||||
Size(thumbSize, thickness)
|
||||
} else {
|
||||
Size(thickness, thumbSize)
|
||||
}
|
||||
|
||||
return {
|
||||
if (showScrollbar) {
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = topLeft,
|
||||
size = size,
|
||||
alpha = alpha(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.drawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
onDraw: ContentDrawScope.(
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
) -> Unit,
|
||||
): Modifier = composed {
|
||||
val scrolled = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
}
|
||||
val nestedScrollConnection = remember(orientation, scrolled) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y
|
||||
if (delta != 0f) scrolled.tryEmit(Unit)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
LaunchedEffect(scrolled, alpha) {
|
||||
scrolled.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val reverseDirection = if (orientation == Orientation.Horizontal) {
|
||||
if (isLtr) reverseScrolling else !reverseScrolling
|
||||
} else {
|
||||
reverseScrolling
|
||||
}
|
||||
val atEnd = if (orientation == Orientation.Vertical) isLtr else true
|
||||
|
||||
val context = LocalContext.current
|
||||
val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() }
|
||||
val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f)
|
||||
Modifier
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.drawWithContent {
|
||||
onDraw(reverseDirection, atEnd, thickness, color, alpha::value)
|
||||
}
|
||||
}
|
||||
|
||||
private val FadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
delayMillis = ViewConfiguration.getScrollDefaultDelay(),
|
||||
)
|
||||
|
||||
@Preview(widthDp = 400, heightDp = 400, showBackground = true)
|
||||
@Composable
|
||||
fun LazyListScrollbarPreview() {
|
||||
val state = rememberLazyListState()
|
||||
LazyColumn(
|
||||
modifier = Modifier.drawVerticalScrollbar(state),
|
||||
state = state,
|
||||
) {
|
||||
items(50) {
|
||||
Text(
|
||||
text = "Item ${it + 1}",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 400, showBackground = true)
|
||||
@Composable
|
||||
fun LazyListHorizontalScrollbarPreview() {
|
||||
val state = rememberLazyListState()
|
||||
LazyRow(
|
||||
modifier = Modifier.drawHorizontalScrollbar(state),
|
||||
state = state,
|
||||
) {
|
||||
items(50) {
|
||||
Text(
|
||||
text = (it + 1).toString(),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user