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

commonMain.androidx.compose.material3.adaptive.layout.PaneExpansionState.kt Maven / Gradle / Ivy

/*
 * Copyright 2024 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.
 */

package androidx.compose.material3.adaptive.layout

import androidx.annotation.IntRange
import androidx.annotation.VisibleForTesting
import androidx.collection.IntList
import androidx.collection.MutableIntList
import androidx.collection.emptyIntList
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.gestures.DragScope
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.isSpecified
import kotlin.math.abs
import kotlinx.coroutines.coroutineScope

@ExperimentalMaterial3AdaptiveApi
@Stable
internal class PaneExpansionState(internal val anchors: List = emptyList()) :
    DraggableState {
    private var firstPaneWidthState by mutableIntStateOf(UnspecifiedWidth)
    private var firstPanePercentageState by mutableFloatStateOf(Float.NaN)
    private var currentDraggingOffsetState by mutableIntStateOf(UnspecifiedWidth)

    var firstPaneWidth: Int
        set(value) {
            firstPanePercentageState = Float.NaN
            currentDraggingOffsetState = UnspecifiedWidth
            firstPaneWidthState = value.coerceIn(0, maxExpansionWidth)
        }
        get() = firstPaneWidthState

    var firstPanePercentage: Float
        set(value) {
            require(value in 0f..1f) { "Percentage value needs to be in [0, 1]" }
            firstPaneWidthState = UnspecifiedWidth
            currentDraggingOffsetState = UnspecifiedWidth
            firstPanePercentageState = value
        }
        get() = firstPanePercentageState

    internal var currentDraggingOffset
        get() = currentDraggingOffsetState
        private set(value) {
            val coercedValue = value.coerceIn(0, maxExpansionWidth)
            if (value == currentDraggingOffsetState) {
                return
            }
            currentDraggingOffsetState = coercedValue
            currentMeasuredDraggingOffset = coercedValue
        }

    internal var isDragging by mutableStateOf(false)
        private set

    internal var isSettling by mutableStateOf(false)
        private set

    internal val isDraggingOrSettling
        get() = isDragging || isSettling

    @VisibleForTesting
    internal var maxExpansionWidth = 0
        private set

    // Use this field to store the dragging offset decided by measuring instead of dragging to
    // prevent redundant re-composition.
    @VisibleForTesting
    internal var currentMeasuredDraggingOffset = UnspecifiedWidth
        private set

    private var anchorPositions: IntList = emptyIntList()

    private val dragScope: DragScope =
        object : DragScope {
            override fun dragBy(pixels: Float): Unit = dispatchRawDelta(pixels)
        }

    private val dragMutex = MutatorMutex()

    fun isUnspecified(): Boolean =
        firstPaneWidthState == UnspecifiedWidth &&
            firstPanePercentage.isNaN() &&
            currentDraggingOffset == UnspecifiedWidth

    override fun dispatchRawDelta(delta: Float) {
        if (currentMeasuredDraggingOffset == UnspecifiedWidth) {
            return
        }
        currentDraggingOffset = (currentMeasuredDraggingOffset + delta).toInt()
    }

    override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) =
        coroutineScope {
            isDragging = true
            dragMutex.mutateWith(dragScope, dragPriority, block)
            isDragging = false
        }

    internal fun onMeasured(measuredWidth: Int, density: Density) {
        if (measuredWidth == maxExpansionWidth) {
            return
        }
        maxExpansionWidth = measuredWidth
        if (firstPaneWidth != UnspecifiedWidth) {
            firstPaneWidth = firstPaneWidth
        }
        anchorPositions = anchors.toPositions(measuredWidth, density)
    }

    internal fun onExpansionOffsetMeasured(measuredOffset: Int) {
        currentMeasuredDraggingOffset = measuredOffset
    }

    internal suspend fun settleToAnchorIfNeeded(velocity: Float) {
        val currentAnchorPositions = anchorPositions
        if (currentAnchorPositions.isEmpty()) {
            return
        }
        dragMutex.mutate(MutatePriority.PreventUserInput) {
            isSettling = true
            // TODO(conradchen): Use the right animation spec here.
            animate(
                currentMeasuredDraggingOffset.toFloat(),
                currentAnchorPositions
                    .getPositionOfTheClosestAnchor(currentMeasuredDraggingOffset, velocity)
                    .toFloat(),
                velocity,
            ) { value, _ ->
                currentDraggingOffset = value.toInt()
            }
            isSettling = false
        }
    }

    private fun IntList.getPositionOfTheClosestAnchor(currentPosition: Int, velocity: Float): Int =
        minBy(
            when {
                velocity >= AnchoringVelocityThreshold -> {
                    { anchorPosition: Int ->
                        val delta = anchorPosition - currentPosition
                        if (delta < 0) Int.MAX_VALUE else delta
                    }
                }
                velocity <= -AnchoringVelocityThreshold -> {
                    { anchorPosition: Int ->
                        val delta = currentPosition - anchorPosition
                        if (delta < 0) Int.MAX_VALUE else delta
                    }
                }
                else -> {
                    { anchorPosition: Int -> abs(currentPosition - anchorPosition) }
                }
            }
        )

    companion object {
        const val UnspecifiedWidth = -1
        private const val AnchoringVelocityThreshold = 200F
    }
}

@ExperimentalMaterial3AdaptiveApi
@Immutable
internal class PaneExpansionAnchor
private constructor(
    val percentage: Int,
    val startOffset: Dp // TODO(conradchen): confirm RTL support
) {
    constructor(@IntRange(0, 100) percentage: Int) : this(percentage, Dp.Unspecified)

    constructor(startOffset: Dp) : this(Int.MIN_VALUE, startOffset)
}

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun List.toPositions(
    maxExpansionWidth: Int,
    density: Density
): IntList {
    val anchors = MutableIntList(size)
    @Suppress("ListIterator") // Not necessarily a random-accessible list
    forEach { anchor ->
        if (anchor.startOffset.isSpecified) {
            val position =
                with(density) { anchor.startOffset.toPx() }
                    .toInt()
                    .let { if (it < 0) maxExpansionWidth + it else it }
            if (position in 0..maxExpansionWidth) {
                anchors.add(position)
            }
        } else {
            anchors.add(maxExpansionWidth * anchor.percentage / 100)
        }
    }
    //the reason is https://youtrack.jetbrains.com/issue/KT-70005
    //it was fixed in androidx.collection:collection:1.5.0-alpha01, but we redirect on 1.4.0 yet
    if (anchors.isNotEmpty()) anchors.sort()
    return anchors
}

private fun > IntList.minBy(selector: (Int) -> T): Int {
    if (isEmpty()) {
        throw NoSuchElementException()
    }
    var minElem = this[0]
    var minValue = selector(minElem)
    for (i in 1 until size) {
        val elem = this[i]
        val value = selector(elem)
        if (minValue > value) {
            minElem = elem
            minValue = value
        }
    }
    return minElem
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy