diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt index 0a1f7c4ee..3fefa64b5 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -67,6 +68,11 @@ public fun LazyTable( .scrollable( orientation = Orientation.Vertical, interactionSource = state.internalInteractionSource, + reverseDirection = ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + Orientation.Vertical, + false, + ), flingBehavior = flingBehavior, state = state.verticalScrollableState, overscrollEffect = overscrollEffect, @@ -75,6 +81,11 @@ public fun LazyTable( .scrollable( orientation = Orientation.Horizontal, interactionSource = state.internalInteractionSource, + reverseDirection = ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + Orientation.Horizontal, + false, + ), flingBehavior = flingBehavior, state = state.horizontalScrollableState, overscrollEffect = overscrollEffect, diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt index 68416c5a3..d7664d120 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt @@ -21,7 +21,9 @@ public interface LazyTableLayoutInfo { public val totalItemsCount: Int get() = columns * rows - public val cellBorderWidth: Int get() = 0 + public val horizontalSpacing: Int get() = 0 + + public val verticalSpacing: Int get() = 0 } internal object EmptyLazyTableLayoutInfo : LazyTableLayoutInfo { @@ -30,5 +32,4 @@ internal object EmptyLazyTableLayoutInfo : LazyTableLayoutInfo { override val viewportEndOffset: IntOffset = IntOffset.Zero override val columns: Int = 0 override val rows: Int = 0 - override val totalItemsCount: Int = 0 } diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt index 52dfae99a..934bacd5b 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt @@ -48,6 +48,8 @@ internal fun measureLazyTable( viewportCellSize = IntSize.Zero, columns = columns, rows = rows, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, ) } else { var scrollDeltaX = scrollToBeConsumed.x.roundToInt() @@ -409,6 +411,8 @@ internal fun measureLazyTable( viewportCellSize = IntSize(visibleColumnCount, visibleRowCount), columns = columns, rows = rows, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, ) } } diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt index a961e7e9a..7821384e2 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt @@ -20,6 +20,8 @@ internal class LazyTableMeasureResult( override val viewportCellSize: IntSize, override val columns: Int, override val rows: Int, + override val horizontalSpacing: Int, + override val verticalSpacing: Int ) : LazyTableLayoutInfo, MeasureResult by measureResult { override val viewportSize: IntSize diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt index 96941b661..f7a67ac15 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt @@ -51,11 +51,11 @@ internal class LazyTableScrollPosition( val firstRowIndex = measureResult.firstVisibleCell?.row ?: 0 val firstColumnIndex = measureResult.firstVisibleCell?.column ?: 0 - update(firstRowIndex, firstColumnIndex, measureResult.viewportCellSize, scrollOffset) + update(firstColumnIndex, firstRowIndex, measureResult.viewportCellSize, scrollOffset) } } - private fun update(row: Int, column: Int, visibleCellSize: IntSize, scrollOffset: IntOffset) { + private fun update(column: Int, row: Int, visibleCellSize: IntSize, scrollOffset: IntOffset) { require(row >= 0f && column >= 0f) { "Coordinate should be non-negative ($row, $column)" } this.row = row this.column = column @@ -64,4 +64,12 @@ internal class LazyTableScrollPosition( this.horizontalScrollOffset = scrollOffset.x this.verticalScrollOffset = scrollOffset.y } + + fun requestColumn(column: Int, scrollOffset: Int) { + update(column, row, IntSize(50, 50), IntOffset(scrollOffset, verticalScrollOffset)) + } + + fun requestRow(row: Int, scrollOffset: Int) { + update(column, row, IntSize(50, 50), IntOffset(horizontalScrollOffset, scrollOffset)) + } } diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt new file mode 100644 index 000000000..d0cee27b6 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt @@ -0,0 +1,223 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.v2.ScrollbarAdapter +import androidx.compose.foundation.v2.maxScrollOffset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import kotlin.math.abs + +internal abstract class LazyTableScrollbarAdapter : ScrollbarAdapter { + + // Implement the adapter in terms of "lines", which means either rows, + // (for a vertically scrollable widget) or columns (for a horizontally + // scrollable one). + // For LazyList this translates directly to items; for LazyGrid, it + // translates to rows/columns of items. + + class VisibleLine( + val index: Int, + val offset: Int, + ) + + /** + * Return the first visible line, if any. + */ + protected abstract fun firstVisibleLine(): VisibleLine? + + /** + * Return the total number of lines. + */ + protected abstract fun totalLineCount(): Int + + /** + * The sum of content padding (before+after) on the scrollable axis. + */ + protected abstract fun contentPadding(): Int + + /** + * Scroll immediately to the given line, and offset it by [scrollOffset] pixels. + */ + protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) + + /** + * Scroll from the current position by the given amount of pixels. + */ + protected abstract suspend fun scrollBy(value: Float) + + /** + * Return the average size (on the scrollable axis) of the visible lines. + */ + protected abstract fun averageVisibleLineSize(): Double + + /** + * The spacing between lines. + */ + protected abstract val lineSpacing: Int + + private val averageVisibleLineSize by derivedStateOf { + if (totalLineCount() == 0) { + 0.0 + } else { + averageVisibleLineSize() + } + } + + private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing + + override val scrollOffset: Double + get() { + val firstVisibleLine = firstVisibleLine() + return if (firstVisibleLine == null) { + 0.0 + } else { + firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset + } + } + + override val contentSize: Double + get() { + val totalLineCount = totalLineCount() + return averageVisibleLineSize * totalLineCount + + lineSpacing * (totalLineCount - 1).coerceAtLeast(0) + + contentPadding() + } + + override suspend fun scrollTo(scrollOffset: Double) { + val distance = scrollOffset - this@LazyTableScrollbarAdapter.scrollOffset + + // if we scroll less than viewport we need to use scrollBy function to avoid + // undesirable scroll jumps (when an item size is different) + // + // if we scroll more than viewport we should immediately jump to this position + // without recreating all items between the current and the new position + if (abs(distance) <= viewportSize) { + scrollBy(distance.toFloat()) + } else { + snapTo(scrollOffset) + } + } + + private suspend fun snapTo(scrollOffset: Double) { + val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset) + + val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing) + .toInt() + .coerceAtLeast(0) + .coerceAtMost(totalLineCount() - 1) + + val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing) + .toInt() + .coerceAtLeast(0) + + snapToLine(lineIndex = index, scrollOffset = offset) + } +} + +internal class LazyTableHorizontalScrollbarAdapter( + private val scrollState: LazyTableState, +) : LazyTableScrollbarAdapter() { + + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { + viewportSize.width + }.toDouble() + + override fun firstVisibleLine(): VisibleLine? { + val item = scrollState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null + return VisibleLine( + index = item.column, + offset = item.offset.x + ) + } + + override fun totalLineCount() = scrollState.layoutInfo.columns + + override fun contentPadding() = with(scrollState.layoutInfo) { + 0 + } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToColumn(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.horizontalScrollableState.scrollBy(value) + } + + override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) { + val first = this.firstOrNull() ?: return@with 0.0 + val last = last() + val count = last.column - first.column + 1 + + (last.offset.x + last.size.width - first.offset.x - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing get() = scrollState.layoutInfo.horizontalSpacing +} + +internal class LazyTableVerticalScrollbarAdapter( + private val scrollState: LazyTableState, +) : LazyTableScrollbarAdapter() { + + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { + viewportSize.height + }.toDouble() + + override fun firstVisibleLine(): VisibleLine? { + val item = scrollState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null + return VisibleLine( + index = item.row, + offset = item.offset.y + ) + } + + override fun totalLineCount() = scrollState.layoutInfo.rows + + override fun contentPadding() = with(scrollState.layoutInfo) { + 0 + } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToRow(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.verticalScrollableState.scrollBy(value) + } + + override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) { + val first = this.firstOrNull() ?: return@with 0.0 + val last = last() + val count = last.row - first.row + 1 + + (last.offset.y + last.size.height - first.offset.y - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing get() = scrollState.layoutInfo.verticalSpacing +} + +public fun tableHorizontalScrollbarAdapter( + scrollState: LazyTableState, +): ScrollbarAdapter = LazyTableHorizontalScrollbarAdapter(scrollState) + +@Composable +public fun rememberTableHorizontalScrollbarAdapter( + scrollState: LazyTableState, +): ScrollbarAdapter = remember(scrollState) { + tableHorizontalScrollbarAdapter(scrollState) +} + +public fun tableVerticalScrollbarAdapter( + scrollState: LazyTableState, +): ScrollbarAdapter = LazyTableVerticalScrollbarAdapter(scrollState) + +@Composable +public fun rememberTableVerticalScrollbarAdapter( + scrollState: LazyTableState, +): ScrollbarAdapter = remember(scrollState) { + tableVerticalScrollbarAdapter(scrollState) +} diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt index b453433c4..c12e33cc4 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.Remeasurement import androidx.compose.ui.layout.RemeasurementModifier -import java.util.SortedMap import kotlin.math.abs @Composable @@ -149,7 +148,9 @@ public class LazyTableState( internal var scrollToBeConsumedHorizontal = 0f private set - public val horizontalScrollableState: ScrollableState = ScrollableState { onHorizontalScroll(it) } + public val horizontalScrollableState: ScrollableState = ScrollableState { + -onHorizontalScroll(-it) + } private var wasScrollingHorizontalForward = false @@ -206,7 +207,7 @@ public class LazyTableState( private set public val verticalScrollableState: ScrollableState = ScrollableState { - onVerticalScroll(it) + -onVerticalScroll(-it) } private var wasScrollingHorizontalVertical = false @@ -259,6 +260,39 @@ public class LazyTableState( public var canVerticalScrollBackward: Boolean by mutableStateOf(false) private set + public val layoutInfo: LazyTableLayoutInfo get() = layoutInfoState.value + + public suspend fun scrollToColumn( + column: Int, + scrollOffset: Int = 0, + ) { + horizontalScroll { + snapToColumnInternal(column, scrollOffset) + } + } + + internal fun snapToColumnInternal(column: Int, scrollOffset: Int) { + scrollPosition.requestColumn(column, scrollOffset) + // placement animation is not needed because we snap into a new position. + // placementAnimator.reset() + remeasurement?.forceRemeasure() + } + + public suspend fun scrollToRow( + row: Int, + scrollOffset: Int = 0, + ) { + verticalScroll { + snapToRowInternal(row, scrollOffset) + } + } + + internal fun snapToRowInternal(row: Int, scrollOffset: Int) { + scrollPosition.requestRow(row, scrollOffset) + // placement animation is not needed because we snap into a new position. + // placementAnimator.reset() + remeasurement?.forceRemeasure() + } public companion object { public val Saver: Saver = listSaver( diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Table.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Table.kt index d09c575ef..d6affffa4 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Table.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Table.kt @@ -4,25 +4,34 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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 kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.Stroke import org.jetbrains.jewel.foundation.lazy.table.LazyTable import org.jetbrains.jewel.foundation.lazy.table.LazyTableCellSize import org.jetbrains.jewel.foundation.lazy.table.fixedHeight +import org.jetbrains.jewel.foundation.lazy.table.rememberLazyTableState +import org.jetbrains.jewel.foundation.lazy.table.rememberTableHorizontalScrollbarAdapter +import org.jetbrains.jewel.foundation.lazy.table.rememberTableVerticalScrollbarAdapter import org.jetbrains.jewel.foundation.modifier.border import org.jetbrains.jewel.samples.standalone.viewmodel.View +import org.jetbrains.jewel.ui.component.HorizontalScrollbar import org.jetbrains.jewel.ui.component.OutlinedButton import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.component.VerticalScrollbar import org.jetbrains.jewel.ui.util.isDark @Composable @@ -30,6 +39,7 @@ import org.jetbrains.jewel.ui.util.isDark fun Tables() { var columns by remember { mutableStateOf(1000) } var rows by remember { mutableStateOf(1000) } + val state = rememberLazyTableState() Row( modifier = Modifier.fillMaxWidth(), @@ -79,30 +89,71 @@ fun Tables() { } } - LazyTable( - modifier = Modifier, - cellSize = LazyTableCellSize.fixedHeight(24.dp, minWidth = 48.dp), - verticalArrangement = Arrangement.spacedBy(1.dp), - horizontalArrangement = Arrangement.spacedBy(1.dp), - pinnedColumns = 2, - pinnedRows = 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - content(columns) { - rows( - rows, - key = { it }, - contentType = { null }, - ) { - val background = Color(red = it.x, green = it.y, blue = it.x / 255) - val foreground = if (background.isDark()) { - Color.White - } else { - Color.Black - } - Box(Modifier.fillMaxSize().background(background).border(Stroke.Alignment.Outside, 1.dp, Color.White)) { - Text("${it.x}, ${it.y}", color = foreground) + var column by remember { mutableStateOf("") } + var row by remember { mutableStateOf("") } + + TextField(column, { + column = it + }) + + TextField(row, { + row = it + }) + + val coroutine = rememberCoroutineScope() + + OutlinedButton(onClick = { + coroutine.launch { + state.scrollToColumn(column.toIntOrNull() ?: 0) + state.scrollToRow(row.toIntOrNull() ?: 0) + } + }) { + Text("Goto") + } + } + + Box(Modifier.fillMaxSize()) { + LazyTable( + modifier = Modifier, + state = state, + cellSize = LazyTableCellSize.fixedHeight(24.dp, minWidth = 48.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + horizontalArrangement = Arrangement.spacedBy(1.dp), + pinnedColumns = 2, + pinnedRows = 2 + ) { + content(columns) { + rows( + rows, + key = { it }, + contentType = { null }, + ) { + val background = Color(red = it.x, green = it.y, blue = it.x / 255) + val foreground = if (background.isDark()) { + Color.White + } else { + Color.Black + } + Box(Modifier.fillMaxSize().background(background).border(Stroke.Alignment.Outside, 1.dp, Color.White)) { + Text("${it.x}, ${it.y}", color = foreground) + } } } } + + HorizontalScrollbar( + rememberTableHorizontalScrollbarAdapter(state), + Modifier.fillMaxWidth().align(Alignment.BottomStart) + ) + + VerticalScrollbar( + rememberTableVerticalScrollbarAdapter(state), + Modifier.fillMaxHeight().align(Alignment.TopEnd) + ) } }