From 6114bf656602edc7f3fd5100ed3442faf4bfb488 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Mon, 13 Apr 2026 15:19:03 -0700 Subject: [PATCH 01/14] Remember more of the FastScrollingTransformingLazyColumn to prevent recompositions --- .../m3/FastScrollingTransformingLazyColumn.kt | 334 +++++++++--------- 1 file changed, 176 insertions(+), 158 deletions(-) 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..52f02d8371 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 @@ -73,12 +74,12 @@ import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.MotionScheme.Companion.expressive import androidx.wear.compose.material3.MotionScheme.Companion.standard import androidx.wear.compose.material3.Text +import kotlin.math.abs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import kotlin.math.abs /** * Modification of the TransformingLazyColumn that allows for fast scrolling to objects with @@ -108,6 +109,7 @@ public fun FastScrollingTransformingLazyColumn( content: TransformingLazyColumnScope.() -> Unit, ) { val haptics = LocalHapticFeedback.current + val density = LocalDensity.current val screenHeight = LocalWindowInfo.current.containerSize.height val defaultFlingBehavior = ScrollableDefaults.flingBehavior() @@ -124,13 +126,13 @@ 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, sectionIndictatorTopPadding) { + with(density) { + (Constants.REMAINING_LETTER_HEIGHT + + Constants.SECTION_INDICATOR_TOP_PADDING + + sectionIndictatorTopPadding) + .roundToPx() + } } var currentSectionIndex by remember { mutableIntStateOf(0) } @@ -140,52 +142,28 @@ 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 + currentSectionIndex = firstItemIndex } } 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) - // We run animateScrollBy with a movement of 0 just to remove the timeText from the screen and - // show the position indicators, as animateScrollToItem will fling from each section. - state.animateScrollBy(0f) - yield() - state.scrollToItem(headers[currentSectionIndex].index, offset) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + // We run animateScrollBy with a movement of 0 just to remove the timeText from the screen and + // show the position indicators, as animateScrollToItem will fling from each section. + state.animateScrollBy(0f) + yield() + state.scrollToItem(headers[currentSectionIndex].index, offset) } } @@ -195,175 +173,214 @@ 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) { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) coroutineScope.launch { - // Here, we animate the scroll by 0f to remove the timeText from the screen and - // show the position indicators. Running animateScrollBy by the delta - // does not scroll as much as scrollBy for some reason. - state.animateScrollBy(0f) - yield() - state.scrollBy(delta) + // Here, we animate the scroll by 0f to remove the timeText from the screen and + // show the position indicators. Running animateScrollBy by the delta + // does not scroll as much as scrollBy for some reason. + state.animateScrollBy(0f) + yield() + state.scrollBy(delta) } } fun scrollOrSkim(delta: Float, isScrollingDown: Boolean) { val newSectionIndex = (currentSectionIndex + (if (isScrollingDown) 1 else -1)) if (newSectionIndex != newSectionIndex.coerceIn(0, headers.size - 1)) { - performScroll(delta) + performScroll(delta) } else { - skimSections(newSectionIndex) + skimSections(newSectionIndex) } } fun handleSkim(currentTime: Long, delta: Float) { val isScrollingDown = delta > 0f if (!isFirstFastScroll) { - // If we fast scroll in two different directions, we will reset the pixels scrolled - // by to 0 to make sure skims in the opposite direction will be performed as intended. - if (isScrollingDown != pixelsScrolledBy > 0f) { - pixelsScrolledBy = 0f - } + // If we fast scroll in two different directions, we will reset the pixels scrolled + // by to 0 to make sure skims in the opposite direction will be performed as intended. + if (isScrollingDown != pixelsScrolledBy > 0f) { + pixelsScrolledBy = 0f + } - // If it has been more than the timeout since the last skim, we will begin taking in - // the fast scrolling pixels. This is to prevent the case where a user starts - // skimming mode by scrolling rapidly, but only wants to move a single section. - if (currentTime - firstSkimTime > Constants.FIRST_SCROLL_TIMEOUT) { - pixelsScrolledBy += delta - } - val sectionsToSkimBy = - (abs(pixelsScrolledBy) / Constants.VERTICAL_SCROLL_BY_THRESHOLD).toInt() - pixelsScrolledBy %= Constants.VERTICAL_SCROLL_BY_THRESHOLD - for (i in 0.. Constants.FIRST_SCROLL_TIMEOUT) { + pixelsScrolledBy += delta + } + val sectionsToSkimBy = + (abs(pixelsScrolledBy) / Constants.VERTICAL_SCROLL_BY_THRESHOLD).toInt() + pixelsScrolledBy %= Constants.VERTICAL_SCROLL_BY_THRESHOLD + for (i in 0.. 0f - val isScrollingInRightDirection = - ( - isScrollingDown && currentSectionIndex < headers.size - 1 || - !isScrollingDown && currentSectionIndex > 0 - ) - - val canFastScroll = - headers.isNotEmpty() && - ( - currentSectionIndex >= 0 && - currentSectionIndex < headers.size && - isScrollingInRightDirection - ) - - if (!isSkimming && abs(currentVelocity) > flingVelocityThreshold && canFastScroll) { - isFirstFastScroll = true - pixelsScrolledBy = 0f - isSkimming = true - } + 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() && + (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) - } + if (isSkimming) { + handleSkim(currentTime = timestampMillis, delta = delta) + } else { + performScroll(delta) } } + } + } TransformingLazyColumn( - state = state, - flingBehavior = flingBehavior, - rotaryScrollableBehavior = rotaryScrollableBehavior, - modifier = modifier.fillMaxWidth(), - contentPadding = remember { contentPadding }, + state = state, + flingBehavior = flingBehavior, + rotaryScrollableBehavior = rotaryScrollableBehavior, + modifier = modifier.fillMaxWidth(), + contentPadding = contentPadding, ) { - content() + content() } - AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { - SectionIndicator(indicatorWidthScale, currentSectionHeader, sectionIndictatorTopPadding) + SkimIndicator( + isSkimmingProvider = { isSkimming }, + indicatorStateProvider = { indicatorState }, + headerProvider = { headers.getOrNull(currentSectionIndex) }, + sectionIndictatorTopPadding = sectionIndictatorTopPadding, + ) + + LaunchedEffect(key1 = headers) { + snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } + .collect { visibleItemIndex -> + if (!isSkimming && headers.isNotEmpty()) { + val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } + val sectionIndex = + if (searchResult >= 0) { + // Exact match found + searchResult + } else { + // No exact match, visibleItemIndex is between header indices. + // 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) + } + setCurrentSectionIndex(sectionIndex) + } + } } + } + } - LaunchedEffect(key1 = Unit) { - snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } - .collect { visibleItemIndex -> - if (!isSkimming && headers.isNotEmpty()) { - val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } - val sectionIndex = - if (searchResult >= 0) { - // Exact match found - searchResult - } else { - // No exact match, visibleItemIndex is between header indices. - // 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) - } - setCurrentSectionIndex(sectionIndex) - } - } + @Composable + private fun SkimIndicator( + isSkimmingProvider: () -> Boolean, + indicatorStateProvider: () -> IndicatorState, + headerProvider: () -> HeaderInfo?, + sectionIndictatorTopPadding: 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, sectionIndictatorTopPadding) + } } -} @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SectionIndicator( - indicatorWidthScale: Float, - currentSectionHeader: HeaderInfo?, + indicatorWidthScale: () -> Float, + headerProvider: () -> HeaderInfo?, sectionIndictatorTopPadding: Dp, ) { + val currentSectionHeader = headerProvider() val shape = remember { RoundedCornerShape(24.dp) } val annotatedText = remember(currentSectionHeader) { @@ -390,7 +407,7 @@ private fun SectionIndicator( ) { 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 +439,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(), From 36953d0492aaeb550d024f3d5c8248915025bee3 Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:40:04 +0000 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=A4=96=20reformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../m3/FastScrollingTransformingLazyColumn.kt | 296 +++++++++--------- 1 file changed, 151 insertions(+), 145 deletions(-) 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 52f02d8371..8b42a998d2 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 @@ -74,12 +74,12 @@ import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.MotionScheme.Companion.expressive import androidx.wear.compose.material3.MotionScheme.Companion.standard import androidx.wear.compose.material3.Text -import kotlin.math.abs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.yield +import kotlin.math.abs /** * Modification of the TransformingLazyColumn that allows for fast scrolling to objects with @@ -127,12 +127,14 @@ public fun FastScrollingTransformingLazyColumn( // composable. val scrollToOffset = remember(density, sectionIndictatorTopPadding) { - with(density) { - (Constants.REMAINING_LETTER_HEIGHT + - Constants.SECTION_INDICATOR_TOP_PADDING + - sectionIndictatorTopPadding) - .roundToPx() - } + with(density) { + ( + Constants.REMAINING_LETTER_HEIGHT + + Constants.SECTION_INDICATOR_TOP_PADDING + + sectionIndictatorTopPadding + ) + .roundToPx() + } } var currentSectionIndex by remember { mutableIntStateOf(0) } @@ -148,7 +150,7 @@ public fun FastScrollingTransformingLazyColumn( fun setCurrentSectionIndex(firstItemIndex: Int) { if (currentSectionIndex != firstItemIndex) { - currentSectionIndex = firstItemIndex + currentSectionIndex = firstItemIndex } } @@ -158,12 +160,12 @@ public fun FastScrollingTransformingLazyColumn( val offset = (screenHeight * 0.5).toInt() - headerOffset coroutineScope.launch { - haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) - // We run animateScrollBy with a movement of 0 just to remove the timeText from the screen and - // show the position indicators, as animateScrollToItem will fling from each section. - state.animateScrollBy(0f) - yield() - state.scrollToItem(headers[currentSectionIndex].index, offset) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + // We run animateScrollBy with a movement of 0 just to remove the timeText from the screen and + // show the position indicators, as animateScrollToItem will fling from each section. + state.animateScrollBy(0f) + yield() + state.scrollToItem(headers[currentSectionIndex].index, offset) } } @@ -174,13 +176,13 @@ public fun FastScrollingTransformingLazyColumn( // 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 + 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 @@ -189,160 +191,164 @@ public fun FastScrollingTransformingLazyColumn( 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 + 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) { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) coroutineScope.launch { - // Here, we animate the scroll by 0f to remove the timeText from the screen and - // show the position indicators. Running animateScrollBy by the delta - // does not scroll as much as scrollBy for some reason. - state.animateScrollBy(0f) - yield() - state.scrollBy(delta) + // Here, we animate the scroll by 0f to remove the timeText from the screen and + // show the position indicators. Running animateScrollBy by the delta + // does not scroll as much as scrollBy for some reason. + state.animateScrollBy(0f) + yield() + state.scrollBy(delta) } } fun scrollOrSkim(delta: Float, isScrollingDown: Boolean) { val newSectionIndex = (currentSectionIndex + (if (isScrollingDown) 1 else -1)) if (newSectionIndex != newSectionIndex.coerceIn(0, headers.size - 1)) { - performScroll(delta) + performScroll(delta) } else { - skimSections(newSectionIndex) + skimSections(newSectionIndex) } } fun handleSkim(currentTime: Long, delta: Float) { val isScrollingDown = delta > 0f if (!isFirstFastScroll) { - // If we fast scroll in two different directions, we will reset the pixels scrolled - // by to 0 to make sure skims in the opposite direction will be performed as intended. - if (isScrollingDown != pixelsScrolledBy > 0f) { - pixelsScrolledBy = 0f - } + // If we fast scroll in two different directions, we will reset the pixels scrolled + // by to 0 to make sure skims in the opposite direction will be performed as intended. + if (isScrollingDown != pixelsScrolledBy > 0f) { + pixelsScrolledBy = 0f + } - // If it has been more than the timeout since the last skim, we will begin taking in - // the fast scrolling pixels. This is to prevent the case where a user starts - // skimming mode by scrolling rapidly, but only wants to move a single section. - if (currentTime - firstSkimTime > Constants.FIRST_SCROLL_TIMEOUT) { - pixelsScrolledBy += delta - } - val sectionsToSkimBy = - (abs(pixelsScrolledBy) / Constants.VERTICAL_SCROLL_BY_THRESHOLD).toInt() - pixelsScrolledBy %= Constants.VERTICAL_SCROLL_BY_THRESHOLD - for (i in 0.. Constants.FIRST_SCROLL_TIMEOUT) { + pixelsScrolledBy += delta + } + val sectionsToSkimBy = + (abs(pixelsScrolledBy) / Constants.VERTICAL_SCROLL_BY_THRESHOLD).toInt() + pixelsScrolledBy %= Constants.VERTICAL_SCROLL_BY_THRESHOLD + for (i in 0.. 0f - val isScrollingInRightDirection = - ((isScrollingDown && currentSectionIndex < headers.size - 1) || - (!isScrollingDown && currentSectionIndex > 0)) - - 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) + 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 = + ( + (isScrollingDown && currentSectionIndex < headers.size - 1) || + (!isScrollingDown && currentSectionIndex > 0) + ) + + 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) + } + } } } - } - } TransformingLazyColumn( - state = state, - flingBehavior = flingBehavior, - rotaryScrollableBehavior = rotaryScrollableBehavior, - modifier = modifier.fillMaxWidth(), - contentPadding = contentPadding, + state = state, + flingBehavior = flingBehavior, + rotaryScrollableBehavior = rotaryScrollableBehavior, + modifier = modifier.fillMaxWidth(), + contentPadding = contentPadding, ) { - content() + content() } SkimIndicator( - isSkimmingProvider = { isSkimming }, - indicatorStateProvider = { indicatorState }, - headerProvider = { headers.getOrNull(currentSectionIndex) }, - sectionIndictatorTopPadding = sectionIndictatorTopPadding, + isSkimmingProvider = { isSkimming }, + indicatorStateProvider = { indicatorState }, + headerProvider = { headers.getOrNull(currentSectionIndex) }, + sectionIndictatorTopPadding = sectionIndictatorTopPadding, ) LaunchedEffect(key1 = headers) { - snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } - .collect { visibleItemIndex -> - if (!isSkimming && headers.isNotEmpty()) { - val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } - val sectionIndex = - if (searchResult >= 0) { - // Exact match found - searchResult - } else { - // No exact match, visibleItemIndex is between header indices. - // 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) + snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } + .collect { visibleItemIndex -> + if (!isSkimming && headers.isNotEmpty()) { + val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } + val sectionIndex = + if (searchResult >= 0) { + // Exact match found + searchResult + } else { + // No exact match, visibleItemIndex is between header indices. + // 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) + } + setCurrentSectionIndex(sectionIndex) + } } - setCurrentSectionIndex(sectionIndex) - } - } } } - } +} - @Composable - private fun SkimIndicator( +@Composable +private fun SkimIndicator( isSkimmingProvider: () -> Boolean, indicatorStateProvider: () -> IndicatorState, headerProvider: () -> HeaderInfo?, sectionIndictatorTopPadding: Dp, - ) { +) { val isSkimming = isSkimmingProvider() val indicatorState = indicatorStateProvider() @@ -350,28 +356,28 @@ public fun FastScrollingTransformingLazyColumn( 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", + 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 - } + when (it) { + IndicatorState.START -> 1f + IndicatorState.SPRING -> 1.25f + IndicatorState.END -> 1f + } } AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { SectionIndicator({ indicatorWidthScale }, headerProvider, sectionIndictatorTopPadding) } - } +} @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -407,7 +413,7 @@ private fun SectionIndicator( ) { Box( modifier = - Modifier.graphicsLayer { this.scaleX = indicatorWidthScale() } + Modifier.graphicsLayer { this.scaleX = indicatorWidthScale() } .clip(shape) .requiredHeight(Constants.INDICATOR_HEIGHT) .sizeIn(minWidth = Constants.INDICATOR_WIDTH) From ab2a99860c075a20d4b337a6a7eb9f92dd633b08 Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:48:10 +0000 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=A4=96=20metalava?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose-layout/api/current.api | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index 691a6e517d..5ccb36851a 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -221,8 +221,13 @@ package com.google.android.horologist.compose.layout.m3 { 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); } - 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(); From a86559e9ef984609d46075aa154ae9f29591ff5e Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:59:28 +0000 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=A4=96=20Updates=20screenshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rmingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png | 4 ++-- ...rmingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png | 4 ++-- ...rmingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png | 4 ++-- ...sformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png | 4 ++-- ...sformingLazyColumnTest_BasicExample[4]_small_round_end.png | 4 ++-- ...sformingLazyColumnTest_BasicExample[5]_large_round_end.png | 4 ++-- ...lumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png | 4 ++-- ...yColumnTest_BasicExample[7]_pixel_watch_large_font_end.png | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) 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 From ae8891932fb1b20973715d5016958dcc70716ffa Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 10:51:02 -0700 Subject: [PATCH 05/14] Remove un-needed remember and rename typo --- .../m3/FastScrollingTransformingLazyColumn.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 8b42a998d2..1ff5e63ff4 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 @@ -91,7 +91,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 @@ -104,7 +104,7 @@ public fun FastScrollingTransformingLazyColumn( state: TransformingLazyColumnState, headers: SnapshotStateList, modifier: Modifier = Modifier, - sectionIndictatorTopPadding: Dp = 0.dp, + sectionIndicatorTopPadding: Dp = 0.dp, contentPadding: PaddingValues = PaddingValues(), content: TransformingLazyColumnScope.() -> Unit, ) { @@ -115,9 +115,7 @@ public fun FastScrollingTransformingLazyColumn( 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) } @@ -126,12 +124,12 @@ public fun FastScrollingTransformingLazyColumn( // section indicator top padding, with whatever extra top padding is passed in from the // composable. val scrollToOffset = - remember(density, sectionIndictatorTopPadding) { + remember(density, sectionIndicatorTopPadding) { with(density) { ( Constants.REMAINING_LETTER_HEIGHT + Constants.SECTION_INDICATOR_TOP_PADDING + - sectionIndictatorTopPadding + sectionIndicatorTopPadding ) .roundToPx() } @@ -316,7 +314,7 @@ public fun FastScrollingTransformingLazyColumn( isSkimmingProvider = { isSkimming }, indicatorStateProvider = { indicatorState }, headerProvider = { headers.getOrNull(currentSectionIndex) }, - sectionIndictatorTopPadding = sectionIndictatorTopPadding, + sectionIndicatorTopPadding = sectionIndicatorTopPadding, ) LaunchedEffect(key1 = headers) { @@ -347,7 +345,7 @@ private fun SkimIndicator( isSkimmingProvider: () -> Boolean, indicatorStateProvider: () -> IndicatorState, headerProvider: () -> HeaderInfo?, - sectionIndictatorTopPadding: Dp, + sectionIndicatorTopPadding: Dp, ) { val isSkimming = isSkimmingProvider() val indicatorState = indicatorStateProvider() @@ -375,7 +373,7 @@ private fun SkimIndicator( } AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { - SectionIndicator({ indicatorWidthScale }, headerProvider, sectionIndictatorTopPadding) + SectionIndicator({ indicatorWidthScale }, headerProvider, sectionIndicatorTopPadding) } } @@ -384,7 +382,7 @@ private fun SkimIndicator( private fun SectionIndicator( indicatorWidthScale: () -> Float, headerProvider: () -> HeaderInfo?, - sectionIndictatorTopPadding: Dp, + sectionIndicatorTopPadding: Dp, ) { val currentSectionHeader = headerProvider() val shape = remember { RoundedCornerShape(24.dp) } @@ -409,7 +407,7 @@ private fun SectionIndicator( Box( contentAlignment = Alignment.TopCenter, - modifier = Modifier.fillMaxWidth().padding(top = sectionIndictatorTopPadding), + modifier = Modifier.fillMaxWidth().padding(top = sectionIndicatorTopPadding), ) { Box( modifier = From f0cca9f97652bcac8be5848b511e72e0e798aad9 Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:12:08 +0000 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=A4=96=20metalava?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose-layout/api/current.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index 5ccb36851a..3451e54219 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -218,7 +218,7 @@ 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); } @androidx.compose.runtime.Immutable public final class HeaderInfo { From 3fa813a2dfe664f7521969f60fda88ceb0c00106 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 13:44:22 -0700 Subject: [PATCH 07/14] empty commit --- .../compose/layout/m3/FastScrollingTransformingLazyColumn.kt | 1 + 1 file changed, 1 insertion(+) 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 1ff5e63ff4..6db7d175b9 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 @@ -108,6 +108,7 @@ public fun FastScrollingTransformingLazyColumn( contentPadding: PaddingValues = PaddingValues(), content: TransformingLazyColumnScope.() -> Unit, ) { + val haptics = LocalHapticFeedback.current val density = LocalDensity.current val screenHeight = LocalWindowInfo.current.containerSize.height From e088b545f955ffbb0c3e797c9ce991be9d32b594 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 13:44:46 -0700 Subject: [PATCH 08/14] empty commit --- .../compose/layout/m3/FastScrollingTransformingLazyColumn.kt | 1 - 1 file changed, 1 deletion(-) 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 6db7d175b9..1ff5e63ff4 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 @@ -108,7 +108,6 @@ public fun FastScrollingTransformingLazyColumn( contentPadding: PaddingValues = PaddingValues(), content: TransformingLazyColumnScope.() -> Unit, ) { - val haptics = LocalHapticFeedback.current val density = LocalDensity.current val screenHeight = LocalWindowInfo.current.containerSize.height From 328e0cba2086323b1ac3e72f6fc08a37b416bb3d Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 16:13:28 -0700 Subject: [PATCH 09/14] update snapshotList logic --- .../m3/FastScrollingTransformingLazyColumn.kt | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) 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 1ff5e63ff4..3653a90bc7 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 @@ -318,25 +318,27 @@ public fun FastScrollingTransformingLazyColumn( ) LaunchedEffect(key1 = headers) { - snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } - .collect { visibleItemIndex -> - if (!isSkimming && headers.isNotEmpty()) { - val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } - val sectionIndex = - if (searchResult >= 0) { - // Exact match found - searchResult - } else { - // No exact match, visibleItemIndex is between header indices. - // 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) - } - setCurrentSectionIndex(sectionIndex) - } - } + snapshotFlow { + Pair(state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0, headers.toList()) } + .collect { (visibleItemIndex, currentHeaders) -> + if (!isSkimming && currentHeaders.isNotEmpty()) { + val searchResult = currentHeaders.binarySearchBy(visibleItemIndex) { it.index } + val sectionIndex = + if (searchResult >= 0) { + // Exact match found + searchResult + } else { + // No exact match, visibleItemIndex is between header indices. + // binarySearchBy returns (-insertion point - 1). + // The section index is the item before the insertion point. + val insertionPoint = -searchResult - 1 + (insertionPoint - 1).coerceIn(0, currentHeaders.size - 1) + } + setCurrentSectionIndex(sectionIndex) + } + } + } } } From 69d5c410478a197ddfbe7b6833cb4eff4fdfd686 Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:17:12 +0000 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=A4=96=20reformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../m3/FastScrollingTransformingLazyColumn.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) 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 3653a90bc7..dad02a5789 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 @@ -318,28 +318,28 @@ public fun FastScrollingTransformingLazyColumn( ) LaunchedEffect(key1 = headers) { - snapshotFlow { - Pair(state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0, headers.toList()) - } - .collect { (visibleItemIndex, currentHeaders) -> - if (!isSkimming && currentHeaders.isNotEmpty()) { - val searchResult = currentHeaders.binarySearchBy(visibleItemIndex) { it.index } - val sectionIndex = - if (searchResult >= 0) { - // Exact match found - searchResult - } else { - // No exact match, visibleItemIndex is between header indices. - // binarySearchBy returns (-insertion point - 1). - // The section index is the item before the insertion point. - val insertionPoint = -searchResult - 1 - (insertionPoint - 1).coerceIn(0, currentHeaders.size - 1) - } - setCurrentSectionIndex(sectionIndex) - } + snapshotFlow { + Pair(state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0, headers.toList()) + } + .collect { (visibleItemIndex, currentHeaders) -> + if (!isSkimming && currentHeaders.isNotEmpty()) { + val searchResult = currentHeaders.binarySearchBy(visibleItemIndex) { it.index } + val sectionIndex = + if (searchResult >= 0) { + // Exact match found + searchResult + } else { + // No exact match, visibleItemIndex is between header indices. + // binarySearchBy returns (-insertion point - 1). + // The section index is the item before the insertion point. + val insertionPoint = -searchResult - 1 + (insertionPoint - 1).coerceIn(0, currentHeaders.size - 1) + } + setCurrentSectionIndex(sectionIndex) + } + } } } - } } @Composable From 3b935c2cc481d48175b74e4f211374375ce54e72 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 16:23:50 -0700 Subject: [PATCH 11/14] Modify key to Unit --- .../compose/layout/m3/FastScrollingTransformingLazyColumn.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dad02a5789..57997f32c9 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 @@ -317,7 +317,7 @@ public fun FastScrollingTransformingLazyColumn( sectionIndicatorTopPadding = sectionIndicatorTopPadding, ) - LaunchedEffect(key1 = headers) { + LaunchedEffect(key1 = Unit) { snapshotFlow { Pair(state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0, headers.toList()) } From 94c1936003022622b2293e780fbf086e1b704220 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Tue, 14 Apr 2026 16:38:14 -0700 Subject: [PATCH 12/14] combine flow to prevent GC getting too full. --- .../layout/m3/FastScrollingTransformingLazyColumn.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 57997f32c9..a380455b46 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 @@ -318,9 +318,13 @@ public fun FastScrollingTransformingLazyColumn( ) LaunchedEffect(key1 = Unit) { - snapshotFlow { - Pair(state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0, headers.toList()) - } + 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 } From 73e031bd7974967f1991f1697d9a9f1c3406f234 Mon Sep 17 00:00:00 2001 From: brysonPaul <89651994+brysonPaul@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:58:06 +0000 Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=A4=96=20reformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/layout/m3/FastScrollingTransformingLazyColumn.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a380455b46..aee8a237cc 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 @@ -323,7 +323,7 @@ public fun FastScrollingTransformingLazyColumn( visibleItemFlow .combine(headersFlow) { visibleItemIndex, currentHeaders -> - Pair(visibleItemIndex, currentHeaders) + Pair(visibleItemIndex, currentHeaders) } .collect { (visibleItemIndex, currentHeaders) -> if (!isSkimming && currentHeaders.isNotEmpty()) { From 93576855abf7c9967fa39da2574ac744eb1d3604 Mon Sep 17 00:00:00 2001 From: brysonpaul Date: Thu, 16 Apr 2026 14:19:36 -0700 Subject: [PATCH 14/14] Adds missing import --- .../compose/layout/m3/FastScrollingTransformingLazyColumn.kt | 1 + 1 file changed, 1 insertion(+) 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 aee8a237cc..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 @@ -77,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