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
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.

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() {
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.

// 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
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

)
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
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 +69 to +117
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 :)

// [END android_compose_touchinput_scroll_scrollable2D_basic]


// [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


// 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
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?


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
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



// [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
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

// [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]




Loading