All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.peanuuutz.fork.ui.foundation.input.ContentScrollState.kt Maven / Gradle / Ivy

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
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy