Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ package com.example.compose.snippets.touchinput.gestures

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.gestures.scrollableArea
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand All @@ -51,12 +55,17 @@ import androidx.compose.material.swipeable
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.annotation.FrequentlyChangingValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
Expand All @@ -67,12 +76,18 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import kotlin.math.abs
import kotlin.math.roundToInt

@Preview
Expand Down Expand Up @@ -174,6 +189,177 @@ private fun ScrollableSample() {
}
// [END android_compose_touchinput_gestures_scrollable]

@Preview
// [START android_compose_touchinput_gestures_scrollableArea]
@Composable
private fun ScrollableAreaSample() {
// [START_EXCLUDE]
// rememberScrollableAreaSampleScrollState() holds the scroll position and other relevant
// information. It implements the ScrollableState interface, making it compatible with the
// scrollableArea modifier
val scrollState = rememberScrollableAreaSampleScrollState()
// [END_EXCLUDE]
Layout(
modifier =
Modifier
.size(150.dp)
.scrollableArea(scrollState, Orientation.Vertical)
.background(Color.LightGray),
// [START_EXCLUDE]
content = {
repeat(40) {
Text(
modifier = Modifier.padding(vertical = 2.dp),
text = "Item $it",
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
}
},
// [END_EXCLUDE]
) { measurables, constraints ->
// [START_EXCLUDE]
var totalHeight = 0

val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables =
measurables.map { measurable ->
val placeable = measurable.measure(childConstraints)
totalHeight += placeable.height
placeable
}

val viewportHeight = constraints.maxHeight
// [END_EXCLUDE]
// Update the maximum scroll value to not scroll beyond limits and stop when scroll
// reaches the end.
scrollState.maxValue = (totalHeight - viewportHeight).coerceAtLeast(0)

// Position the children within the layout.
layout(constraints.maxWidth, viewportHeight) {
// The current vertical scroll position, in pixels.
val scrollY = scrollState.value
val viewportCenterY = scrollY + viewportHeight / 2

var placeableLayoutPositionY = 0
placeables.forEach { placeable ->
// This sample applies a scaling effect to items based on their distance
// from the center, creating a wheel-like effect.
// [START_EXCLUDE]
val itemCenterY = placeableLayoutPositionY + placeable.height / 2
val distanceFromCenter = abs(itemCenterY - viewportCenterY)
val normalizedDistance =
(distanceFromCenter / (viewportHeight / 2f)).fastCoerceIn(0f, 1f)

// Items scale between 0.4 at the edges of the viewport and 1 at the center.
val scaleFactor = 1f - (normalizedDistance * 0.6f)
// [END_EXCLUDE]
// Place the item horizontally centered with a layer transformation for
// scaling to achieve wheel-like effect.
placeable.placeRelativeWithLayer(
x = constraints.maxWidth / 2 - placeable.width / 2,
// Offset y by the scroll position to make placeable visible in the viewport.
y = placeableLayoutPositionY - scrollY,
) {
scaleX = scaleFactor
scaleY = scaleFactor
}
// Move to the next item's vertical position.
placeableLayoutPositionY += placeable.height
}
}
}
}
// [START_EXCLUDE]
/*
* A custom implementation of ScrollableState that manages a scroll position and its maximum allowed
* value.
*
* This is a simplified version of the `ScrollState` used by `Modifier.verticalScroll` and
* `Modifier.horizontalScroll`, demonstrating how to implement a custom state for custom scrollable
* containers.
*/
private class ScrollableAreaSampleScrollState(initial: Int) : ScrollableState {

// The current integer scroll position in pixels.
// This is backed by a mutableStateOf to trigger recomposition when it changes.
@get:FrequentlyChangingValue
var value by mutableIntStateOf(initial)
private set

// The maximum scroll position allowed. This is typically derived from the content size minus
// viewport size.
var maxValue: Int
get() = _maxValueState.intValue
set(newMax) {
_maxValueState.intValue = newMax
Snapshot.withoutReadObservation {
if (value > newMax) {
value = newMax
}
}
}

private var _maxValueState = mutableIntStateOf(Int.MAX_VALUE)

// Accumulates sub-pixel scroll deltas. This ensures that even small, fractional scroll
// movements are accounted for and contribute to the total scroll position over time, preventing
// loss of precision.
private var accumulator: Float = 0f

// The underlying ScrollableState that handles the actual scroll consumption logic. This lambda
// is invoked when a scroll delta is received.
private val scrollableState = ScrollableState {
val absolute = (value + it + accumulator)
val newValue = absolute.coerceIn(0f, maxValue.toFloat())
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.fastRoundToInt()
value += consumedInt
accumulator = consumed - consumedInt

if (changed) consumed else it
}

override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit,
): Unit = scrollableState.scroll(scrollPriority, block)

override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)

override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress

override val canScrollForward: Boolean by derivedStateOf { value < maxValue }

override val canScrollBackward: Boolean by derivedStateOf { value > 0 }

override val lastScrolledForward: Boolean
get() = scrollableState.lastScrolledForward

override val lastScrolledBackward: Boolean
get() = scrollableState.lastScrolledBackward

companion object {
// Saver for CustomSampleScrollState, allowing it to be saved and restored across
// process death or configuration changes. Only the current scroll 'value' is saved.
val Saver: Saver<ScrollableAreaSampleScrollState, *> =
Saver(save = { it.value }, restore = { ScrollableAreaSampleScrollState(it) })
}
}

@Composable
private fun rememberScrollableAreaSampleScrollState(
initial: Int = 0
): ScrollableAreaSampleScrollState {
return rememberSaveable(saver = ScrollableAreaSampleScrollState.Saver) {
ScrollableAreaSampleScrollState(initial = initial)
}
}
// [END_EXCLUDE]
// [END android_compose_touchinput_gestures_scrollableArea]

// [START android_compose_touchinput_gestures_nested_scroll]
@Composable
private fun AutomaticNestedScroll() {
Expand Down
Loading