diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index 691a6e517d..3451e54219 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -218,11 +218,16 @@ package com.google.android.horologist.compose.layout { package com.google.android.horologist.compose.layout.m3 { public final class FastScrollingTransformingLazyColumnKt { - method @androidx.compose.runtime.Composable public static void FastScrollingTransformingLazyColumn(androidx.wear.compose.foundation.lazy.TransformingLazyColumnState state, androidx.compose.runtime.snapshots.SnapshotStateList headers, optional androidx.compose.ui.Modifier modifier, optional float sectionIndictatorTopPadding, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void FastScrollingTransformingLazyColumn(androidx.wear.compose.foundation.lazy.TransformingLazyColumnState state, androidx.compose.runtime.snapshots.SnapshotStateList headers, optional androidx.compose.ui.Modifier modifier, optional float sectionIndicatorTopPadding, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function1 content); } - public final class HeaderInfo { + @androidx.compose.runtime.Immutable public final class HeaderInfo { ctor public HeaderInfo(int index, String value, optional java.util.Map inlineContent, optional Integer? extraScrollToOffset); + method public int component1(); + method public String component2(); + method public java.util.Map component3(); + method public Integer? component4(); + method public com.google.android.horologist.compose.layout.m3.HeaderInfo copy(int index, String value, java.util.Map inlineContent, Integer? extraScrollToOffset); method public Integer? getExtraScrollToOffset(); method public int getIndex(); method public java.util.Map getInlineContent(); diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt index 8d0cd1d486..370cb88076 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -76,6 +77,7 @@ import androidx.wear.compose.material3.Text import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.yield import kotlin.math.abs @@ -90,7 +92,7 @@ import kotlin.math.abs * index. This is used by the FastScrollingTransformingLazyColumn to display a header over the * given information and snap to the speicified header. * @property modifier The modifier(s) to apply to the list. - * @property sectionIndictatorTopPadding The top padding to apply to the section indicator. This + * @property sectionIndicatorTopPadding The top padding to apply to the section indicator. This * should only be needed to align with the header scrolled to when the scrollToOffset is NOT 0. * @property content The content within the list. This can be used the exact same way as the * TransformingLazyColumn with content, though do note that any items that you do not want to @@ -103,19 +105,18 @@ public fun FastScrollingTransformingLazyColumn( state: TransformingLazyColumnState, headers: SnapshotStateList, modifier: Modifier = Modifier, - sectionIndictatorTopPadding: Dp = 0.dp, + sectionIndicatorTopPadding: Dp = 0.dp, contentPadding: PaddingValues = PaddingValues(), content: TransformingLazyColumnScope.() -> Unit, ) { val haptics = LocalHapticFeedback.current + val density = LocalDensity.current val screenHeight = LocalWindowInfo.current.containerSize.height val defaultFlingBehavior = ScrollableDefaults.flingBehavior() val context = LocalContext.current // The minimum fling velocity to trigger a skim event. - val flingVelocityThreshold = remember { - 7 * ViewConfiguration.get(context).scaledMinimumFlingVelocity - } + val flingVelocityThreshold = 7 * ViewConfiguration.get(context).scaledMinimumFlingVelocity val coroutineScope = rememberCoroutineScope() var fadingOutJob: Job? by remember { mutableStateOf(null) } var animationJob: Job? by remember { mutableStateOf(null) } @@ -124,13 +125,15 @@ public fun FastScrollingTransformingLazyColumn( // section indicator top padding, with whatever extra top padding is passed in from the // composable. val scrollToOffset = - with(LocalDensity.current) { - ( - Constants.REMAINING_LETTER_HEIGHT + - Constants.SECTION_INDICATOR_TOP_PADDING + - sectionIndictatorTopPadding - ) - .roundToPx() + remember(density, sectionIndicatorTopPadding) { + with(density) { + ( + Constants.REMAINING_LETTER_HEIGHT + + Constants.SECTION_INDICATOR_TOP_PADDING + + sectionIndicatorTopPadding + ) + .roundToPx() + } } var currentSectionIndex by remember { mutableIntStateOf(0) } @@ -140,34 +143,10 @@ public fun FastScrollingTransformingLazyColumn( var isSkimming by remember { mutableStateOf(false) } var isFirstFastScroll by remember { mutableStateOf(false) } - val currentSectionHeader: HeaderInfo? = - remember(headers, currentSectionIndex) { headers.getOrNull(currentSectionIndex) } var indicatorState by remember { mutableStateOf(IndicatorState.START) } var pixelsScrolledBy by remember { mutableFloatStateOf(0f) } - val transition = updateTransition(indicatorState) - - val indicatorWidthScale by - transition.animateFloat( - transitionSpec = { - when { - IndicatorState.START isTransitioningTo IndicatorState.SPRING -> - standard().defaultEffectsSpec() - IndicatorState.SPRING isTransitioningTo IndicatorState.END -> - expressive().fastSpatialSpec() - else -> standard().defaultEffectsSpec() - } - }, - label = "width", - ) { - when (it) { - IndicatorState.START -> 1f - IndicatorState.SPRING -> 1.25f - IndicatorState.END -> 1f - } - } - fun setCurrentSectionIndex(firstItemIndex: Int) { if (currentSectionIndex != firstItemIndex) { currentSectionIndex = firstItemIndex @@ -177,7 +156,7 @@ public fun FastScrollingTransformingLazyColumn( fun scrollListToSection() { val headerOffset = scrollToOffset + (headers[currentSectionIndex].extraScrollToOffset ?: 0) - val offset = headerOffset + (screenHeight * -.5).toInt() + val offset = (screenHeight * 0.5).toInt() - headerOffset coroutineScope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) @@ -195,28 +174,26 @@ public fun FastScrollingTransformingLazyColumn( scrollListToSection() // Start the animation, and cancel the previous animations if any were running animationJob?.cancel() - animationJob = - coroutineScope.launch { - if (indicatorState != IndicatorState.START) { - indicatorState = IndicatorState.START - } - delay(50) - indicatorState = IndicatorState.SPRING - delay(50) - indicatorState = IndicatorState.END + animationJob = coroutineScope.launch { + if (indicatorState != IndicatorState.START) { + indicatorState = IndicatorState.START } + delay(50) + indicatorState = IndicatorState.SPRING + delay(50) + indicatorState = IndicatorState.END + } // After every skim, we will run a job that will fade out the indicator and reset the flags // once the timeout is reached. This will continuously allow the skim to keep running if // skimming keeps being performed. fadingOutJob?.cancel() - fadingOutJob = - coroutineScope.launch { - delay(Constants.RSB_SKIMMING_TIMEOUT) - // Skim has finally ended, as another skim did not happen to reset the skim flag. - isSkimming = false - } + fadingOutJob = coroutineScope.launch { + delay(Constants.RSB_SKIMMING_TIMEOUT) + // Skim has finally ended, as another skim did not happen to reset the skim flag. + isSkimming = false + } } fun performScroll(delta: Float) { @@ -273,49 +250,53 @@ public fun FastScrollingTransformingLazyColumn( Box(modifier = Modifier.fillMaxSize()) { val flingBehavior = - object : FlingBehavior { - override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { - isSkimming = false - return with(defaultFlingBehavior) { this@performFling.performFling(initialVelocity) } + remember(defaultFlingBehavior) { + object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + isSkimming = false + return with(defaultFlingBehavior) { this@performFling.performFling(initialVelocity) } + } } } val rotaryScrollableBehavior = - object : RotaryScrollableBehavior { - override suspend fun CoroutineScope.performScroll( - timestampMillis: Long, - delta: Float, - inputDeviceId: Int, - orientation: Orientation, - ) { - val deltaTime = timestampMillis - lastRotaryScroll - val currentVelocity = (delta / deltaTime) * 1000 // Convert to pixels per second - lastRotaryScroll = timestampMillis - - val isScrollingDown = delta > 0f - val isScrollingInRightDirection = - ( - isScrollingDown && currentSectionIndex < headers.size - 1 || - !isScrollingDown && currentSectionIndex > 0 - ) - - val canFastScroll = - headers.isNotEmpty() && + remember(headers, flingVelocityThreshold) { + object : RotaryScrollableBehavior { + override suspend fun CoroutineScope.performScroll( + timestampMillis: Long, + delta: Float, + inputDeviceId: Int, + orientation: Orientation, + ) { + val deltaTime = timestampMillis - lastRotaryScroll + val currentVelocity = (delta / deltaTime) * 1000 // Convert to pixels per second + lastRotaryScroll = timestampMillis + + val isScrollingDown = delta > 0f + val isScrollingInRightDirection = ( - currentSectionIndex >= 0 && - currentSectionIndex < headers.size && - isScrollingInRightDirection + (isScrollingDown && currentSectionIndex < headers.size - 1) || + (!isScrollingDown && currentSectionIndex > 0) ) - if (!isSkimming && abs(currentVelocity) > flingVelocityThreshold && canFastScroll) { - isFirstFastScroll = true - pixelsScrolledBy = 0f - isSkimming = true - } - - if (isSkimming) { - handleSkim(currentTime = timestampMillis, delta = delta) - } else { - performScroll(delta) + val canFastScroll = + headers.isNotEmpty() && + ( + currentSectionIndex >= 0 && + currentSectionIndex < headers.size && + isScrollingInRightDirection + ) + + if (!isSkimming && abs(currentVelocity) > flingVelocityThreshold && canFastScroll) { + isFirstFastScroll = true + pixelsScrolledBy = 0f + isSkimming = true + } + + if (isSkimming) { + handleSkim(currentTime = timestampMillis, delta = delta) + } else { + performScroll(delta) + } } } } @@ -325,20 +306,29 @@ public fun FastScrollingTransformingLazyColumn( flingBehavior = flingBehavior, rotaryScrollableBehavior = rotaryScrollableBehavior, modifier = modifier.fillMaxWidth(), - contentPadding = remember { contentPadding }, + contentPadding = contentPadding, ) { content() } - AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { - SectionIndicator(indicatorWidthScale, currentSectionHeader, sectionIndictatorTopPadding) - } + SkimIndicator( + isSkimmingProvider = { isSkimming }, + indicatorStateProvider = { indicatorState }, + headerProvider = { headers.getOrNull(currentSectionIndex) }, + sectionIndicatorTopPadding = sectionIndicatorTopPadding, + ) LaunchedEffect(key1 = Unit) { - snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } - .collect { visibleItemIndex -> - if (!isSkimming && headers.isNotEmpty()) { - val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } + val visibleItemFlow = snapshotFlow { state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0 } + val headersFlow = snapshotFlow { headers.toList() } + + visibleItemFlow + .combine(headersFlow) { visibleItemIndex, currentHeaders -> + Pair(visibleItemIndex, currentHeaders) + } + .collect { (visibleItemIndex, currentHeaders) -> + if (!isSkimming && currentHeaders.isNotEmpty()) { + val searchResult = currentHeaders.binarySearchBy(visibleItemIndex) { it.index } val sectionIndex = if (searchResult >= 0) { // Exact match found @@ -348,7 +338,7 @@ public fun FastScrollingTransformingLazyColumn( // binarySearchBy returns (-insertion point - 1). // The section index is the item before the insertion point. val insertionPoint = -searchResult - 1 - (insertionPoint - 1).coerceIn(0, headers.size - 1) + (insertionPoint - 1).coerceIn(0, currentHeaders.size - 1) } setCurrentSectionIndex(sectionIndex) } @@ -357,13 +347,51 @@ public fun FastScrollingTransformingLazyColumn( } } +@Composable +private fun SkimIndicator( + isSkimmingProvider: () -> Boolean, + indicatorStateProvider: () -> IndicatorState, + headerProvider: () -> HeaderInfo?, + sectionIndicatorTopPadding: Dp, +) { + val isSkimming = isSkimmingProvider() + val indicatorState = indicatorStateProvider() + + val transition = updateTransition(indicatorState, label = "SkimIndicatorTransition") + + val indicatorWidthScale by + transition.animateFloat( + transitionSpec = { + when { + IndicatorState.START isTransitioningTo IndicatorState.SPRING -> + standard().defaultEffectsSpec() + IndicatorState.SPRING isTransitioningTo IndicatorState.END -> + expressive().fastSpatialSpec() + else -> standard().defaultEffectsSpec() + } + }, + label = "width", + ) { + when (it) { + IndicatorState.START -> 1f + IndicatorState.SPRING -> 1.25f + IndicatorState.END -> 1f + } + } + + AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { + SectionIndicator({ indicatorWidthScale }, headerProvider, sectionIndicatorTopPadding) + } +} + @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SectionIndicator( - indicatorWidthScale: Float, - currentSectionHeader: HeaderInfo?, - sectionIndictatorTopPadding: Dp, + indicatorWidthScale: () -> Float, + headerProvider: () -> HeaderInfo?, + sectionIndicatorTopPadding: Dp, ) { + val currentSectionHeader = headerProvider() val shape = remember { RoundedCornerShape(24.dp) } val annotatedText = remember(currentSectionHeader) { @@ -386,11 +414,11 @@ private fun SectionIndicator( Box( contentAlignment = Alignment.TopCenter, - modifier = Modifier.fillMaxWidth().padding(top = sectionIndictatorTopPadding), + modifier = Modifier.fillMaxWidth().padding(top = sectionIndicatorTopPadding), ) { Box( modifier = - Modifier.graphicsLayer { this.scaleX = indicatorWidthScale } + Modifier.graphicsLayer { this.scaleX = indicatorWidthScale() } .clip(shape) .requiredHeight(Constants.INDICATOR_HEIGHT) .sizeIn(minWidth = Constants.INDICATOR_WIDTH) @@ -422,7 +450,8 @@ private fun SectionIndicator( * more information). * @property extraScrollToOffset The optional extra offset added to the default offset. */ -public class HeaderInfo( +@Immutable +public data class HeaderInfo( val index: Int, val value: String, val inlineContent: Map = mapOf(), diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png index 0600dd53a7..dfc522b5ed 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9903d005366ae4fe8ab4b0ad154994e4fad8eef958e09c7eadca634d66f0794 -size 40616 +oid sha256:1415f44146adec4b28d500c0929a2f2d79c20e6173638e00dcc4ebede78b76a2 +size 43094 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png index 31ba879e15..1e9b8bcf1c 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:284f39bd55bb569e31181958e5bd0cef195b792f727232423a49b4c9ccf37d21 -size 28293 +oid sha256:3c0b1c567a18ab984348567837435cd21356194b3ea082c0ca739006d57a7116 +size 31707 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png index 18e539b931..d38f8bab46 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08befc782b223c3b6278de8a1dd8bc6c937c236a9532e7c8fb47bc004113e915 -size 39028 +oid sha256:9fb9178af6c996642cb3ef179e4f6bc17e4897ded72f925b0dc49ab58e9dba48 +size 41993 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png index 53101fc26c..0326a29728 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33e1f5f834b4218befb188a6ab4ba79d462af13427c1f2796acd046c2a8a3fbf -size 27355 +oid sha256:12102cfb60df0549457d3f2e7b6f6b97f8bf32bad43188789b9a68ee1323eb58 +size 29252 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png index 53101fc26c..0326a29728 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33e1f5f834b4218befb188a6ab4ba79d462af13427c1f2796acd046c2a8a3fbf -size 27355 +oid sha256:12102cfb60df0549457d3f2e7b6f6b97f8bf32bad43188789b9a68ee1323eb58 +size 29252 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png index 22d313153e..c464c5a21b 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5102b33e4e5db105d434c39f17b455532553580beffb301c0f5e357821d2f38 -size 39715 +oid sha256:f726115f8d73fd3e274b5f38106a660f334bf53899282640c6b232a024eca0ea +size 42095 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png index a03cc6a837..d1e66b98b5 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3ebc6f284f1717145b687636f8788bd5f5e28691a12e9c8b561784c0e0feb66 -size 38224 +oid sha256:48eac47d14fbd387f55f3ae0023c6466d3235e6307bdd2979a815e80c298e332 +size 40278 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png index 36cfccf34e..01553d2196 100644 --- a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8fd149de1b91b5e8b72c225fd616c489269311a44c770d7f887317107fd98f5 -size 24816 +oid sha256:802ebd31e04616779daef8370cb71777ff3bf4ccd27caeff8d40c486eb31ff75 +size 23907