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

commonMain.io.github.materiiapps.panels.SwipePanels.kt Maven / Gradle / Ivy

package io.github.materiiapps.panels

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlin.math.roundToInt

/**
 * Used for keeping track of which panel is currently active.
 */
public enum class SwipePanelsValue {
    /**
     * The first panel, showing it and a portion of the center panel on the right.
     */
    Start,

    /**
     * The middle panel, taking up the full screen.
     * Both other panels are not rendered when this is active.
     */
    Center,

    /**
     * The last (third) panel, showing it and a portion of the center panel on the left.
     */
    End,
}

@Stable
public class SwipePanelsState(
    initialValue: SwipePanelsValue = SwipePanelsValue.Center,
) {
    /**
     * The current panels state excluding dragging
     */
    public var currentValue: SwipePanelsValue by mutableStateOf(initialValue, referentialEqualityPolicy())
        internal set

    // neverEqualPolicy is needed in order to reanimate to the same values
    public var targetValue: SwipePanelsValue by mutableStateOf(initialValue, neverEqualPolicy())
        internal set

    /**
     * Whether any of the panels are currently being dragged.
     * This does not affect [currentValue] or [targetValue].
     */
    public var isDragging: Boolean by mutableStateOf(false)
        internal set

    /**
     * Animates towards a target panels state.
     */
    public fun setValue(value: SwipePanelsValue) {
        targetValue = value
    }

    /**
     * Animates to opening the start panel.
     */
    public fun openStart() {
        targetValue = SwipePanelsValue.Start
    }

    /**
     * Animates to opening the end panel.
     */
    public fun openEnd() {
        targetValue = SwipePanelsValue.End
    }

    /**
     * Animates to closing both the start & end panels, returning to the center.
     */
    public fun close() {
        targetValue = SwipePanelsValue.Center
    }
}

@Composable
public fun rememberSwipePanelsState(
    initialValue: SwipePanelsValue = SwipePanelsValue.Center,
): SwipePanelsState {
    return remember {
        SwipePanelsState(initialValue)
    }
}

/**
 * Makes a 3-panel Discord-like layout.
 *
 * **NOTE**: This does NOT fill max size! You most likely want to apply a max size modifier
 * otherwise the panels will be as tiny as your panel content.
 *
 * @param start The start (first) panel
 * @param center The full middle panel
 * @param end The end (third) panel
 * @param modifier The modifier applied to the base layout
 * @param maxPanelWidth Fraction of how much width the start/end panels use up before the center panel appears on the side.
 * @param changeThreshold Fraction of how much width of the base layout needs to be dragged before the panels switch to the new state.
 * @param inBetweenPadding Padding between the start/center and center/end panel areas.
 * @param state Panels state. Look at [SwipePanelsState] and [rememberSwipePanelsState] for more information.
 */
@Composable
public fun SwipePanels(
    start: @Composable () -> Unit,
    center: @Composable () -> Unit,
    end: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    maxPanelWidth: Float = 0.9f,
    changeThreshold: Float = 0.1f,
    inBetweenPadding: Dp = 0.dp,
    state: SwipePanelsState = rememberSwipePanelsState(),
) {
    val density = LocalDensity.current

    var dragVelocity by remember { mutableStateOf(0f) }
    var maxWidthSynthetic by remember { mutableStateOf(0f) }
    var centerOffset by remember { mutableStateOf(0f) }

    // Update targetValue when dragging stops
    LaunchedEffect(state.isDragging, maxWidthSynthetic) {
        if (!state.isDragging) {
            val offsetWithVelocity = centerOffset + (dragVelocity / 26f)
            val inverseThreshold = 1 - changeThreshold

            val targetValue = when {
                state.targetValue != SwipePanelsValue.Start && offsetWithVelocity >= maxWidthSynthetic * changeThreshold ->
                    SwipePanelsValue.Start

                state.targetValue != SwipePanelsValue.End && offsetWithVelocity <= -maxWidthSynthetic * changeThreshold ->
                    SwipePanelsValue.End

                state.targetValue == SwipePanelsValue.Start && offsetWithVelocity <= maxWidthSynthetic * inverseThreshold ->
                    SwipePanelsValue.Center
                state.targetValue == SwipePanelsValue.Start ->
                    SwipePanelsValue.Start

                state.targetValue == SwipePanelsValue.End && offsetWithVelocity >= -maxWidthSynthetic * inverseThreshold ->
                    SwipePanelsValue.Center
                state.targetValue == SwipePanelsValue.End
                -> SwipePanelsValue.End

                state.targetValue == SwipePanelsValue.Center ->
                    SwipePanelsValue.Center

                else -> state.currentValue
            }

            state.targetValue = targetValue
        }
    }

    // Animate the panels when targetValue changes or cancel on drag
    LaunchedEffect(state.targetValue, state.isDragging) {
        if (state.isDragging) {
            // Cancel the previous LaunchedEffect
            return@LaunchedEffect
        }

        val targetValue = when (state.targetValue) {
            SwipePanelsValue.Center -> 0f
            SwipePanelsValue.Start -> maxWidthSynthetic
            SwipePanelsValue.End -> -maxWidthSynthetic
        }
        Animatable(centerOffset).animateTo(
            targetValue = targetValue,
            animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
        ) {
            centerOffset = this.value
        }

        state.currentValue = state.targetValue
    }

    BoxWithConstraints(
        modifier = modifier
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    centerOffset = (centerOffset + delta).coerceIn(
                        minimumValue = -maxWidthSynthetic,
                        maximumValue = maxWidthSynthetic,
                    )
                },
                onDragStarted = {
                    dragVelocity = 0f
                    state.isDragging = true
                },
                onDragStopped = {
                    dragVelocity = it
                    state.isDragging = false
                },
            ),
    ) {
        // Keep track of the max width available in the current layout accounting for our constraints
        LaunchedEffect(maxWidth, maxPanelWidth, density) {
            maxWidthSynthetic = maxPanelWidth *
                    density.run { maxWidth.toPx() } +
                    density.run { inBetweenPadding.toPx() }
        }

        // Panels are not rendered if they are not visible
        val startVisible by remember { derivedStateOf { centerOffset >= 0 } }
        val endVisible by remember { derivedStateOf { centerOffset <= 0 } }

        // Start panel
        if (startVisible) {
            Box(
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .zIndex(0f)
                    .fillMaxHeight()
                    .fillMaxWidth(maxPanelWidth),
                propagateMinConstraints = true,
            ) {
                start()
            }
        }

        // Center panel
        Box(
            modifier = Modifier
                .offset { IntOffset(x = centerOffset.roundToInt(), y = 0) }
                .zIndex(1f),
            propagateMinConstraints = true,
        ) {
            center()
        }

        // End panel
        if (endVisible) {
            Box(
                modifier = Modifier
                    .align(Alignment.CenterEnd)
                    .zIndex(0f)
                    .fillMaxHeight()
                    .fillMaxWidth(maxPanelWidth),
                propagateMinConstraints = true,
            ) {
                end()
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy