-
Notifications
You must be signed in to change notification settings - Fork 346
Added scrollable snippets #837
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,337 @@ | ||||||
| /* | ||||||
| * Copyright 2023 The Android Open Source Project | ||||||
| * | ||||||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
| * you may not use this file except in compliance with the License. | ||||||
| * You may obtain a copy of the License at | ||||||
| * | ||||||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||||||
| * | ||||||
| * Unless required by applicable law or agreed to in writing, software | ||||||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||||||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| * See the License for the specific language governing permissions and | ||||||
| * limitations under the License. | ||||||
| */ | ||||||
|
|
||||||
| package com.example.compose.snippets.touchinput.scroll | ||||||
|
|
||||||
| import androidx.compose.foundation.Image | ||||||
| import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.border | ||||||
| import androidx.compose.foundation.gestures.draggable2D | ||||||
| import androidx.compose.foundation.gestures.rememberDraggable2DState | ||||||
| import androidx.compose.foundation.gestures.rememberScrollable2DState | ||||||
| import androidx.compose.foundation.gestures.scrollable2D | ||||||
| import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.Spacer | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.offset | ||||||
| import androidx.compose.foundation.layout.padding | ||||||
| import androidx.compose.foundation.layout.requiredSize | ||||||
| import androidx.compose.foundation.layout.size | ||||||
| import androidx.compose.foundation.rememberScrollState | ||||||
| import androidx.compose.foundation.shape.CircleShape | ||||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.foundation.verticalScroll | ||||||
| import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.remember | ||||||
| import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.draw.clip | ||||||
| import androidx.compose.ui.draw.clipToBounds | ||||||
| import androidx.compose.ui.draw.dropShadow | ||||||
| import androidx.compose.ui.geometry.Offset | ||||||
| import androidx.compose.ui.graphics.Brush | ||||||
| import androidx.compose.ui.graphics.Color | ||||||
| import androidx.compose.ui.graphics.graphicsLayer | ||||||
| import androidx.compose.ui.layout.ContentScale | ||||||
| import androidx.compose.ui.layout.onSizeChanged | ||||||
| import androidx.compose.ui.res.painterResource | ||||||
| import androidx.compose.ui.text.TextStyle | ||||||
| import androidx.compose.ui.text.font.FontWeight | ||||||
| import androidx.compose.ui.text.style.TextAlign | ||||||
| import androidx.compose.ui.unit.IntOffset | ||||||
| import androidx.compose.ui.unit.IntSize | ||||||
| import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | ||||||
| import com.example.compose.snippets.R | ||||||
| import kotlin.math.roundToInt | ||||||
|
|
||||||
| // [START android_compose_touchinput_scroll_scrollable2D_basic] | ||||||
| @Composable | ||||||
| fun Scrollable2DSample() { | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make all composables in this file private
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||
| // 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() | ||||||
| // [START_EXCLUDE] | ||||||
| .background(Color(0xFFF5F5F5)), | ||||||
| // [END_EXCLUDE] | ||||||
| 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 | ||||||
| } | ||||||
| ) | ||||||
| // [START_EXCLUDE] | ||||||
| .background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)), | ||||||
| // [END_EXCLUDE] | ||||||
| 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 | ||||||
|
Comment on lines
+103
to
+105
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. surround with exclude |
||||||
| ) | ||||||
| Spacer(modifier = Modifier.height(8.dp)) | ||||||
| Text( | ||||||
| text = "Y: ${offset.value.y.roundToInt()}", | ||||||
| color = Color.White, | ||||||
| fontSize = 20.sp, | ||||||
| fontWeight = FontWeight.Bold | ||||||
|
Comment on lines
+110
to
+112
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. surround with exclude |
||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
Comment on lines
+69
to
+117
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better readability and consistency with idiomatic Jetpack Compose patterns, consider using the property delegation syntax for 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
)
}
}
}
}
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please address :) |
||||||
| // [END android_compose_touchinput_scroll_scrollable2D_basic] | ||||||
|
|
||||||
|
|
||||||
| // [START android_compose_touchinput_scroll_scrollable2D_pan_large_viewport] | ||||||
| @Composable | ||||||
| fun Panning2DImage(modifier: Modifier) { | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove modifier here or set it to modifier: 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 | ||||||
| // [START_EXCLUDE] | ||||||
| .border(width = 2.dp, color = Color.Black, shape = RoundedCornerShape(0.dp)) | ||||||
| .background(color = Color.LightGray) | ||||||
| // [END_EXCLUDE] | ||||||
| // Hide any parts of the large content that sit outside this container's boundaries | ||||||
| .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.cheese_5), | ||||||
| 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 | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| // [END android_compose_touchinput_scroll_scrollable2D_pan_large_viewport] | ||||||
|
|
||||||
|
|
||||||
| // [START android_compose_touchinput_scroll_scrollable2D_nested_scrolling] | ||||||
| @Composable | ||||||
| fun NestedScrollable2DSample() { | ||||||
| val offset = remember { mutableStateOf(Offset.Zero) } | ||||||
| val maxScroll = 300f | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||||||
|
|
||||||
| 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 | ||||||
| } | ||||||
| ) | ||||||
| // [START_EXCLUDE] | ||||||
| .background(Color(0xFF6200EE), shape = RoundedCornerShape(16.dp)), | ||||||
| // [END_EXCLUDE] | ||||||
| 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] | ||||||
|
Comment on lines
+167
to
+231
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This composable can be improved in a few ways for better maintainability and readability:
// [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]
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Address comment 1 |
||||||
|
|
||||||
|
|
||||||
| // [START android_compose_touchinput_scroll_draggable2D_basic] | ||||||
| @Composable | ||||||
| fun DraggableComposableElement() { | ||||||
| // 1. Track the position of the floating window | ||||||
| var offset by remember { mutableStateOf(Offset.Zero) } | ||||||
|
|
||||||
| Box(modifier = Modifier.fillMaxSize().background(Color.DarkGray)) { | ||||||
| Box( | ||||||
| modifier = Modifier | ||||||
| // 2. Apply the offset to the box's position | ||||||
| .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } | ||||||
| // [START_EXCLUDE] | ||||||
| .size(160.dp, 90.dp) | ||||||
| .background(Color.Black, RoundedCornerShape(8.dp)) | ||||||
| .border(1.dp, Color.White, RoundedCornerShape(8.dp)) | ||||||
| // [END_EXCLUDE] | ||||||
| // 3. Attach the 2D drag logic | ||||||
| .draggable2D( | ||||||
| state = rememberDraggable2DState { delta -> | ||||||
| // 4. Update the position based on the movement delta | ||||||
| offset += delta | ||||||
| } | ||||||
| ), | ||||||
| contentAlignment = Alignment.Center | ||||||
| ) { | ||||||
| Text("Video Preview", color = Color.White, fontSize = 12.sp) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| // [END android_compose_touchinput_scroll_draggable2D_basic] | ||||||
|
|
||||||
|
|
||||||
| // [START android_compose_touchinput_scroll_draggable2D_color_picker] | ||||||
| @Composable | ||||||
| fun ExampleColorSelector( | ||||||
| // [START_EXCLUDE] | ||||||
| hue: Float = 0f, | ||||||
| onColorSelected: (Saturation: Float, Value: Float) -> Unit | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to Kotlin's coding conventions, function parameter names should start with a lowercase letter and use camelCase.
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please address |
||||||
| // [END_EXCLUDE] | ||||||
| ) { | ||||||
| // 1. Maintain the 2D position of the selector in state. | ||||||
| var selectorOffset by remember { mutableStateOf(Offset.Zero) } | ||||||
|
|
||||||
| // 2. Track the size of the background container. | ||||||
| var containerSize by remember { mutableStateOf(IntSize.Zero) } | ||||||
|
|
||||||
| Box( | ||||||
| modifier = Modifier | ||||||
| .size(300.dp, 200.dp) | ||||||
| // Capture the actual pixel dimensions of the container when it's laid out. | ||||||
| .onSizeChanged { containerSize = it } | ||||||
| .clip(RoundedCornerShape(12.dp)) | ||||||
| .background( | ||||||
| brush = remember(hue) { | ||||||
| // Create a simple gradient representing Saturation and Value for the given Hue. | ||||||
| Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f))) | ||||||
| } | ||||||
| ) | ||||||
| ) { | ||||||
| Box( | ||||||
| modifier = Modifier | ||||||
| .size(24.dp) | ||||||
| .graphicsLayer { | ||||||
| // Center the selector on the finger by subtracting half its size. | ||||||
| translationX = selectorOffset.x - (24.dp.toPx() / 2) | ||||||
| translationY = selectorOffset.y - (24.dp.toPx() / 2) | ||||||
| } | ||||||
| // [START_EXCLUDE] | ||||||
| // Configure the shadow | ||||||
| .dropShadow(shape = CircleShape) { | ||||||
| color = Color.Black.copy(alpha = 0.2f) | ||||||
| radius = 4.dp.toPx() | ||||||
| offset = Offset(0f, 2.dp.toPx()) | ||||||
| } | ||||||
| .background(Color.Transparent, CircleShape) | ||||||
| .border(2.dp, Color.White, CircleShape) | ||||||
| // [END_EXCLUDE] | ||||||
| // 3. Configure 2D touch dragging. | ||||||
| .draggable2D( | ||||||
| state = rememberDraggable2DState { delta -> | ||||||
| // 4. Calculate the new position and clamp it to the container bounds | ||||||
| val newX = (selectorOffset.x + delta.x) | ||||||
| .coerceIn(0f, containerSize.width.toFloat()) | ||||||
| val newY = (selectorOffset.y + delta.y) | ||||||
| .coerceIn(0f, containerSize.height.toFloat()) | ||||||
|
|
||||||
| selectorOffset = Offset(newX, newY) | ||||||
| // [START_EXCLUDE] | ||||||
| // Map pixel coordinates to Saturation and Value percentages (0f..1f). | ||||||
| onColorSelected( | ||||||
| newX / containerSize.width, | ||||||
| 1f - (newY / containerSize.height) | ||||||
| ) | ||||||
| // [END_EXCLUDE] | ||||||
| } | ||||||
| ) | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| // [END android_compose_touchinput_scroll_draggable2D_color_picker] | ||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rename to TwoDimensionalScrollSnippets
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated in latest commit.