Skip to content

Added scrollable snippets#837

Open
raystatic wants to merge 2 commits intomainfrom
scroll-snippets
Open

Added scrollable snippets#837
raystatic wants to merge 2 commits intomainfrom
scroll-snippets

Conversation

@raystatic
Copy link
Contributor

Scrollable snippets added

@raystatic raystatic requested a review from a team as a code owner March 16, 2026 11:03
@snippet-bot
Copy link

snippet-bot bot commented Mar 16, 2026

Here is the summary of changes.

You are about to add 5 region tags.

This comment is generated by snippet-bot.
If you find problems with this result, please file an issue at:
https://github.com/googleapis/repo-automation-bots/issues.
To update this comment, add snippet-bot:force-run label or use the checkbox below:

  • Refresh this comment

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a comprehensive set of new Jetpack Compose UI snippets focused on advanced touch input handling, specifically for two-dimensional scrolling and dragging. These examples provide clear, practical demonstrations of how to implement custom 2D gesture recognition and interaction patterns, enhancing the library's utility for developers building complex interactive UIs.

Highlights

  • New Scrollable and Draggable Snippets: Added a new file ScrollSnippets.kt containing five distinct examples demonstrating 2D scrolling and dragging functionalities in Jetpack Compose.
  • 2D Scrolling Examples: Included Scrollable2DSample for basic 2D scroll tracking, ImageViewer2D for panning a large image within a viewport, and NestedScrollable2DSample showcasing coordinated nested scrolling between a 2D scrollable box and a vertical parent scroll.
  • 2D Dragging Examples: Provided DraggableComposableWindow to illustrate basic 2D dragging for a floating UI element, and ColorPicker2D demonstrating a more complex use case for a 2D color selector.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • compose/snippets/src/main/java/com/example/compose/snippets/touchinput/scroll/ScrollSnippets.kt
    • Added new file with various 2D scroll and drag examples.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new file with several well-structured Jetpack Compose snippets demonstrating scrollable2D and draggable2D gestures. The examples are clear and cover basic to nested use cases. My review includes suggestions to improve code style consistency by using property delegation for mutableStateOf, enhance maintainability by extracting magic numbers into constants, improve accessibility by adding content descriptions, and adhere to Kotlin coding conventions for parameter naming. These changes will make the snippets more robust and idiomatic.

Comment on lines +69 to +113
fun Scrollable2DSample() {
// 1. Manually track the total distance the user has moved in both X and Y directions
val offset = remember { mutableStateOf(Offset.Zero) }

Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5)),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(200.dp)
// 2. Attach the 2D scroll logic to capture XY movement deltas
.scrollable2D(
state = rememberScrollable2DState { delta ->
// 3. Update the cumulative offset state with the new movement delta
offset.value += delta

// Return the delta to indicate the entire movement was handled by this box
delta
}
)
.background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// 4. Display the current X and Y values from the offset state in real-time
Text(
text = "X: ${offset.value.x.roundToInt()}",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Y: ${offset.value.y.roundToInt()}",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better readability and consistency with idiomatic Jetpack Compose patterns, consider using the property delegation syntax for mutableStateOf. This avoids repeatedly using .value to access and update the state.

fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFF5F5F5)),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                .background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    color = Color.White,
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    color = Color.White,
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold
                )
            }
        }
    }
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address :)

