İki boyutlu kaydırma: scrollable2D, draggable2D

Jetpack Compose'da scrollable2D ve draggable2D, işaretçi girişini iki boyutta işlemek için tasarlanmış düşük düzeyli değiştiricilerdir. Standart 1 boyutlu değiştiriciler scrollable ve draggable tek bir yönle sınırlıyken 2 boyutlu varyantlar, hareketi hem X hem de Y ekseninde aynı anda izler.

Örneğin, mevcut scrollable değiştiricisi tek yönlü kaydırma ve fırlatma için kullanılırken scrollable2d, 2D'de kaydırma ve fırlatma için kullanılır. Bu sayede, elektronik tablolar veya resim görüntüleyiciler gibi her yöne hareket eden daha karmaşık düzenler oluşturabilirsiniz. scrollable2d değiştiricisi, 2D senaryolarda iç içe kaydırmayı da destekler.

Şekil 1. Haritada iki yönlü kaydırma.

scrollable2D veya draggable2D arasından birini seçin.

Doğru API'yi seçmek, taşımak istediğiniz kullanıcı arayüzü öğelerine ve bu öğeler için tercih edilen fiziksel davranışa bağlıdır.

Modifier.scrollable2D: İçindeki içeriği taşımak için bir kapsayıcıda bu değiştiriciyi kullanın. Örneğin, kapsayıcının içeriğinin hem yatay hem de dikey yönde kaydırılması gereken haritalar, e-tablolar veya fotoğraf görüntüleyicilerle birlikte kullanın. Kaydırma işleminden sonra içeriğin hareket etmeye devam etmesini sağlayan yerleşik hızlı kaydırma desteği içerir ve sayfadaki diğer kaydırma bileşenleriyle koordineli çalışır.

Modifier.draggable2D: Bir bileşeni taşımak için bu değiştiriciyi kullanın. Bu, hafif bir değiştiricidir. Dolayısıyla hareket, kullanıcının parmağı durduğunda tam olarak durur. Fling desteği yoktur.

Bir bileşeni sürüklenebilir hale getirmek istiyorsanız ancak hızlıca kaydırma veya iç içe kaydırma desteğine ihtiyacınız yoksa draggable2D kullanın.

2D değiştiricileri uygulama

Aşağıdaki bölümlerde, 2D değiştiricilerin nasıl kullanılacağını gösteren örnekler verilmiştir.

Modifier.scrollable2D uygulayın

Kullanıcının içeriği her yöne taşıması gereken kapsayıcılar için bu değiştiriciyi kullanın.

2D hareket verilerini yakalama

Bu örnekte, ham 2D hareket verilerinin nasıl yakalanacağı ve X,Y uzaklığının nasıl gösterileceği açıklanmaktadır:

@Composable
private 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()
            // ...
        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
                    }
                )
                // ...
            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()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

Şekil 2. Kullanıcı, işaretçiyi yüzeyinde sürüklerken mevcut X ve Y koordinat kaymalarını izleyip görüntüleyen mor bir kutu.

Önceki snippet şunları yapar:

  • Kullanıcının kaydırdığı toplam mesafeyi tutan bir durum olarak offset değerini kullanır.
  • rememberScrollable2DState içinde, kullanıcının parmağıyla oluşturulan her delta değerini işlemek için bir lambda işlevi tanımlanır. offset.value += delta kodu, manuel durumu yeni konumla günceller.
  • Text bileşenleri, kullanıcının sürüklemesiyle gerçek zamanlı olarak güncellenen offset durumunun geçerli X ve Y değerlerini gösterir.

Geniş bir görüntü alanını kaydırma

Bu örnekte, yakalanan 2D kaydırılabilir verilerin nasıl kullanılacağı ve üst kapsayıcısından daha büyük olan içeriklere translationX ve translationY özelliklerinin nasıl uygulanacağı gösterilmektedir:

@Composable
private fun Panning2DImage() {

    // 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
            // ...
            // 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
        )
    }
}

Şekil 3. Modifier.scrollable2D ile oluşturulmuş, iki yönlü kaydırma özelliğine sahip bir resim görüntü alanı.
Şekil 4. Modifier.scrollable2D ile oluşturulmuş, iki yönlü kaydırma metni görüntü alanı.

Yukarıdaki snippet şunları içerir:

  • Kapsayıcı sabit bir boyuta (600x400dp) ayarlanmışken içeriğe, üst öğe boyutuna göre yeniden boyutlandırılmasını önlemek için çok daha büyük bir boyut (1200x800dp) veriliyor.
  • Kapsayıcıdaki clipToBounds() değiştiricisi, 600x400 kutusunun dışında kalan büyük içeriğin herhangi bir bölümünün görünümden gizlenmesini sağlar.
  • LazyColumn gibi üst düzey bileşenlerin aksine, scrollable2D içerikleri sizin için otomatik olarak taşımaz. Bunun yerine, offset dönüşümlerini veya düzen kaymalarını kullanarak izlenen offset öğesini içeriğinize uygulamanız gerekir.graphicsLayer
  • graphicsLayer bloğunda, translationX = offset.value.x ve translationY = offset.value.y, parmağınızın hareketine göre resmin veya metnin çizim konumunu değiştirerek kaydırma görsel efekti oluşturur.

scrollable2D ile iç içe kaydırmayı uygulama

Bu örnekte, çift yönlü bir bileşenin, dikey bir haber feed'i gibi standart tek boyutlu bir üst öğeye nasıl entegre edilebileceği gösterilmektedir.

İç içe kaydırmayı uygularken aşağıdaki noktaları göz önünde bulundurun:

  • rememberScrollable2DState için lambda, alt öğe sınırına ulaştığında üst listenin doğal olarak devralmasına olanak tanımak için yalnızca tüketilen delta değerini döndürmelidir.
  • Kullanıcı çapraz hızlı kaydırma yaptığında 2D hız paylaşılır. Çocuk, animasyon sırasında bir sınıra ulaşırsa kaydırmanın doğal bir şekilde devam etmesi için kalan momentum üst öğeye aktarılır.

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    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(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        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
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\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)
        )
    }
}

Şekil 5. Dikey kaydırma listesinde yer alan ve dahili 2D harekete izin veren ancak kutunun dahili Y uzaklığı 300 piksel sınırına ulaştığında dikey kaydırma kontrolünü üst listeye aktaran mor bir kutu.

Önceki snippet'te:

  • 2D bileşen, X ekseni hareketini tüketerek dahili olarak kaydırabilir. Aynı zamanda, alt öğenin kendi dikey sınırlarına ulaşıldığında Y ekseni hareketini üst listeye gönderebilir.
  • Sistem, kullanıcıyı 2D yüzeyde tutmak yerine tüketilen delta değerini hesaplar ve kalanı hiyerarşide yukarıya geçirir. Bu sayede kullanıcı, parmağını kaldırmadan sayfanın geri kalanını kaydırmaya devam edebilir.

Modifier.draggable2D uygulayın

Ayrı kullanıcı arayüzü öğelerini taşımak için draggable2D değiştiricisini kullanın.

Bir composable öğesini sürükleyin

Bu örnekte, draggable2D için en yaygın kullanım alanı gösterilmektedir. Bu kullanım alanı, kullanıcının bir kullanıcı arayüzü öğesini alıp üst kapsayıcı içinde herhangi bir yere yeniden konumlandırmasına olanak tanır.

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 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)
        }
    }
}

Şekil 6. Gri bir arka planda yeniden konumlandırılan küçük bir mor kutu. Bu kutu, kullanıcının parmağı kaldırıldığı anda hareket etmeyi bırakan doğrudan 2D sürüklemeyi gösteriyor.

Yukarıdaki kod snippet'i şunları içerir:

  • offset durumu kullanarak kutunun konumunu izler.
  • Sürükleme farklarına göre bileşenin konumunu değiştirmek için offset değiştiricisini kullanır.
  • Fling desteği olmadığından kullanıcı parmağını kaldırdığı anda kutu hareket etmeyi durdurur.

Ebeveynin sürükleme alanına göre bir alt composable'ı sürükleme

Bu örnekte, bir seçici düğmesinin belirli bir yüzeyle sınırlandırıldığı 2D giriş alanı oluşturmak için draggable2D özelliğinin nasıl kullanılacağı gösterilmektedir. Bileşenin kendisini hareket ettiren sürüklenebilir öğe örneğinin aksine, bu uygulama, bir alt composable öğesi olan "seçiciyi" bir renk seçicide hareket ettirmek için 2D delta değerlerini kullanır:

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 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)
                }
                // ...
                // 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)
                    }
                )
        )
    }
}

Şekil 7. 2D delta değerlerinin seçili renk değerlerini güncellemek için kapsayıcının sınırlarına nasıl sabitlendiğini gösteren, herhangi bir yöne sürüklenebilen beyaz dairesel seçici düğmeli bir renk gradyanı.

Yukarıdaki snippet şunları içerir:

  • Gradyan kapsayıcının gerçek boyutlarını yakalamak için onSizeChanged değiştiricisini kullanır. Seçici, kenarların tam olarak nerede olduğunu bilir.
  • graphicsLayer içinde, seçicinin sürüklenirken ortalanmış kalmasını sağlamak için translationX ve translationY ayarlanır.