Skip to content

Commit

Permalink
Support scrollbar
Browse files Browse the repository at this point in the history
  • Loading branch information
devkanro committed Nov 24, 2023
1 parent c469563 commit dfd32e1
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ internal fun measureLazyTable(
viewportCellSize = IntSize.Zero,
columns = columns,
rows = rows,
horizontalSpacing = horizontalSpacing,
verticalSpacing = verticalSpacing,
)
} else {
var scrollDeltaX = scrollToBeConsumed.x.roundToInt()
Expand Down Expand Up @@ -409,6 +411,8 @@ internal fun measureLazyTable(
viewportCellSize = IntSize(visibleColumnCount, visibleRowCount),
columns = columns,
rows = rows,
horizontalSpacing = horizontalSpacing,
verticalSpacing = verticalSpacing,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -206,7 +207,7 @@ public class LazyTableState(
private set

public val verticalScrollableState: ScrollableState = ScrollableState {
onVerticalScroll(it)
-onVerticalScroll(-it)
}

private var wasScrollingHorizontalVertical = false
Expand Down Expand Up @@ -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<LazyTableState, *> = listSaver(
Expand Down
Loading

0 comments on commit dfd32e1

Please sign in to comment.