@@ -20,10 +20,13 @@ package com.example.compose.snippets.touchinput.gestures
2020
2121import android.os.Bundle
2222import androidx.activity.ComponentActivity
23+ import androidx.compose.foundation.MutatePriority
2324import androidx.compose.foundation.background
2425import androidx.compose.foundation.border
2526import androidx.compose.foundation.clickable
2627import androidx.compose.foundation.gestures.Orientation
28+ import androidx.compose.foundation.gestures.ScrollScope
29+ import androidx.compose.foundation.gestures.ScrollableState
2730import androidx.compose.foundation.gestures.detectDragGestures
2831import androidx.compose.foundation.gestures.detectTapGestures
2932import androidx.compose.foundation.gestures.draggable
@@ -43,6 +46,7 @@ import androidx.compose.foundation.layout.size
4346import androidx.compose.foundation.layout.width
4447import androidx.compose.foundation.lazy.LazyColumn
4548import androidx.compose.foundation.rememberScrollState
49+ import androidx.compose.foundation.scrollableArea
4650import androidx.compose.foundation.verticalScroll
4751import androidx.compose.material.ExperimentalMaterialApi
4852import androidx.compose.material.FractionalThreshold
@@ -51,12 +55,17 @@ import androidx.compose.material.swipeable
5155import androidx.compose.material3.Text
5256import androidx.compose.runtime.Composable
5357import androidx.compose.runtime.LaunchedEffect
58+ import androidx.compose.runtime.annotation.FrequentlyChangingValue
59+ import androidx.compose.runtime.derivedStateOf
5460import androidx.compose.runtime.getValue
5561import androidx.compose.runtime.mutableFloatStateOf
5662import androidx.compose.runtime.mutableIntStateOf
5763import androidx.compose.runtime.mutableStateOf
5864import androidx.compose.runtime.remember
65+ import androidx.compose.runtime.saveable.Saver
66+ import androidx.compose.runtime.saveable.rememberSaveable
5967import androidx.compose.runtime.setValue
68+ import androidx.compose.runtime.snapshots.Snapshot
6069import androidx.compose.ui.Alignment
6170import androidx.compose.ui.Modifier
6271import androidx.compose.ui.geometry.Offset
@@ -67,12 +76,18 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
6776import androidx.compose.ui.input.nestedscroll.NestedScrollSource
6877import androidx.compose.ui.input.nestedscroll.nestedScroll
6978import androidx.compose.ui.input.pointer.pointerInput
79+ import androidx.compose.ui.layout.Layout
7080import androidx.compose.ui.platform.ComposeView
7181import androidx.compose.ui.platform.LocalDensity
7282import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
83+ import androidx.compose.ui.text.style.TextAlign
7384import androidx.compose.ui.tooling.preview.Preview
7485import androidx.compose.ui.unit.IntOffset
7586import androidx.compose.ui.unit.dp
87+ import androidx.compose.ui.unit.sp
88+ import androidx.compose.ui.util.fastCoerceIn
89+ import androidx.compose.ui.util.fastRoundToInt
90+ import kotlin.math.abs
7691import kotlin.math.roundToInt
7792
7893@Preview
@@ -174,6 +189,177 @@ private fun ScrollableSample() {
174189}
175190// [END android_compose_touchinput_gestures_scrollable]
176191
192+ @Preview
193+ // [START android_compose_touchinput_gestures_scrollableArea]
194+ @Composable
195+ private fun ScrollableAreaSample () {
196+ // [START_EXCLUDE]
197+ // rememberScrollableAreaSampleScrollState() holds the scroll position and other relevant
198+ // information. It implements the ScrollableState interface, making it compatible with the
199+ // scrollableArea modifier
200+ val scrollState = rememberScrollableAreaSampleScrollState()
201+ // [END_EXCLUDE]
202+ Layout (
203+ modifier =
204+ Modifier
205+ .size(150 .dp)
206+ .scrollableArea(scrollState, Orientation .Vertical )
207+ .background(Color .LightGray ),
208+ // [START_EXCLUDE]
209+ content = {
210+ repeat(40 ) {
211+ Text (
212+ modifier = Modifier .padding(vertical = 2 .dp),
213+ text = " Item $it " ,
214+ fontSize = 24 .sp,
215+ textAlign = TextAlign .Center ,
216+ )
217+ }
218+ },
219+ // [END_EXCLUDE]
220+ ) { measurables, constraints ->
221+ // [START_EXCLUDE]
222+ var totalHeight = 0
223+
224+ val childConstraints = constraints.copy(minWidth = 0 , minHeight = 0 )
225+ val placeables =
226+ measurables.map { measurable ->
227+ val placeable = measurable.measure(childConstraints)
228+ totalHeight + = placeable.height
229+ placeable
230+ }
231+
232+ val viewportHeight = constraints.maxHeight
233+ // [END_EXCLUDE]
234+ // Update the maximum scroll value to not scroll beyond limits and stop when scroll
235+ // reaches the end.
236+ scrollState.maxValue = (totalHeight - viewportHeight).coerceAtLeast(0 )
237+
238+ // Position the children within the layout.
239+ layout(constraints.maxWidth, viewportHeight) {
240+ // The current vertical scroll position, in pixels.
241+ val scrollY = scrollState.value
242+ val viewportCenterY = scrollY + viewportHeight / 2
243+
244+ var placeableLayoutPositionY = 0
245+ placeables.forEach { placeable ->
246+ // This sample applies a scaling effect to items based on their distance
247+ // from the center, creating a wheel-like effect.
248+ // [START_EXCLUDE]
249+ val itemCenterY = placeableLayoutPositionY + placeable.height / 2
250+ val distanceFromCenter = abs(itemCenterY - viewportCenterY)
251+ val normalizedDistance =
252+ (distanceFromCenter / (viewportHeight / 2f )).fastCoerceIn(0f , 1f )
253+
254+ // Items scale between 0.4 at the edges of the viewport and 1 at the center.
255+ val scaleFactor = 1f - (normalizedDistance * 0.6f )
256+ // [END_EXCLUDE]
257+ // Place the item horizontally centered with a layer transformation for
258+ // scaling to achieve wheel-like effect.
259+ placeable.placeRelativeWithLayer(
260+ x = constraints.maxWidth / 2 - placeable.width / 2 ,
261+ // Offset y by the scroll position to make placeable visible in the viewport.
262+ y = placeableLayoutPositionY - scrollY,
263+ ) {
264+ scaleX = scaleFactor
265+ scaleY = scaleFactor
266+ }
267+ // Move to the next item's vertical position.
268+ placeableLayoutPositionY + = placeable.height
269+ }
270+ }
271+ }
272+ }
273+ // [START_EXCLUDE]
274+ /*
275+ * A custom implementation of ScrollableState that manages a scroll position and its maximum allowed
276+ * value.
277+ *
278+ * This is a simplified version of the `ScrollState` used by `Modifier.verticalScroll` and
279+ * `Modifier.horizontalScroll`, demonstrating how to implement a custom state for custom scrollable
280+ * containers.
281+ */
282+ private class ScrollableAreaSampleScrollState (initial : Int ) : ScrollableState {
283+
284+ // The current integer scroll position in pixels.
285+ // This is backed by a mutableStateOf to trigger recomposition when it changes.
286+ @get:FrequentlyChangingValue
287+ var value by mutableIntStateOf(initial)
288+ private set
289+
290+ // The maximum scroll position allowed. This is typically derived from the content size minus
291+ // viewport size.
292+ var maxValue: Int
293+ get() = _maxValueState .intValue
294+ set(newMax) {
295+ _maxValueState .intValue = newMax
296+ Snapshot .withoutReadObservation {
297+ if (value > newMax) {
298+ value = newMax
299+ }
300+ }
301+ }
302+
303+ private var _maxValueState = mutableIntStateOf(Int .MAX_VALUE )
304+
305+ // Accumulates sub-pixel scroll deltas. This ensures that even small, fractional scroll
306+ // movements are accounted for and contribute to the total scroll position over time, preventing
307+ // loss of precision.
308+ private var accumulator: Float = 0f
309+
310+ // The underlying ScrollableState that handles the actual scroll consumption logic. This lambda
311+ // is invoked when a scroll delta is received.
312+ private val scrollableState = ScrollableState {
313+ val absolute = (value + it + accumulator)
314+ val newValue = absolute.coerceIn(0f , maxValue.toFloat())
315+ val changed = absolute != newValue
316+ val consumed = newValue - value
317+ val consumedInt = consumed.fastRoundToInt()
318+ value + = consumedInt
319+ accumulator = consumed - consumedInt
320+
321+ if (changed) consumed else it
322+ }
323+
324+ override suspend fun scroll (
325+ scrollPriority : MutatePriority ,
326+ block : suspend ScrollScope .() -> Unit ,
327+ ): Unit = scrollableState.scroll(scrollPriority, block)
328+
329+ override fun dispatchRawDelta (delta : Float ): Float = scrollableState.dispatchRawDelta(delta)
330+
331+ override val isScrollInProgress: Boolean
332+ get() = scrollableState.isScrollInProgress
333+
334+ override val canScrollForward: Boolean by derivedStateOf { value < maxValue }
335+
336+ override val canScrollBackward: Boolean by derivedStateOf { value > 0 }
337+
338+ override val lastScrolledForward: Boolean
339+ get() = scrollableState.lastScrolledForward
340+
341+ override val lastScrolledBackward: Boolean
342+ get() = scrollableState.lastScrolledBackward
343+
344+ companion object {
345+ // Saver for CustomSampleScrollState, allowing it to be saved and restored across
346+ // process death or configuration changes. Only the current scroll 'value' is saved.
347+ val Saver : Saver <ScrollableAreaSampleScrollState , * > =
348+ Saver (save = { it.value }, restore = { ScrollableAreaSampleScrollState (it) })
349+ }
350+ }
351+
352+ @Composable
353+ private fun rememberScrollableAreaSampleScrollState (
354+ initial : Int = 0
355+ ): ScrollableAreaSampleScrollState {
356+ return rememberSaveable(saver = ScrollableAreaSampleScrollState .Saver ) {
357+ ScrollableAreaSampleScrollState (initial = initial)
358+ }
359+ }
360+ // [END_EXCLUDE]
361+ // [END android_compose_touchinput_gestures_scrollableArea]
362+
177363// [START android_compose_touchinput_gestures_nested_scroll]
178364@Composable
179365private fun AutomaticNestedScroll () {
0 commit comments