Comment on lines +119 to +157
fun ImageViewer2D(modifier: Modifier) {

// Manually track the total distance the user has moved in both X and Y directions
val offset = remember { mutableStateOf(Offset.Zero) }

// Define how gestures are captured. The lambda is called for every finger movement
val scrollState = rememberScrollable2DState { delta ->
offset.value += delta
delta
}

// The Viewport (Container): A fixed-size box that acts as a window into the larger content
Box(
modifier = modifier
.size(600.dp, 400.dp) // The visible area dimensions
.border(width = 2.dp, color = Color.Black, shape = RoundedCornerShape(0.dp))
.background(color = Color.LightGray)
// Hide any parts of the large content that sit outside this container's boundarie
.clipToBounds()
// Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
.scrollable2D(state = scrollState),
contentAlignment = Alignment.Center,
) {
// The Content: An image given a much larger size than the container viewport
Image(
painter = painterResource(R.drawable.pexels_demo),
contentDescription = null,
modifier = Modifier
.requiredSize(1200.dp, 800.dp)
// Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
// we use graphicsLayer to shift the drawing position based on the tracked offset.
.graphicsLayer {
translationX = offset.value.x
translationY = offset.value.y
},
contentScale = ContentScale.FillBounds
)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This composable can be improved by using property delegation (by) for mutableStateOf for more idiomatic state handling. Additionally, for accessibility, it's important to provide a contentDescription for images. If the image is purely decorative, you can provide an empty string ("").

fun ImageViewer2D(modifier: Modifier) {

    // Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            .border(width = 2.dp, color = Color.Black, shape = RoundedCornerShape(0.dp))
            .background(color = Color.LightGray)
            // Hide any parts of the large content that sit outside this container's boundarie
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.pexels_demo),
            contentDescription = "Large image for panning",
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.x
                    translationY = offset.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

Comment on lines +161 to +224
// [START android_compose_touchinput_scroll_scrollable2D_nested_scrolling]
@Composable
fun NestedScrollable2DSample() {
val offset = remember { mutableStateOf(Offset.Zero) }
val maxScroll = 300f

// The Parent: A standard vertical scroll container
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(Color(0xFFF5F5F5)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Scroll down to find the 2D Box",
modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
style = TextStyle(fontSize = 18.sp, color = Color.Gray)
)

// The Child: A 2D scrollable box with nested scroll coordination
Box(
modifier = Modifier
.size(250.dp)
.scrollable2D(
state = rememberScrollable2DState { delta ->
val oldOffset = offset.value

// Calculate new potential offset and clamp it to our boundaries
val newX = (oldOffset.x + delta.x).coerceIn(-maxScroll, maxScroll)
val newY = (oldOffset.y + delta.y).coerceIn(-maxScroll, maxScroll)

val newOffset = Offset(newX, newY)

// Calculate exactly how much was consumed by the child
val consumed = newOffset - oldOffset

offset.value = newOffset

// IMPORTANT: Return ONLY the consumed delta.
// The remaining (unconsumed) delta propagates to the parent Column.
consumed
}
)
.background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
Spacer(Modifier.height(8.dp))
Text("X: ${offset.value.x.roundToInt()}", color = Color.White, fontWeight = FontWeight.Bold)
Text("Y: ${offset.value.y.roundToInt()}", color = Color.White, fontWeight = FontWeight.Bold)
}
}

Text(
"Once the Purple Box hits Y: 300 or -300,\nthis parent list will take over the vertical scroll.",
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
style = TextStyle(fontSize = 14.sp, color = Color.Gray)
)
}
}
// [END android_compose_touchinput_scroll_scrollable2D_nested_scrolling]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This composable can be improved in a few ways for better maintainability and readability:

  1. Use property delegation (by) for mutableStateOf for more idiomatic state management.
  2. Extract the magic number 300f into a named constant.
  3. Use this new constant in the Text composable to ensure the description stays in sync with the logic.
// [START android_compose_touchinput_scroll_scrollable2D_nested_scrolling]
private const val MAX_SCROLL = 300f

@Composable
fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }

    // The Parent: A standard vertical scroll container
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-MAX_SCROLL, MAX_SCROLL)
                        val newY = (oldOffset.y + delta.y).coerceIn(-MAX_SCROLL, MAX_SCROLL)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                .background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${offset.x.roundToInt()}", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${offset.y.roundToInt()}", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: ${MAX_SCROLL.roundToInt()} or -${MAX_SCROLL.roundToInt()},\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}
// [END android_compose_touchinput_scroll_scrollable2D_nested_scrolling]

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address comment 1

@Composable
fun ColorPicker2D(
hue: Float = 0f,
onColorSelected: (Saturation: Float, Value: Float) -> Unit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to Kotlin's coding conventions, function parameter names should start with a lowercase letter and use camelCase. Saturation and Value should be renamed to saturation and value respectively.

Suggested change
onColorSelected: (Saturation: Float, Value: Float) -> Unit
onColorSelected: (saturation: Float, value: Float) -> Unit

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please address

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to TwoDimensionalScrollSnippets

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in latest commit.


// [START android_compose_touchinput_scroll_scrollable2D_pan_large_viewport]
@Composable
fun ImageViewer2D(modifier: Modifier) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename to Panning2DImage instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in latest commit

.size(600.dp, 400.dp) // The visible area dimensions
.border(width = 2.dp, color = Color.Black, shape = RoundedCornerShape(0.dp))
.background(color = Color.LightGray)
// Hide any parts of the large content that sit outside this container's boundarie
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix comment

Suggested change
// Hide any parts of the large content that sit outside this container's boundarie
// Hide any parts of the large content that sit outside this container's boundaries

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in latest commit


// [START android_compose_touchinput_scroll_draggable2D_basic]
@Composable
fun DraggableComposableWindow() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename to avoid using the word Window as that conflicts with Android System window.
Perhaps

Suggested change
fun DraggableComposableWindow() {
fun DraggabaleFloatingElement() {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in latest commit

2. Excluded un-related code snippets using in the output.

3. Addressed comments on the PR
@Composable
fun NestedScrollable2DSample() {
val offset = remember { mutableStateOf(Offset.Zero) }
val maxScroll = 300f
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this dp or px? Should this not be the size of the child? ie 250.dp or is it slightly bigger than the child size?


// [START android_compose_touchinput_scroll_scrollable2D_pan_large_viewport]
@Composable
fun Panning2DImage(modifier: Modifier) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove modifier here or set it to modifier: Modifier = Modifier


// [START android_compose_touchinput_scroll_scrollable2D_basic]
@Composable
fun Scrollable2DSample() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make all composables in this file private

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and annotate with @Preview to be able to run individually, but place the preview tag above the Start tags so they arent included in the rendered composable.

Comment on lines +103 to +105
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

surround with exclude

Comment on lines +110 to +112
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

surround with exclude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants