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

net.peanuuutz.fork.ui.preset.Scrollbar.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.preset

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import net.peanuuutz.fork.ui.foundation.draw.background
import net.peanuuutz.fork.ui.foundation.input.ContentScrollMode
import net.peanuuutz.fork.ui.foundation.input.ContentScrollState
import net.peanuuutz.fork.ui.foundation.input.contentScrollable
import net.peanuuutz.fork.ui.foundation.input.detectDrag
import net.peanuuutz.fork.ui.foundation.input.detectPressRelease
import net.peanuuutz.fork.ui.foundation.input.hoverable
import net.peanuuutz.fork.ui.foundation.input.interaction.DragInteraction
import net.peanuuutz.fork.ui.foundation.input.interaction.MutableInteractionSource
import net.peanuuutz.fork.ui.foundation.input.interaction.PressInteraction
import net.peanuuutz.fork.ui.foundation.input.interaction.collectHoverState
import net.peanuuutz.fork.ui.foundation.input.interaction.detectAndEmitDragInteractions
import net.peanuuutz.fork.ui.foundation.input.interaction.detectAndEmitPressInteractions
import net.peanuuutz.fork.ui.foundation.input.interaction.tryEmitCancelOnDrag
import net.peanuuutz.fork.ui.foundation.input.interaction.tryEmitCancelOnPress
import net.peanuuutz.fork.ui.foundation.input.interrupt
import net.peanuuutz.fork.ui.foundation.layout.Box
import net.peanuuutz.fork.ui.foundation.layout.BoxScope
import net.peanuuutz.fork.ui.foundation.layout.fillMaxHeight
import net.peanuuutz.fork.ui.foundation.layout.fillMaxWidth
import net.peanuuutz.fork.ui.foundation.layout.height
import net.peanuuutz.fork.ui.foundation.layout.minHeight
import net.peanuuutz.fork.ui.foundation.layout.minWidth
import net.peanuuutz.fork.ui.foundation.layout.offset
import net.peanuuutz.fork.ui.foundation.layout.width
import net.peanuuutz.fork.ui.foundation.layout.wrapContentHeight
import net.peanuuutz.fork.ui.foundation.layout.wrapContentWidth
import net.peanuuutz.fork.ui.inspection.InspectInfo
import net.peanuuutz.fork.ui.preset.theme.Theme
import net.peanuuutz.fork.ui.ui.context.pointer.PointerEvent
import net.peanuuutz.fork.ui.ui.draw.Painter
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.Modifier
import net.peanuuutz.fork.ui.ui.modifier.ModifierNodeElement
import net.peanuuutz.fork.ui.ui.modifier.input.SuspendingPointerInputModifierNode
import net.peanuuutz.fork.ui.ui.node.BranchingModifierNode
import net.peanuuutz.fork.ui.ui.node.GlobalLayoutCallbackModifierNode
import net.peanuuutz.fork.ui.ui.node.LayoutInfo
import net.peanuuutz.fork.ui.ui.node.ModifierNode
import net.peanuuutz.fork.ui.ui.node.PointerEventPass
import net.peanuuutz.fork.ui.ui.node.PointerInputModifierNode
import net.peanuuutz.fork.ui.ui.unit.FloatOffset
import net.peanuuutz.fork.ui.ui.unit.IntOffset
import net.peanuuutz.fork.ui.ui.unit.IntSize
import net.peanuuutz.fork.ui.util.MutationPriority
import kotlin.math.roundToInt

@Composable
fun VerticalScrollbar(
    scrollState: ContentScrollState,
    modifier: Modifier = Modifier,
    scrollbarScrollMode: ContentScrollMode = ContentScrollMode.Default,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    scrollbarStyle: ScrollbarStyle = Theme.scrollbar
) {
    Box(
        modifier = modifier
            .minWidth(ScrollbarDefaults.Vertical.MinSize.width)
            .fillMaxHeight(),
        propagateMinConstraints = true
    ) {
        // We must do a double pass here to retrieve information about the track
        // and the container which Modifier.xxxContentScrollLayout applies to,
        // and this must be done AFTER that layout callback gets invoked
        var trackHeight by remember { mutableStateOf(0) }
        val trackHeightUpdater = remember<(Int) -> Unit> { { trackHeight = it } }
        val controller = remember(trackHeight, scrollState) {
            ScrollbarController(
                trackSize = trackHeight,
                scrollState = scrollState
            )
        }

        ScrollbarTrack(
            orientation = Vertical,
            controller = controller,
            scrollbarScrollMode = scrollbarScrollMode,
            interactionSource = interactionSource,
            modifier = Modifier
        )

        VerticalScrollbarThumb(
            controller = controller,
            trackHeightUpdater = trackHeightUpdater,
            interactionSource = interactionSource,
            scrollbarStyle = scrollbarStyle,
            modifier = Modifier
        )
    }
}

@Composable
fun HorizontalScrollbar(
    scrollState: ContentScrollState,
    modifier: Modifier = Modifier,
    scrollbarScrollMode: ContentScrollMode = ContentScrollMode.Default,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    scrollbarStyle: ScrollbarStyle = Theme.scrollbar
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .minHeight(ScrollbarDefaults.Horizontal.MinSize.height),
        propagateMinConstraints = true
    ) {
        // We must do a double pass here to retrieve information about the track
        // and the container which Modifier.xxxContentScrollLayout applies to,
        // and this must be done AFTER that layout callback gets invoked
        var trackWidth by remember { mutableStateOf(0) }
        val trackWidthUpdater = remember<(Int) -> Unit> { { trackWidth = it } }
        val controller = remember(trackWidth, scrollState) {
            ScrollbarController(
                trackSize = trackWidth,
                scrollState = scrollState
            )
        }

        ScrollbarTrack(
            orientation = Horizontal,
            controller = controller,
            scrollbarScrollMode = scrollbarScrollMode,
            interactionSource = interactionSource,
            modifier = Modifier
        )

        HorizontalScrollbarThumb(
            controller = controller,
            trackWidthUpdater = trackWidthUpdater,
            interactionSource = interactionSource,
            scrollbarStyle = scrollbarStyle,
            modifier = Modifier
        )
    }
}

object ScrollbarDefaults {
    object Vertical {
        val MinSize: IntSize = IntSize(4, 8)
    }

    object Horizontal {
        val MinSize: IntSize = IntSize(8, 4)
    }
}

@Stable
interface ScrollbarStyle {
    @Composable
    fun background(isSelected: Boolean): State

    @Stable
    abstract class Delegated(
        val delegate: ScrollbarStyle
    ) : ScrollbarStyle by delegate
}

val Theme.scrollbar: ScrollbarStyle
    @ReadOnlyComposable
    @Composable
    get() = LocalScrollbar.current

@NonRestartableComposable
@Composable
fun ScrollbarStyleProvider(
    scrollbar: ScrollbarStyle,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalScrollbar provides scrollbar,
        content = content
    )
}

@Stable
class DefaultScrollbarStyle(
    val background: Painter
) : ScrollbarStyle {
    @Composable
    override fun background(isSelected: Boolean): State {
        return rememberUpdatedState(background)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is DefaultScrollbarStyle) return false
        if (background != other.background) return false
        return true
    }

    override fun hashCode(): Int {
        return background.hashCode()
    }

    override fun toString(): String {
        return "DefaultScrollbarStyle(background=$background)"
    }
}

// ======== Internal ========

// -------- Common --------

@Composable
private fun BoxScope.ScrollbarTrack(
    orientation: LayoutOrientation,
    controller: ScrollbarController,
    scrollbarScrollMode: ContentScrollMode,
    interactionSource: MutableInteractionSource,
    modifier: Modifier
) {
    Box(
        modifier = modifier
            .matchParentSize()
            .scrollbarTrackRelocator(
                orientation = orientation,
                controller = controller,
                interactionSource = interactionSource
            )
            .contentScrollable(
                state = controller.scrollState,
                mode = scrollbarScrollMode
            )
            .hoverable(
                interactionSource = interactionSource,
                label = TrackThumbInteractions.Thumb
            )
    )
}

// ---- Scrollbar Controller ----

@Stable
private class ScrollbarController(
    val trackSize: Int,
    val scrollState: ContentScrollState
) {
    var offset: Float
        get() = scrollState.offset * offsetScale
        set(value) {
            scrollState.offset = value / offsetScale
        }

    val roundedOffset: Int by derivedStateOf { offset.roundToInt() }

    val thumbSize: Float
        get() = trackSize * sizeScale

    val roundedThumbSize: Int by derivedStateOf { thumbSize.roundToInt() }

    private val offsetScale: Float
        get() {
            val trackSize = trackSize
            val contentSize = scrollState.contentMainAxisSize
            return if (
                trackSize != 0 &&
                contentSize != null &&
                contentSize != 0
            ) {
                trackSize.toFloat() / contentSize
            } else {
                1.0f
            }
        }

    private val sizeScale: Float
        get() {
            val viewportSize = scrollState.viewportMainAxisSize
            val contentSize = scrollState.contentMainAxisSize
            return if (
                viewportSize != null &&
                viewportSize != 0 &&
                contentSize != null &&
                contentSize != 0 &&
                contentSize > viewportSize
            ) {
                viewportSize.toFloat() / contentSize
            } else {
                1.0f
            }
        }
}

// ---- Scrollbar Track Relocator Modifier ----

private fun Modifier.scrollbarTrackRelocator(
    orientation: LayoutOrientation,
    controller: ScrollbarController,
    interactionSource: MutableInteractionSource
): Modifier {
    val element = ScrollbarTrackRelocatorModifier(
        orientation = orientation,
        controller = controller,
        interactionSource = interactionSource
    )
    return this then element
}

private data class ScrollbarTrackRelocatorModifier(
    val orientation: LayoutOrientation,
    val controller: ScrollbarController,
    val interactionSource: MutableInteractionSource
) : ModifierNodeElement() {
    override fun create(): ScrollbarTrackRelocatorModifierNode {
        return ScrollbarTrackRelocatorModifierNode(
            orientation = orientation,
            controller = controller,
            interactionSource = interactionSource
        )
    }

    override fun update(node: ScrollbarTrackRelocatorModifierNode) {
        node.controller = controller
        node.interactionSource = interactionSource
    }

    override fun InspectInfo.inspect() {
        set("orientation", orientation)
    }
}

private class ScrollbarTrackRelocatorModifierNode(
    val orientation: LayoutOrientation,
    var controller: ScrollbarController,
    interactionSource: MutableInteractionSource
) : BranchingModifierNode(), PointerInputModifierNode {
    var interactionSource: MutableInteractionSource = interactionSource
        set(value) {
            if (field == value) {
                return
            }
            field.tryEmitCancelOnDrag(
                stateProvider = this::dragState,
                stateUpdater = this::dragState::set
            )
            field.tryEmitCancelOnPress(
                stateProvider = this::pressState,
                stateUpdater = this::pressState::set
            )
            field = value
        }

    private val pointerInputHandler: SuspendingPointerInputModifierNode = branch {
        SuspendingPointerInputModifierNode {
            coroutineScope {
                launch {
                    detectAndEmitDragInteractions(
                        interactionSourceProvider = this@ScrollbarTrackRelocatorModifierNode::interactionSource,
                        stateProvider = this@ScrollbarTrackRelocatorModifierNode::dragState,
                        stateUpdater = this@ScrollbarTrackRelocatorModifierNode::dragState::set,
                        labelProvider = { TrackThumbInteractions.Thumb }
                    )
                }
                launch {
                    detectDrag(
                        onDrag = { moveEvent ->
                            val trackLocalPosition = moveEvent.position
                            launch {
                                relocate(trackLocalPosition)
                            }
                        }
                    )
                }
                launch {
                    detectAndEmitPressInteractions(
                        interactionSourceProvider = this@ScrollbarTrackRelocatorModifierNode::interactionSource,
                        stateProvider = this@ScrollbarTrackRelocatorModifierNode::pressState,
                        stateUpdater = this@ScrollbarTrackRelocatorModifierNode::pressState::set,
                        labelProvider = { TrackThumbInteractions.Thumb }
                    )
                }
                launch {
                    detectPressRelease(
                        onPress = { tapEvent ->
                            val trackLocalPosition = tapEvent.position
                            launch {
                                relocate(trackLocalPosition)
                            }
                        }
                    )
                }
            }
        }
    }

    private var pressState: PressInteraction.Press? = null

    private var dragState: DragInteraction.Start? = null

    private suspend fun relocate(trackLocalPosition: FloatOffset) {
        with(controller) {
            val thumbCenterOffset = thumbSize / 2
            val targetOffset = when (orientation) {
                Vertical -> trackLocalPosition.y - thumbCenterOffset
                Horizontal -> trackLocalPosition.x - thumbCenterOffset
            }
            if (scrollState.isScrolling) {
                scrollState.interrupt(MutationPriority.User)
            }
            offset = targetOffset
        }
    }

    override fun onPointerEvent(pass: PointerEventPass, pointerEvent: PointerEvent) {
        pointerInputHandler.onPointerEvent(pass, pointerEvent)
    }
}

// ---- Scrollbar Track Size Calculator Modifier ----

private fun Modifier.scrollbarTrackSizeCalculator(
    trackMainAxisSizeUpdater: (Int) -> Unit,
    orientation: LayoutOrientation
): Modifier {
    val element = ScrollbarTrackSizeCalculatorModifier(
        trackMainAxisSizeUpdater = trackMainAxisSizeUpdater,
        orientation = orientation
    )
    return this then element
}

private data class ScrollbarTrackSizeCalculatorModifier(
    val trackMainAxisSizeUpdater: (Int) -> Unit,
    val orientation: LayoutOrientation
) : ModifierNodeElement() {
    override fun create(): ScrollbarTrackSizeCalculatorModifierNode {
        return ScrollbarTrackSizeCalculatorModifierNode(
            trackMainAxisSizeUpdater = trackMainAxisSizeUpdater,
            orientation = orientation
        )
    }

    override fun update(node: ScrollbarTrackSizeCalculatorModifierNode) {}

    override fun InspectInfo.inspect() {
        set("orientation", orientation)
    }
}

private class ScrollbarTrackSizeCalculatorModifierNode(
    val trackMainAxisSizeUpdater: (Int) -> Unit,
    val orientation: LayoutOrientation
) : ModifierNode(), GlobalLayoutCallbackModifierNode {
    override fun onGloballyPlaced(info: LayoutInfo) {
        val trackSize = info.size
        val trackMainAxisSize = when (orientation) {
            Vertical -> trackSize.height
            Horizontal -> trackSize.width
        }
        trackMainAxisSizeUpdater(trackMainAxisSize)
    }
}

// -------- Vertical Scrollbar --------

@Composable
private fun BoxScope.VerticalScrollbarThumb(
    controller: ScrollbarController,
    trackHeightUpdater: (Int) -> Unit,
    interactionSource: MutableInteractionSource,
    scrollbarStyle: ScrollbarStyle,
    modifier: Modifier
) {
    val controllerRemembered by rememberUpdatedState(controller)
    val thumbHeight = controller.roundedThumbSize.coerceAtLeast(ScrollbarDefaults.Vertical.MinSize.height)
    val isHovered by interactionSource.collectHoverState()
    val isSelected = isHovered
    val background by scrollbarStyle.background(isSelected = isSelected)

    Box(
        modifier = modifier
            .scrollbarTrackSizeCalculator(
                trackMainAxisSizeUpdater = trackHeightUpdater,
                orientation = Vertical
            )
            .matchParentSize()
            .wrapContentHeight(Alignment.Top)
            .offset { IntOffset(0, controllerRemembered.roundedOffset) }
            .height(thumbHeight)
            .background(background)
    )
}

// -------- Horizontal Scrollbar --------

@Composable
private fun BoxScope.HorizontalScrollbarThumb(
    controller: ScrollbarController,
    trackWidthUpdater: (Int) -> Unit,
    interactionSource: MutableInteractionSource,
    scrollbarStyle: ScrollbarStyle,
    modifier: Modifier
) {
    val controllerRemembered by rememberUpdatedState(controller)
    val thumbWidth = controller.roundedThumbSize.coerceAtLeast(ScrollbarDefaults.Horizontal.MinSize.width)
    val isHovered by interactionSource.collectHoverState()
    val isSelected = isHovered
    val background by scrollbarStyle.background(isSelected = isSelected)

    Box(
        modifier = modifier
            .scrollbarTrackSizeCalculator(
                trackMainAxisSizeUpdater = trackWidthUpdater,
                orientation = Horizontal
            )
            .matchParentSize()
            .wrapContentWidth(Alignment.Left)
            .offset { IntOffset(controllerRemembered.roundedOffset, 0) }
            .width(thumbWidth)
            .background(background)
    )
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy