VerticalFastScroller.kt 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. package eu.kanade.presentation.components
  2. import android.view.ViewConfiguration
  3. import androidx.compose.animation.core.Animatable
  4. import androidx.compose.animation.core.tween
  5. import androidx.compose.foundation.background
  6. import androidx.compose.foundation.gestures.Orientation
  7. import androidx.compose.foundation.gestures.draggable
  8. import androidx.compose.foundation.gestures.rememberDraggableState
  9. import androidx.compose.foundation.interaction.MutableInteractionSource
  10. import androidx.compose.foundation.interaction.collectIsDraggedAsState
  11. import androidx.compose.foundation.layout.Box
  12. import androidx.compose.foundation.layout.height
  13. import androidx.compose.foundation.layout.offset
  14. import androidx.compose.foundation.layout.padding
  15. import androidx.compose.foundation.layout.width
  16. import androidx.compose.foundation.lazy.LazyListItemInfo
  17. import androidx.compose.foundation.lazy.LazyListState
  18. import androidx.compose.foundation.shape.RoundedCornerShape
  19. import androidx.compose.foundation.systemGestureExclusion
  20. import androidx.compose.material3.MaterialTheme
  21. import androidx.compose.runtime.Composable
  22. import androidx.compose.runtime.LaunchedEffect
  23. import androidx.compose.runtime.getValue
  24. import androidx.compose.runtime.mutableStateOf
  25. import androidx.compose.runtime.remember
  26. import androidx.compose.runtime.setValue
  27. import androidx.compose.ui.Modifier
  28. import androidx.compose.ui.draw.alpha
  29. import androidx.compose.ui.graphics.Color
  30. import androidx.compose.ui.layout.SubcomposeLayout
  31. import androidx.compose.ui.platform.LocalDensity
  32. import androidx.compose.ui.unit.Dp
  33. import androidx.compose.ui.unit.IntOffset
  34. import androidx.compose.ui.unit.dp
  35. import androidx.compose.ui.util.fastForEach
  36. import androidx.compose.ui.util.fastMaxBy
  37. import kotlinx.coroutines.channels.BufferOverflow
  38. import kotlinx.coroutines.flow.MutableSharedFlow
  39. import kotlinx.coroutines.flow.collectLatest
  40. import kotlin.math.abs
  41. import kotlin.math.max
  42. import kotlin.math.min
  43. import kotlin.math.roundToInt
  44. @Composable
  45. fun VerticalFastScroller(
  46. listState: LazyListState,
  47. modifier: Modifier = Modifier,
  48. thumbAllowed: () -> Boolean = { true },
  49. thumbColor: Color = MaterialTheme.colorScheme.primary,
  50. topContentPadding: Dp = Dp.Hairline,
  51. endContentPadding: Dp = Dp.Hairline,
  52. content: @Composable () -> Unit,
  53. ) {
  54. SubcomposeLayout(modifier = modifier) { constraints ->
  55. val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
  56. val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
  57. val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
  58. val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
  59. val scrollerPlaceable = subcompose("scroller") {
  60. val layoutInfo = listState.layoutInfo
  61. val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
  62. if (!showScroller) return@subcompose
  63. val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
  64. var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) }
  65. val dragInteractionSource = remember { MutableInteractionSource() }
  66. val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
  67. val scrolled = remember {
  68. MutableSharedFlow<Unit>(
  69. extraBufferCapacity = 1,
  70. onBufferOverflow = BufferOverflow.DROP_OLDEST,
  71. )
  72. }
  73. val heightPx = contentHeight.toFloat() - thumbTopPadding - listState.layoutInfo.afterContentPadding
  74. val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
  75. val trackHeightPx = heightPx - thumbHeightPx
  76. // When thumb dragged
  77. LaunchedEffect(thumbOffsetY) {
  78. if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
  79. val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
  80. val scrollItem = layoutInfo.totalItemsCount * scrollRatio
  81. val scrollItemRounded = scrollItem.roundToInt()
  82. val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
  83. val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
  84. listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
  85. scrolled.tryEmit(Unit)
  86. }
  87. // When list scrolled
  88. LaunchedEffect(listState.firstVisibleItemScrollOffset) {
  89. if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
  90. val scrollOffset = computeScrollOffset(state = listState)
  91. val scrollRange = computeScrollRange(state = listState)
  92. val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
  93. thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
  94. scrolled.tryEmit(Unit)
  95. }
  96. // Thumb alpha
  97. val alpha = remember { Animatable(0f) }
  98. val isThumbVisible = alpha.value > 0f
  99. LaunchedEffect(scrolled, alpha) {
  100. scrolled.collectLatest {
  101. if (thumbAllowed()) {
  102. alpha.snapTo(1f)
  103. alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
  104. } else {
  105. alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
  106. }
  107. }
  108. }
  109. Box(
  110. modifier = Modifier
  111. .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
  112. .then(
  113. // Recompose opts
  114. if (isThumbVisible && !listState.isScrollInProgress) {
  115. Modifier.draggable(
  116. interactionSource = dragInteractionSource,
  117. orientation = Orientation.Vertical,
  118. state = rememberDraggableState { delta ->
  119. val newOffsetY = thumbOffsetY + delta
  120. thumbOffsetY = newOffsetY.coerceIn(thumbTopPadding, thumbTopPadding + trackHeightPx)
  121. },
  122. )
  123. } else Modifier,
  124. )
  125. .then(
  126. // Exclude thumb from gesture area only when needed
  127. if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
  128. Modifier.systemGestureExclusion()
  129. } else Modifier,
  130. )
  131. .height(ThumbLength)
  132. .padding(horizontal = 8.dp)
  133. .padding(end = endContentPadding)
  134. .width(ThumbThickness)
  135. .alpha(alpha.value)
  136. .background(color = thumbColor, shape = ThumbShape),
  137. )
  138. }.map { it.measure(scrollerConstraints) }
  139. val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
  140. layout(contentWidth, contentHeight) {
  141. contentPlaceable.fastForEach {
  142. it.place(0, 0)
  143. }
  144. scrollerPlaceable.fastForEach {
  145. it.placeRelative(contentWidth - scrollerWidth, 0)
  146. }
  147. }
  148. }
  149. }
  150. private fun computeScrollOffset(state: LazyListState): Int {
  151. if (state.layoutInfo.totalItemsCount == 0) return 0
  152. val visibleItems = state.layoutInfo.visibleItemsInfo
  153. val startChild = visibleItems.first()
  154. val endChild = visibleItems.last()
  155. val minPosition = min(startChild.index, endChild.index)
  156. val maxPosition = max(startChild.index, endChild.index)
  157. val itemsBefore = minPosition.coerceAtLeast(0)
  158. val startDecoratedTop = startChild.top
  159. val laidOutArea = abs(endChild.bottom - startDecoratedTop)
  160. val itemRange = abs(minPosition - maxPosition) + 1
  161. val avgSizePerRow = laidOutArea.toFloat() / itemRange
  162. return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
  163. }
  164. private fun computeScrollRange(state: LazyListState): Int {
  165. if (state.layoutInfo.totalItemsCount == 0) return 0
  166. val visibleItems = state.layoutInfo.visibleItemsInfo
  167. val startChild = visibleItems.first()
  168. val endChild = visibleItems.last()
  169. val laidOutArea = endChild.bottom - startChild.top
  170. val laidOutRange = abs(startChild.index - endChild.index) + 1
  171. return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
  172. }
  173. private val ThumbLength = 48.dp
  174. private val ThumbThickness = 8.dp
  175. private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
  176. private val FadeOutAnimationSpec = tween<Float>(
  177. durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
  178. delayMillis = 2000,
  179. )
  180. private val ImmediateFadeOutAnimationSpec = tween<Float>(
  181. durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
  182. )
  183. private val LazyListItemInfo.top: Int
  184. get() = offset
  185. private val LazyListItemInfo.bottom: Int
  186. get() = offset + size