Conversation
Summary of ChangesHello, 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 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| 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 | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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
)
}
}
}
}| 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 | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
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
)
}
}| // [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] |
There was a problem hiding this comment.
This composable can be improved in a few ways for better maintainability and readability:
- Use property delegation (
by) formutableStateOffor more idiomatic state management. - Extract the magic number
300finto a named constant. - Use this new constant in the
Textcomposable 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]| @Composable | ||
| fun ColorPicker2D( | ||
| hue: Float = 0f, | ||
| onColorSelected: (Saturation: Float, Value: Float) -> Unit |
There was a problem hiding this comment.
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.
| onColorSelected: (Saturation: Float, Value: Float) -> Unit | |
| onColorSelected: (saturation: Float, value: Float) -> Unit |
There was a problem hiding this comment.
rename to TwoDimensionalScrollSnippets
There was a problem hiding this comment.
Updated in latest commit.
|
|
||
| // [START android_compose_touchinput_scroll_scrollable2D_pan_large_viewport] | ||
| @Composable | ||
| fun ImageViewer2D(modifier: Modifier) { |
There was a problem hiding this comment.
Can you rename to Panning2DImage instead?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Fix comment
| // 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 |
There was a problem hiding this comment.
Updated in latest commit
compose/snippets/src/main/java/com/example/compose/snippets/touchinput/scroll/ScrollSnippets.kt
Outdated
Show resolved
Hide resolved
|
|
||
| // [START android_compose_touchinput_scroll_draggable2D_basic] | ||
| @Composable | ||
| fun DraggableComposableWindow() { |
There was a problem hiding this comment.
Rename to avoid using the word Window as that conflicts with Android System window.
Perhaps
| fun DraggableComposableWindow() { | |
| fun DraggabaleFloatingElement() { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Remove modifier here or set it to modifier: Modifier = Modifier
|
|
||
| // [START android_compose_touchinput_scroll_scrollable2D_basic] | ||
| @Composable | ||
| fun Scrollable2DSample() { |
There was a problem hiding this comment.
Make all composables in this file private
There was a problem hiding this comment.
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.
| color = Color.White, | ||
| fontSize = 20.sp, | ||
| fontWeight = FontWeight.Bold |
| color = Color.White, | ||
| fontSize = 20.sp, | ||
| fontWeight = FontWeight.Bold |
Scrollable snippets added