diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Scrollbars.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Scrollbars.kt index 01cf27467..078bc8f5f 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Scrollbars.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Scrollbars.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -24,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.jewel.foundation.Stroke @@ -35,6 +37,7 @@ import org.jetbrains.jewel.intui.standalone.styling.light import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.CheckboxRow import org.jetbrains.jewel.ui.component.Divider +import org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer import org.jetbrains.jewel.ui.component.RadioButtonRow import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.component.Typography @@ -49,48 +52,45 @@ import java.util.Locale @Composable fun Scrollbars() { - Column { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { val isDark = JewelTheme.isDark - val baseStyle = - remember(isDark) { - if (isDark) ScrollbarStyle.dark() else ScrollbarStyle.light() - } + val baseStyle = remember(isDark) { if (isDark) ScrollbarStyle.dark() else ScrollbarStyle.light() } var alwaysVisible by remember { mutableStateOf(false) } var clickBehavior by remember { mutableStateOf(baseStyle.trackClickBehavior) } SettingsRow(alwaysVisible, clickBehavior, { alwaysVisible = it }, { clickBehavior = it }) - Spacer(modifier = Modifier.height(16.dp)) + val style by + remember(alwaysVisible, clickBehavior, baseStyle) { + mutableStateOf( + if (alwaysVisible) { + ScrollbarStyle( + colors = baseStyle.colors, + metrics = baseStyle.metrics, + trackClickBehavior = clickBehavior, + scrollbarVisibility = ScrollbarVisibility.AlwaysVisible.default(), + ) + } else { + ScrollbarStyle( + colors = baseStyle.colors, + metrics = baseStyle.metrics, + trackClickBehavior = clickBehavior, + scrollbarVisibility = ScrollbarVisibility.WhenScrolling.default(), + ) + }, + ) + } Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - val style by - remember(alwaysVisible, clickBehavior, baseStyle) { - mutableStateOf( - if (alwaysVisible) { - ScrollbarStyle( - colors = baseStyle.colors, - metrics = baseStyle.metrics, - trackClickBehavior = clickBehavior, - scrollbarVisibility = ScrollbarVisibility.AlwaysVisible.default(), - ) - } else { - ScrollbarStyle( - colors = baseStyle.colors, - metrics = baseStyle.metrics, - trackClickBehavior = clickBehavior, - scrollbarVisibility = ScrollbarVisibility.WhenScrolling.default(), - ) - }, - ) - } - - LazyColumnWithScrollbar(style, Modifier.weight(1f).height(200.dp)) - ColumnWithScrollbar(style, Modifier.weight(1f).height(200.dp)) + LazyColumnWithScrollbar(style, Modifier.height(200.dp).weight(1f)) + ColumnWithScrollbar(style, Modifier.height(200.dp).weight(1f)) } + + HorizontalScrollbarContent(style, Modifier.fillMaxWidth()) } } @@ -102,11 +102,7 @@ private fun SettingsRow( onClickBehaviorChange: (TrackClickBehavior) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { - CheckboxRow( - checked = alwaysVisible, - onCheckedChange = onAlwaysVisibleChange, - text = "Always visible", - ) + CheckboxRow(checked = alwaysVisible, onCheckedChange = onAlwaysVisibleChange, text = "Always visible") Spacer(Modifier.weight(1f)) @@ -187,24 +183,47 @@ private fun ColumnWithScrollbar( val scrollState = rememberScrollState() Column( modifier = - Modifier - .background(JewelTheme.textAreaStyle.colors.background) + Modifier.background(JewelTheme.textAreaStyle.colors.background) .verticalScroll(scrollState) - .padding(end = scrollbarContentSafePadding(style)) .align(Alignment.CenterStart), ) { - LIST_ITEMS.forEach { + LIST_ITEMS.forEachIndexed { index, line -> Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = it, + modifier = + Modifier.padding(horizontal = 8.dp).padding(end = scrollbarContentSafePadding(style)), + text = line, ) + if (index < LIST_ITEMS.lastIndex) { + Box(Modifier.height(8.dp), contentAlignment = Alignment.CenterStart) { + Divider(Orientation.Horizontal, Modifier.fillMaxWidth()) + } + } } } - VerticalScrollbar( - scrollState = scrollState, - modifier = Modifier.align(Alignment.CenterEnd), - style = style, - ) + VerticalScrollbar(scrollState = scrollState, modifier = Modifier.align(Alignment.CenterEnd), style = style) + } + } +} + +@Composable +private fun HorizontalScrollbarContent( + scrollbarStyle: ScrollbarStyle, + modifier: Modifier, +) { + HorizontallyScrollableContainer( + modifier = modifier.border(Stroke.Alignment.Outside, 1.dp, JewelTheme.globalColors.borders.normal).background(Color.Red), + style = scrollbarStyle, + ) { + Column( + modifier = + Modifier.fillMaxHeight() + .background(JewelTheme.textAreaStyle.colors.background) + .padding(bottom = scrollbarContentSafePadding(scrollbarStyle)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val oneLineIpsum = LOREM_IPSUM.replace('\n', ' ') + repeat(4) { Text(oneLineIpsum) } } } } @@ -224,12 +243,8 @@ private const val LOREM_IPSUM = "Sed nec sapien nec dui rhoncus bibendum. Sed blandit bibendum libero." private val LIST_ITEMS = - LOREM_IPSUM - .split(",") + LOREM_IPSUM.split(",") .map { lorem -> - lorem - .trim() - .replaceFirstChar { - if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() - } - }.let { it + it + it + it + it + it } + lorem.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + .let { it + it + it + it + it + it } diff --git a/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt index b52fe050d..df2b3fdb7 100644 --- a/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ScrollableContainer.kt @@ -1,3 +1,5 @@ +@file:Suppress("DuplicatedCode") // Lots of identical-looking but not deduplicable code + package org.jetbrains.jewel.ui.component import androidx.compose.foundation.ScrollState @@ -23,11 +25,12 @@ import androidx.compose.ui.layout.layoutId import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.jewel.foundation.modifier.onHover +import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.styling.ScrollbarStyle import org.jetbrains.jewel.ui.component.styling.ScrollbarVisibility.AlwaysVisible @@ -47,12 +50,14 @@ public fun VerticallyScrollableContainer( content: @Composable () -> Unit, ) { var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( verticalScrollbar = { VerticalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) }, horizontalScrollbar = null, - modifier = modifier.onHover { keepVisible = it }, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT).verticalScroll(scrollState)) { content() } @@ -68,7 +73,6 @@ public fun VerticallyScrollableContainer( content: @Composable () -> Unit, ) { var keepVisible by remember { mutableStateOf(false) } - var delayJob by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() ScrollableContainerImpl( @@ -76,21 +80,7 @@ public fun VerticallyScrollableContainer( VerticalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) }, horizontalScrollbar = null, - modifier = - modifier.pointerInput(scrollState) { - awaitEachGesture { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Move) { - delayJob?.cancel() - keepVisible = true - delayJob = - scope.launch { - delay(50.milliseconds) - keepVisible = false - } - } - } - }, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } @@ -105,10 +95,15 @@ public fun VerticallyScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( - verticalScrollbar = { VerticalScrollbar(scrollState, scrollbarModifier, style = style) }, + verticalScrollbar = { + VerticalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) + }, horizontalScrollbar = null, - modifier = modifier, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } @@ -123,10 +118,15 @@ public fun HorizontallyScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( verticalScrollbar = null, - horizontalScrollbar = { HorizontalScrollbar(scrollState, scrollbarModifier, style = style) }, - modifier = modifier, + horizontalScrollbar = { + HorizontalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) + }, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT).horizontalScroll(scrollState)) { content() } @@ -141,10 +141,15 @@ public fun HorizontallyScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( verticalScrollbar = null, - horizontalScrollbar = { HorizontalScrollbar(scrollState, scrollbarModifier, style = style) }, - modifier = modifier, + horizontalScrollbar = { + HorizontalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) + }, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } @@ -159,16 +164,22 @@ public fun HorizontallyScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( verticalScrollbar = null, - horizontalScrollbar = { HorizontalScrollbar(scrollState, scrollbarModifier, style = style) }, - modifier = modifier, + horizontalScrollbar = { + HorizontalScrollbar(scrollState, scrollbarModifier, style = style, keepVisible = keepVisible) + }, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } } } +@ExperimentalJewelApi @Composable public fun ScrollableContainer( modifier: Modifier = Modifier, @@ -179,12 +190,22 @@ public fun ScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( - verticalScrollbar = { VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style) }, + verticalScrollbar = { + VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style, keepVisible = keepVisible) + }, horizontalScrollbar = { - HorizontalScrollbar(horizontalScrollState, horizontalScrollbarModifier, style = style) + HorizontalScrollbar( + horizontalScrollState, + horizontalScrollbarModifier, + style = style, + keepVisible = keepVisible, + ) }, - modifier = modifier, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT).verticalScroll(verticalScrollState).horizontalScroll(horizontalScrollState)) { @@ -193,6 +214,7 @@ public fun ScrollableContainer( } } +@ExperimentalJewelApi @Composable public fun ScrollableContainer( verticalScrollState: LazyListState, @@ -203,18 +225,29 @@ public fun ScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( - verticalScrollbar = { VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style) }, + verticalScrollbar = { + VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style, keepVisible = keepVisible) + }, horizontalScrollbar = { - HorizontalScrollbar(horizontalScrollState, horizontalScrollbarModifier, style = style) + HorizontalScrollbar( + horizontalScrollState, + horizontalScrollbarModifier, + style = style, + keepVisible = keepVisible, + ) }, - modifier = modifier, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } } } +@ExperimentalJewelApi @Composable public fun ScrollableContainer( verticalScrollState: LazyGridState, @@ -225,18 +258,49 @@ public fun ScrollableContainer( style: ScrollbarStyle = JewelTheme.scrollbarStyle, content: @Composable () -> Unit, ) { + var keepVisible by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + ScrollableContainerImpl( - verticalScrollbar = { VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style) }, + verticalScrollbar = { + VerticalScrollbar(verticalScrollState, verticalScrollbarModifier, style = style, keepVisible = keepVisible) + }, horizontalScrollbar = { - HorizontalScrollbar(horizontalScrollState, horizontalScrollbarModifier, style = style) + HorizontalScrollbar( + horizontalScrollState, + horizontalScrollbarModifier, + style = style, + keepVisible = keepVisible, + ) }, - modifier = modifier, + modifier = modifier.withKeepVisible(style.scrollbarVisibility.lingerDuration, scope) { keepVisible = it }, scrollbarStyle = style, ) { Box(Modifier.layoutId(ID_CONTENT)) { content() } } } +private fun Modifier.withKeepVisible( + lingerDuration: Duration, + scope: CoroutineScope, + onKeepVisibleChange: (Boolean) -> Unit, +) = + pointerInput(scope) { + var delayJob: Job? = null + awaitEachGesture { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Move) { + delayJob?.cancel() + onKeepVisibleChange(true) + delayJob = + scope.launch { + delay(lingerDuration) + onKeepVisibleChange(false) + } + } + } + } + @Composable private fun ScrollableContainerImpl( verticalScrollbar: (@Composable () -> Unit)?, @@ -282,6 +346,7 @@ private fun ScrollableContainerImpl( horizontalScrollbarMeasurable.measure(horizontalScrollbarConstraints) } else null + val contentMeasurable = measurables.find { it.layoutId == ID_CONTENT } ?: error("Content not provided") val contentConstraints = computeContentConstraints( scrollbarStyle, @@ -289,22 +354,21 @@ private fun ScrollableContainerImpl( verticalScrollbarPlaceable, horizontalScrollbarPlaceable, ) - val contentMeasurable = measurables.find { it.layoutId == ID_CONTENT } ?: error("Content not provided") val contentPlaceable = contentMeasurable.measure(contentConstraints) - layout( - width = contentPlaceable.width + (verticalScrollbarPlaceable?.width ?: 0), - height = contentPlaceable.height + (horizontalScrollbarPlaceable?.height ?: 0), - ) { + val isAlwaysVisible = scrollbarStyle.scrollbarVisibility is AlwaysVisible + val vScrollbarWidth = if (isAlwaysVisible) verticalScrollbarPlaceable?.width ?: 0 else 0 + val width = contentPlaceable.width + vScrollbarWidth + + val hScrollbarHeight = if (isAlwaysVisible) horizontalScrollbarPlaceable?.height ?: 0 else 0 + val height = contentPlaceable.height + hScrollbarHeight + + layout(width, height) { contentPlaceable.placeRelative(x = 0, y = 0, zIndex = 0f) - verticalScrollbarPlaceable?.placeRelative( - x = incomingConstraints.maxWidth - verticalScrollbarPlaceable.width, - y = 0, - zIndex = 1f, - ) + verticalScrollbarPlaceable?.placeRelative(x = width - verticalScrollbarPlaceable.width, y = 0, zIndex = 1f) horizontalScrollbarPlaceable?.placeRelative( x = 0, - y = incomingConstraints.maxHeight - horizontalScrollbarPlaceable.height, + y = height - horizontalScrollbarPlaceable.height, zIndex = 1f, ) } @@ -319,9 +383,10 @@ private fun computeContentConstraints( ): Constraints { fun width() = if (incomingConstraints.hasBoundedWidth) { + val maxWidth = incomingConstraints.maxWidth when (scrollbarStyle.scrollbarVisibility) { - is AlwaysVisible -> incomingConstraints.maxWidth - (verticalScrollbarPlaceable?.width ?: 0) - is WhenScrolling -> incomingConstraints.maxWidth + is AlwaysVisible -> maxWidth - (verticalScrollbarPlaceable?.width ?: 0) + is WhenScrolling -> maxWidth } } else { error("Incoming constraints have infinite width, should not use fixed width") @@ -329,9 +394,10 @@ private fun computeContentConstraints( fun height() = if (incomingConstraints.hasBoundedHeight) { + val maxHeight = incomingConstraints.maxHeight when (scrollbarStyle.scrollbarVisibility) { - is AlwaysVisible -> incomingConstraints.maxHeight - (horizontalScrollbarPlaceable?.height ?: 0) - is WhenScrolling -> incomingConstraints.maxHeight + is AlwaysVisible -> maxHeight - (horizontalScrollbarPlaceable?.height ?: 0) + is WhenScrolling -> maxHeight } } else { error("Incoming constraints have infinite height, should not use fixed height")