VerticalFastScroller.kt 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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.Arrangement
  12. import androidx.compose.foundation.layout.Box
  13. import androidx.compose.foundation.layout.PaddingValues
  14. import androidx.compose.foundation.layout.calculateEndPadding
  15. import androidx.compose.foundation.layout.calculateStartPadding
  16. import androidx.compose.foundation.layout.height
  17. import androidx.compose.foundation.layout.offset
  18. import androidx.compose.foundation.layout.padding
  19. import androidx.compose.foundation.layout.width
  20. import androidx.compose.foundation.lazy.LazyListItemInfo
  21. import androidx.compose.foundation.lazy.LazyListState
  22. import androidx.compose.foundation.lazy.grid.GridCells
  23. import androidx.compose.foundation.lazy.grid.LazyGridState
  24. import androidx.compose.foundation.shape.RoundedCornerShape
  25. import androidx.compose.foundation.systemGestureExclusion
  26. import androidx.compose.material3.MaterialTheme
  27. import androidx.compose.runtime.Composable
  28. import androidx.compose.runtime.LaunchedEffect
  29. import androidx.compose.runtime.getValue
  30. import androidx.compose.runtime.mutableStateOf
  31. import androidx.compose.runtime.remember
  32. import androidx.compose.runtime.setValue
  33. import androidx.compose.ui.Modifier
  34. import androidx.compose.ui.draw.alpha
  35. import androidx.compose.ui.graphics.Color
  36. import androidx.compose.ui.layout.SubcomposeLayout
  37. import androidx.compose.ui.platform.LocalDensity
  38. import androidx.compose.ui.unit.Constraints
  39. import androidx.compose.ui.unit.Density
  40. import androidx.compose.ui.unit.Dp
  41. import androidx.compose.ui.unit.IntOffset
  42. import androidx.compose.ui.unit.LayoutDirection
  43. import androidx.compose.ui.unit.dp
  44. import androidx.compose.ui.util.fastForEach
  45. import androidx.compose.ui.util.fastMaxBy
  46. import eu.kanade.presentation.util.plus
  47. import kotlinx.coroutines.channels.BufferOverflow
  48. import kotlinx.coroutines.flow.MutableSharedFlow
  49. import kotlinx.coroutines.flow.collectLatest
  50. import kotlin.math.abs
  51. import kotlin.math.max
  52. import kotlin.math.min
  53. import kotlin.math.roundToInt
  54. @Composable
  55. fun VerticalFastScroller(
  56. listState: LazyListState,
  57. modifier: Modifier = Modifier,
  58. thumbAllowed: () -> Boolean = { true },
  59. thumbColor: Color = MaterialTheme.colorScheme.primary,
  60. topContentPadding: Dp = Dp.Hairline,
  61. bottomContentPadding: Dp = Dp.Hairline,
  62. endContentPadding: Dp = Dp.Hairline,
  63. content: @Composable () -> Unit,
  64. ) {
  65. SubcomposeLayout(modifier = modifier) { constraints ->
  66. val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
  67. val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
  68. val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
  69. val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
  70. val scrollerPlaceable = subcompose("scroller") {
  71. val layoutInfo = listState.layoutInfo
  72. val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
  73. if (!showScroller) return@subcompose
  74. val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
  75. var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) }
  76. val dragInteractionSource = remember { MutableInteractionSource() }
  77. val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
  78. val scrolled = remember {
  79. MutableSharedFlow<Unit>(
  80. extraBufferCapacity = 1,
  81. onBufferOverflow = BufferOverflow.DROP_OLDEST,
  82. )
  83. }
  84. val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
  85. val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - listState.layoutInfo.afterContentPadding
  86. val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
  87. val trackHeightPx = heightPx - thumbHeightPx
  88. // When thumb dragged
  89. LaunchedEffect(thumbOffsetY) {
  90. if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
  91. val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
  92. val scrollItem = layoutInfo.totalItemsCount * scrollRatio
  93. val scrollItemRounded = scrollItem.roundToInt()
  94. val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
  95. val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
  96. listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
  97. scrolled.tryEmit(Unit)
  98. }
  99. // When list scrolled
  100. LaunchedEffect(listState.firstVisibleItemScrollOffset) {
  101. if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
  102. val scrollOffset = computeScrollOffset(state = listState)
  103. val scrollRange = computeScrollRange(state = listState)
  104. val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
  105. thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
  106. scrolled.tryEmit(Unit)
  107. }
  108. // Thumb alpha
  109. val alpha = remember { Animatable(0f) }
  110. val isThumbVisible = alpha.value > 0f
  111. LaunchedEffect(scrolled, alpha) {
  112. scrolled.collectLatest {
  113. if (thumbAllowed()) {
  114. alpha.snapTo(1f)
  115. alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
  116. } else {
  117. alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
  118. }
  119. }
  120. }
  121. Box(
  122. modifier = Modifier
  123. .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
  124. .then(
  125. // Recompose opts
  126. if (isThumbVisible && !listState.isScrollInProgress) {
  127. Modifier.draggable(
  128. interactionSource = dragInteractionSource,
  129. orientation = Orientation.Vertical,
  130. state = rememberDraggableState { delta ->
  131. val newOffsetY = thumbOffsetY + delta
  132. thumbOffsetY = newOffsetY.coerceIn(
  133. thumbTopPadding,
  134. thumbTopPadding + trackHeightPx,
  135. )
  136. },
  137. )
  138. } else Modifier,
  139. )
  140. .then(
  141. // Exclude thumb from gesture area only when needed
  142. if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
  143. Modifier.systemGestureExclusion()
  144. } else Modifier,
  145. )
  146. .height(ThumbLength)
  147. .padding(horizontal = 8.dp)
  148. .padding(end = endContentPadding)
  149. .width(ThumbThickness)
  150. .alpha(alpha.value)
  151. .background(color = thumbColor, shape = ThumbShape),
  152. )
  153. }.map { it.measure(scrollerConstraints) }
  154. val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
  155. layout(contentWidth, contentHeight) {
  156. contentPlaceable.fastForEach {
  157. it.place(0, 0)
  158. }
  159. scrollerPlaceable.fastForEach {
  160. it.placeRelative(contentWidth - scrollerWidth, 0)
  161. }
  162. }
  163. }
  164. }
  165. @Composable
  166. private fun rememberColumnWidthSums(
  167. columns: GridCells,
  168. horizontalArrangement: Arrangement.Horizontal,
  169. contentPadding: PaddingValues,
  170. ) = remember<Density.(Constraints) -> List<Int>>(
  171. columns,
  172. horizontalArrangement,
  173. contentPadding,
  174. ) {
  175. { constraints ->
  176. require(constraints.maxWidth != Constraints.Infinity) {
  177. "LazyVerticalGrid's width should be bound by parent."
  178. }
  179. val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
  180. contentPadding.calculateEndPadding(LayoutDirection.Ltr)
  181. val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
  182. with(columns) {
  183. calculateCrossAxisCellSizes(
  184. gridWidth,
  185. horizontalArrangement.spacing.roundToPx(),
  186. ).toMutableList().apply {
  187. for (i in 1 until size) {
  188. this[i] += this[i - 1]
  189. }
  190. }
  191. }
  192. }
  193. }
  194. @Composable
  195. fun VerticalGridFastScroller(
  196. state: LazyGridState,
  197. columns: GridCells,
  198. arrangement: Arrangement.Horizontal,
  199. contentPadding: PaddingValues,
  200. modifier: Modifier = Modifier,
  201. thumbAllowed: () -> Boolean = { true },
  202. thumbColor: Color = MaterialTheme.colorScheme.primary,
  203. topContentPadding: Dp = Dp.Hairline,
  204. bottomContentPadding: Dp = Dp.Hairline,
  205. endContentPadding: Dp = Dp.Hairline,
  206. content: @Composable () -> Unit,
  207. ) {
  208. val slotSizesSums = rememberColumnWidthSums(
  209. columns = columns,
  210. horizontalArrangement = arrangement,
  211. contentPadding = contentPadding,
  212. )
  213. SubcomposeLayout(modifier = modifier) { constraints ->
  214. val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
  215. val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
  216. val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
  217. val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
  218. val scrollerPlaceable = subcompose("scroller") {
  219. val layoutInfo = state.layoutInfo
  220. val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
  221. if (!showScroller) return@subcompose
  222. val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
  223. var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) }
  224. val dragInteractionSource = remember { MutableInteractionSource() }
  225. val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
  226. val scrolled = remember {
  227. MutableSharedFlow<Unit>(
  228. extraBufferCapacity = 1,
  229. onBufferOverflow = BufferOverflow.DROP_OLDEST,
  230. )
  231. }
  232. val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
  233. val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - state.layoutInfo.afterContentPadding
  234. val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
  235. val trackHeightPx = heightPx - thumbHeightPx
  236. val columnCount = remember { slotSizesSums(constraints).size }
  237. // When thumb dragged
  238. LaunchedEffect(thumbOffsetY) {
  239. if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
  240. val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
  241. val scrollItem = layoutInfo.totalItemsCount * scrollRatio
  242. // I can't think of anything else rn but this'll do
  243. val scrollItemWhole = scrollItem.toInt()
  244. val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount
  245. val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole
  246. val offsetPerItem = 1f / columnCount
  247. val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1))
  248. // TODO: Sometimes item height is not available when scrolling up
  249. val scrollItemSize = (1..columnCount).maxOf { num ->
  250. val actualIndex = if (num != columnNum) {
  251. scrollItemWhole + num - columnCount
  252. } else {
  253. scrollItemWhole
  254. }
  255. layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0
  256. }
  257. val scrollItemOffset = scrollItemSize * offsetRatio
  258. state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt())
  259. scrolled.tryEmit(Unit)
  260. }
  261. // When list scrolled
  262. LaunchedEffect(state.firstVisibleItemScrollOffset) {
  263. if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
  264. val scrollOffset = computeScrollOffset(state = state)
  265. val scrollRange = computeScrollRange(state = state)
  266. val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
  267. thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
  268. scrolled.tryEmit(Unit)
  269. }
  270. // Thumb alpha
  271. val alpha = remember { Animatable(0f) }
  272. val isThumbVisible = alpha.value > 0f
  273. LaunchedEffect(scrolled, alpha) {
  274. scrolled.collectLatest {
  275. if (thumbAllowed()) {
  276. alpha.snapTo(1f)
  277. alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
  278. } else {
  279. alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
  280. }
  281. }
  282. }
  283. Box(
  284. modifier = Modifier
  285. .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
  286. .then(
  287. // Recompose opts
  288. if (isThumbVisible && !state.isScrollInProgress) {
  289. Modifier.draggable(
  290. interactionSource = dragInteractionSource,
  291. orientation = Orientation.Vertical,
  292. state = rememberDraggableState { delta ->
  293. val newOffsetY = thumbOffsetY + delta
  294. thumbOffsetY = newOffsetY.coerceIn(
  295. thumbTopPadding,
  296. thumbTopPadding + trackHeightPx,
  297. )
  298. },
  299. )
  300. } else Modifier,
  301. )
  302. .then(
  303. // Exclude thumb from gesture area only when needed
  304. if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) {
  305. Modifier.systemGestureExclusion()
  306. } else Modifier,
  307. )
  308. .height(ThumbLength)
  309. .padding(horizontal = 8.dp)
  310. .padding(end = endContentPadding)
  311. .width(ThumbThickness)
  312. .alpha(alpha.value)
  313. .background(color = thumbColor, shape = ThumbShape),
  314. )
  315. }.map { it.measure(scrollerConstraints) }
  316. val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
  317. layout(contentWidth, contentHeight) {
  318. contentPlaceable.fastForEach {
  319. it.place(0, 0)
  320. }
  321. scrollerPlaceable.fastForEach {
  322. it.placeRelative(contentWidth - scrollerWidth, 0)
  323. }
  324. }
  325. }
  326. }
  327. private fun computeScrollOffset(state: LazyGridState): Int {
  328. if (state.layoutInfo.totalItemsCount == 0) return 0
  329. val visibleItems = state.layoutInfo.visibleItemsInfo
  330. val startChild = visibleItems.first()
  331. val endChild = visibleItems.last()
  332. val minPosition = min(startChild.index, endChild.index)
  333. val maxPosition = max(startChild.index, endChild.index)
  334. val itemsBefore = minPosition.coerceAtLeast(0)
  335. val startDecoratedTop = startChild.offset.y
  336. val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop)
  337. val itemRange = abs(minPosition - maxPosition) + 1
  338. val avgSizePerRow = laidOutArea.toFloat() / itemRange
  339. return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
  340. }
  341. private fun computeScrollRange(state: LazyGridState): Int {
  342. if (state.layoutInfo.totalItemsCount == 0) return 0
  343. val visibleItems = state.layoutInfo.visibleItemsInfo
  344. val startChild = visibleItems.first()
  345. val endChild = visibleItems.last()
  346. val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
  347. val laidOutRange = abs(startChild.index - endChild.index) + 1
  348. return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
  349. }
  350. private fun computeScrollOffset(state: LazyListState): Int {
  351. if (state.layoutInfo.totalItemsCount == 0) return 0
  352. val visibleItems = state.layoutInfo.visibleItemsInfo
  353. val startChild = visibleItems.first()
  354. val endChild = visibleItems.last()
  355. val minPosition = min(startChild.index, endChild.index)
  356. val maxPosition = max(startChild.index, endChild.index)
  357. val itemsBefore = minPosition.coerceAtLeast(0)
  358. val startDecoratedTop = startChild.top
  359. val laidOutArea = abs(endChild.bottom - startDecoratedTop)
  360. val itemRange = abs(minPosition - maxPosition) + 1
  361. val avgSizePerRow = laidOutArea.toFloat() / itemRange
  362. return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
  363. }
  364. private fun computeScrollRange(state: LazyListState): Int {
  365. if (state.layoutInfo.totalItemsCount == 0) return 0
  366. val visibleItems = state.layoutInfo.visibleItemsInfo
  367. val startChild = visibleItems.first()
  368. val endChild = visibleItems.last()
  369. val laidOutArea = endChild.bottom - startChild.top
  370. val laidOutRange = abs(startChild.index - endChild.index) + 1
  371. return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
  372. }
  373. private val ThumbLength = 48.dp
  374. private val ThumbThickness = 8.dp
  375. private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
  376. private val FadeOutAnimationSpec = tween<Float>(
  377. durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
  378. delayMillis = 2000,
  379. )
  380. private val ImmediateFadeOutAnimationSpec = tween<Float>(
  381. durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
  382. )
  383. private val LazyListItemInfo.top: Int
  384. get() = offset
  385. private val LazyListItemInfo.bottom: Int
  386. get() = offset + size