net.peanuuutz.fork.ui.foundation.input.ContentScrollState.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of fork-ui Show documentation
Show all versions of fork-ui Show documentation
Comprehensive API designed for Minecraft modders
The newest version!
/*
* Copyright 2020 The Android Open Source Project
* Modifications Copyright 2022 Peanuuutz
*
* 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.
*/
package net.peanuuutz.fork.ui.foundation.input
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import net.peanuuutz.fork.ui.animation.animateDecay
import net.peanuuutz.fork.ui.animation.spec.decay.DefaultFloatDecayAnimationSpec
import net.peanuuutz.fork.ui.animation.spec.decay.composite.DecayAnimationSpec
import net.peanuuutz.fork.ui.animation.spec.target.DefaultFloatAnimationSpec
import net.peanuuutz.fork.ui.animation.spec.target.composite.FiniteAnimationSpec
import net.peanuuutz.fork.ui.ui.layout.Alignment
import net.peanuuutz.fork.ui.ui.layout.LayoutOrientation
import net.peanuuutz.fork.ui.ui.layout.LayoutOrientation.Horizontal
import net.peanuuutz.fork.ui.ui.layout.LayoutOrientation.Vertical
import net.peanuuutz.fork.ui.ui.modifier.layout.layoutId
import net.peanuuutz.fork.ui.ui.node.LayoutInfo
import net.peanuuutz.fork.ui.util.MutationPriority
import net.peanuuutz.fork.util.common.fastFindOrNull
import net.peanuuutz.fork.util.common.milliNow
import kotlin.math.roundToInt
@Composable
fun rememberContentScrollState(
initialOffset: Float = 0.0f
): ContentScrollState {
return rememberSaveable(
saver = ContentScrollState.Saver
) {
ContentScrollState(initialOffset)
}
}
@Stable
class ContentScrollState(
initialOffset: Float = 0.0f
) : ScrollState {
// -------- States --------
// ---- Offset ----
var offset: Float
get() = _offset
set(value) {
if (_offset == value) {
return
}
_offset = value.coerceIn(0.0f, maxOffset)
}
val roundedOffset: Int by derivedStateOf { offset.roundToInt() }
var maxOffset: Float
get() = _maxOffset
set(value) {
if (_maxOffset == value) {
return
}
_maxOffset = value
_offset = offset.coerceIn(0.0f, value)
}
// ---- Container ----
val viewportMainAxisSize: Int?
get() = _viewportMainAxisSize
val contentMainAxisSize: Int?
get() = _contentMainAxisSize
val containerInfo: LayoutInfo?
get() = _containerInfo
val containerOrientation: LayoutOrientation?
get() = _containerOrientation
// -------- State Operations --------
suspend fun animateIncrementalScroll(
velocity: Float,
velocityDecayAnimationSpec: DecayAnimationSpec = DefaultFloatDecayAnimationSpec,
priority: MutationPriority = MutationPriority.Default
) {
val testAmount = velocity * TestAmountDuration
if (canScroll(testAmount).not()) {
return
}
// Start TODO Find a way to get rid of this block
val currentTimeMillis = milliNow()
if (currentTimeMillis - lastIncrementMillis <= IncrementInterval) {
return
}
lastIncrementMillis = currentTimeMillis
// End
val previousVelocity = currentVelocity
currentVelocity = if (previousVelocity * velocity > 0) {
previousVelocity + velocity
} else {
velocity
}
scroll(priority) {
var previousValue = 0.0f
animateDecay(
initialValue = 0.0f,
initialVelocity = currentVelocity,
decayAnimationSpec = velocityDecayAnimationSpec
) { value, velocity ->
previousValue += scrollBy(value - previousValue)
currentVelocity = velocity
}
}
}
// ======== Internal ========
// -------- Delegate --------
private val delegate: ScrollState = ScrollState { amount ->
val currentOffset = offset
val newOffset = currentOffset - amount
val coercedNewOffset = newOffset.coerceIn(0.0f, maxOffset)
val consumedAmount = currentOffset - coercedNewOffset
_offset = coercedNewOffset
consumedAmount
}
override val isScrolling: Boolean
get() = delegate.isScrolling
override suspend fun scroll(priority: MutationPriority, scope: suspend ScrollScope.() -> Unit) {
delegate.scroll(priority, scope)
}
override fun scrollBy(amount: Float): Float {
return delegate.scrollBy(amount)
}
// -------- States --------
// ---- Offset ----
private var _offset: Float by mutableStateOf(initialOffset)
private var _maxOffset: Float by mutableStateOf(Float.POSITIVE_INFINITY)
// ---- Container ----
private var _viewportMainAxisSize: Int? by mutableStateOf(null)
private var _contentMainAxisSize: Int? by mutableStateOf(null)
private var _containerInfo: LayoutInfo? by mutableStateOf(null)
private var _containerOrientation: LayoutOrientation? by mutableStateOf(null)
internal fun attach(
info: LayoutInfo,
orientation: LayoutOrientation
) {
val viewportSize = info.size
val contentSize = info.innerInfo!!.size
when (orientation) {
Vertical -> {
_viewportMainAxisSize = viewportSize.height
_contentMainAxisSize = contentSize.height
}
Horizontal -> {
_viewportMainAxisSize = viewportSize.width
_contentMainAxisSize = contentSize.width
}
}
_containerInfo = info
_containerOrientation = orientation
}
internal fun detach() {
_viewportMainAxisSize = null
_contentMainAxisSize = null
_containerInfo = null
_containerOrientation = null
}
// -------- State Operations --------
private var lastIncrementMillis: Long = 0L
private var currentVelocity: Float = 0.0f
// ======== Public ========
companion object {
val Saver: Saver = Saver(
save = { it.offset },
restore = { ContentScrollState(it) }
)
}
}
// -------- States --------
fun ContentScrollState.canScroll(amount: Float): Boolean {
return offset - amount in 0.0f..maxOffset
}
// -------- State Operations --------
suspend fun ContentScrollState.animateToItemByIndex(
index: Int,
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
animationSpec: FiniteAnimationSpec = DefaultFloatAnimationSpec,
priority: MutationPriority = MutationPriority.Default
): Float {
return animateToItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
animationSpec = animationSpec,
priority = priority
) { items ->
items.getOrNull(index)
}
}
suspend fun ContentScrollState.snapToItemByIndex(
index: Int,
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
priority: MutationPriority = MutationPriority.Default
): Float {
return snapToItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
priority = priority
) { items ->
items.getOrNull(index)
}
}
suspend fun ContentScrollState.animateToItemByLayoutId(
layoutId: Any?,
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
animationSpec: FiniteAnimationSpec = DefaultFloatAnimationSpec,
priority: MutationPriority = MutationPriority.Default
): Float {
return animateToItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
animationSpec = animationSpec,
priority = priority
) { items ->
items.fastFindOrNull { it.layoutId == layoutId }
}
}
suspend fun ContentScrollState.snapToItemByLayoutId(
layoutId: Any?,
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
priority: MutationPriority = MutationPriority.Default
): Float {
return snapToItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
priority = priority
) { items ->
items.fastFindOrNull { it.layoutId == layoutId }
}
}
suspend fun ContentScrollState.animateToItem(
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
animationSpec: FiniteAnimationSpec = DefaultFloatAnimationSpec,
priority: MutationPriority = MutationPriority.Default,
selector: (items: List) -> LayoutInfo?
): Float {
val targetOffset = calculateTargetOffsetFromItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
selector = selector
)
return animateTo(
targetOffset = targetOffset,
animationSpec = animationSpec,
priority = priority
)
}
suspend fun ContentScrollState.snapToItem(
alignmentInViewport: Alignment.HorizontalOrVertical = Alignment.CenterOnAxis,
localOffset: Float = 0.0f,
priority: MutationPriority = MutationPriority.Default,
selector: (items: List) -> LayoutInfo?
): Float {
val targetOffset = calculateTargetOffsetFromItem(
alignmentInViewport = alignmentInViewport,
localOffset = localOffset,
selector = selector
)
return snapTo(
targetOffset = targetOffset,
priority = priority
)
}
suspend fun ContentScrollState.animateTo(
targetOffset: Float,
animationSpec: FiniteAnimationSpec = DefaultFloatAnimationSpec,
priority: MutationPriority = MutationPriority.Default
): Float {
return animateScrollBy(
amount = convertTargetOffsetToScrollAmount(targetOffset),
animationSpec = animationSpec,
priority = priority
)
}
suspend fun ContentScrollState.snapTo(
targetOffset: Float,
priority: MutationPriority = MutationPriority.Default
): Float {
var amount = 0.0f
scroll(priority) {
val providedAmount = convertTargetOffsetToScrollAmount(targetOffset)
amount = scrollBy(providedAmount)
}
return amount
}
// ======== Internal ========
private const val IncrementInterval: Long = 20L
internal const val TestAmountDuration: Float = 0.02f // 20 milliseconds
// -------- State Operations --------
private fun ContentScrollState.convertTargetOffsetToScrollAmount(targetOffset: Float): Float {
return offset - targetOffset.coerceIn(0.0f, maxOffset)
}
internal fun ContentScrollState.calculateTargetOffsetFromItem(
alignmentInViewport: Alignment.HorizontalOrVertical,
localOffset: Float,
selector: (items: List) -> LayoutInfo?
): Float {
val currentOffset = offset
val viewportInfo = containerInfo ?: return currentOffset
val orientation = containerOrientation ?: return currentOffset
val itemInfo = selector(viewportInfo.childrenInfo) ?: return currentOffset
val itemPosition = viewportInfo.localPositionOf(itemInfo)
val itemOffset = when (orientation) {
Vertical -> {
val itemSize = itemInfo.size.height
val viewportSize = viewportInfo.size.height
val alignmentOffset = alignmentInViewport.align(
contentSize = itemSize,
availableSpace = viewportSize
)
itemPosition.y - alignmentOffset
}
Horizontal -> {
val itemSize = itemInfo.size.width
val viewportSize = viewportInfo.size.width
val alignmentOffset = alignmentInViewport.align(
contentSize = itemSize,
availableSpace = viewportSize
)
itemPosition.x - alignmentOffset
}
}
return currentOffset + itemOffset + localOffset
}