Pantrix UI

Bir çubuğun altında kalan odak

Compose Multiplatform'da bir bar'ın altından kayan içerikte klavye odağının neden bar'ın arkasında kaybolduğu, bunun için kütüphanenin ne verdiği ve nasıl sınandığı. Sorun Pantrix'e özgü değildir: içeriğini bir alt bar'ın, bir üst bar'ın ya da sistem çubuğunun altından kaydıran her Compose uygulamasında aynı şekilde görülür.

Burada söylenenlerin hangisinin cihazda görüldüğü, hangisinin Compose Multiplatform 1.12.1'in kaynağında okunduğu ve hangisinin doğrulanmadığı 7. bölümde ayrı ayrı yazılıdır.

1. Sorun

Belirti

Neden

Compose odağı alan öğeyi görünür alana getirir: öğe bir istek gönderir, isteği yolundaki her kaydırıcı sırayla karşılar ve öğeyi kendi kenarına kadar kaydırır, fazlasını değil. Kaydırıcının "görünür" saydığı, kendi boyunun tamamıdır. İçerik boşluğu (bir lazy listenin contentPadding'i ya da kaydırmanın içindeki bir padding) o alanın parçasıdır; kaydırıcı onu bilmez. Bar'ın altına uzanan bir listenin alt kenarı bar'ın arkasıdır.

Küçük kardeşi: kütüphanenin odak halkası öğenin dışına çizilir. Kaydırıcı öğeyi kenarına getirince halkanın bir kenarı kaydırma alanının dışında kalır ve kesilir.

2. Çözüm

Önce soru: içerik bar'ın altından kaymak zorunda mı?

DurumYol
Bar opak ve arkasında görünecek bir şey yokBoşluk içeriğe değil kaydırıcının kendisine verilir: LazyColumn(Modifier.padding(padding)). Kaydırma alanı bar'da biter, kenarı bar'ın üstüdür; sorun kalmaz. Hiçbir API gerekmez.
İçerik bar'ın altından geçmeli (yarı saydam bar, bar'ı olmayan kenarda sistem çubuğunun arkası)İçerik, sonlarında ne kadar boşluk bıraktığını söyler; görünür alana getirme isteği o kadar büyütülür. Aşağıdaki budur.

İkinci yolun parçası tektir: içeriğin üstünde duran ve geçen her isteği boşluk kadar büyüten bir modifier. Öğe "beni göster" der; modifier bunu "beni ve altımdaki 64dp'yi göster" yapar; kaydırıcı öğeyi kenarından 64dp içeride, bar'ın üstünde bırakır. Compose bunun için herkese açık ve kararlı bir arayüz verir: BringIntoViewModifierNode (androidx.compose.ui.relocation).

Dört ayrıntı:

AyrıntıNe yapılır
1Kendisinde olandan fazlası istenen kaydırıcı kaydırmaz ve artanı kullanıcının sürüklemesi olarak üstündekine geçirir: içinde durduğu bir sheet oynar, çekip yenilenen bir liste işaretini gösterirİçeriğin uzandığından fazlası istenmez. Kaydırılan içerik boyundan tanınır (yerinden büyüktür) ve istek onun sınırında kesilir
2Lazy liste öğelerini tek tek dizer: içeriğin nereye kadar uzandığı bir öğeden bilinemezModifier her öğeye konur ve boşluğun tamamı istenir; listenin contentPadding'i en az o kadar olmalıdır
3Klavye açıkken kaydırma alanı klavyenin üstünde biter (imePadding) ve alt bar klavyenin arkasındadır: alanı örtmezBoşluktan klavyenin payı düşülür (padding - WindowInsets.ime.asPaddingValues()); düşülmezse ilk tuşta sayfa bar'ın boyu kadar yukarı sıçrar
4Odak halkası öğenin dışındadırBir sütunda ya da satırda halka yerini kendisi ister. Lazy listede isteyemez: liste halkanın yerini contentPadding'ine katar ve öğelerine aynı değeri söyler

Neden BringIntoViewSpec değil

Compose'un bu iş için görünen aracı LocalBringIntoViewSpec'tir: kaydırıcıya "alanının şu kadarı görünür sayılmaz" denir. Demo'nun ilk çözümü oydu; üç sebeple bırakıldı:

Bir üstünlüğü vardır: kaydırma alanı küçülürken (klavye açılırken) odaktaki öğeyi görünür tutan Compose'un kendi izlemesi de o kuralı kullanır. Modifier yalnızca istekleri büyütür; 5. bölümde yazılı.

3. Pantrix ile

Kütüphane tek bir modifier verir (com.pantrixui.foundation.a11y):

public fun Modifier.scrollPadding(padding: PaddingValues): Modifier

Boşluk eklemez, içeriğin bıraktığı boşluğu söyler. Kaydırılanın üstüne, kaydırıcının içine konur.

3.1. Kayan bir sütun ya da satır

Kaydırmadan sonra, sözünü ettiği padding'den önce:

PantrixScaffold(bottomBar = { insets -> AppTabs(Modifier.padding(insets)) }) { padding ->
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .scrollPadding(padding)
            .padding(padding)
    ) {
        // …
    }
}

Burada fazlası istenemez: içerik boyundan tanınır ve istek onun sınırında kesilir. İçerikte o boşluk yoksa en sondaki öğe kenara kadar gelir, üstündekine hiçbir şey geçmez. Odak halkası yerini kendisi ister; içeriğin sonunda bar'ın payından sonra bir halkalık yer daha varsa (sayfaların çoğunda kendi kenar boşluğu vardır) son öğenin halkası da bütündür.

Yana kayan bir satırda padding'in başı ve sonu yazı yönüne göre okunur. Demo'nun yana kayan çerçevesi iki ucunda bir halkanın yerini bırakır ve bunu böyle söyler:

val ring = PaddingValues(horizontal = PantrixTheme.platform.focusRingReach)
Box(Modifier.horizontalScroll(state).scrollPadding(ring).padding(ring)) { content() }

3.2. Lazy liste

Her öğeye; değer, listenin contentPadding'idir:

PantrixScaffold(bottomBar = { insets -> AppTabs(Modifier.padding(insets)) }) { padding ->
    // Bar'ların payı ve, üstünde ve altında, bir halkanın yeri.
    val room = padding + PaddingValues(vertical = PantrixTheme.platform.focusRingReach)
    LazyColumn(contentPadding = room) {
        items(orders, key = { it.id }) { order ->
            OrderRow(order, Modifier.scrollPadding(room))
        }
    }
}

Öğesine konmayan bir satır eskisi gibi davranır: kenara gelir. Listede contentPadding söylenenden azsa listeden olmayan istenir (2. bölüm, 1. ayrıntı): iki değer aynı değişkenden gelmelidir. Izgaralarda (LazyVerticalGrid) kullanım aynıdır.

3.3. Klavye açıkken

Metin alanı olan ekran kaydırma alanını klavyenin üstünde bitirir. O sırada alt bar klavyenin arkasındadır; boşluktan klavyenin payı düşülür. Compose'un PaddingValues çıkarması bunu yapar: sıfırın altına inmez ve değeri sorulduğu anda okur, klavye oynarken ekran yeniden kurulmaz.

Column(
    Modifier
        .imePadding()
        .verticalScroll(scroll)
        .scrollPadding(padding - WindowInsets.ime.asPaddingValues())
        .padding(padding)
)

Demo'nun sayfası böyledir (App.kt, DemoPage).

3.4. İçindeki kaydırıcılar

Onlara dokunulmaz. Sütunun içindeki yana kayan bir satır öğesini kendi kenarına getirir, her zamanki gibi; ondan çıkan istek sütunun modifier'ından geçer ve sütun satırı bar'ın üstünde gösterir. Bir metin alanının imleci kendi kutusunda olduğu gibi kalır.

4. Pantrix olmadan

Bir modifier ve onun node'u. Kararlı API'lerle yazılır; expect/actual gerekmez.

fun Modifier.scrollPadding(padding: PaddingValues): Modifier = this then ScrollPaddingElement(padding)

private data class ScrollPaddingElement(val padding: PaddingValues) : ModifierNodeElement<ScrollPaddingNode>() {
    override fun create() = ScrollPaddingNode(padding)

    override fun update(node: ScrollPaddingNode) {
        node.padding = padding
    }
}

private class ScrollPaddingNode(var padding: PaddingValues) :
    Modifier.Node(),
    BringIntoViewModifierNode {
    override suspend fun bringIntoView(
        childCoordinates: LayoutCoordinates,
        boundsProvider: () -> Rect?
    ) {
        // İstek üstteki kaydırıcıya bu node'un kendi koordinatlarında ve büyütülmüş olarak gider.
        bringIntoView {
            val asked = if (isAttached && childCoordinates.isAttached) boundsProvider() else null
            asked?.let { rect ->
                val own = requireLayoutCoordinates()
                val here = rect.translate(own.localBoundingBoxOf(childCoordinates, clipBounds = false).topLeft)
                val direction = requireLayoutDirection()
                val grown = with(requireDensity()) {
                    Rect(
                        left = here.left - padding.calculateLeftPadding(direction).roundToPx(),
                        top = here.top - padding.calculateTopPadding().roundToPx(),
                        right = here.right + padding.calculateRightPadding(direction).roundToPx(),
                        bottom = here.bottom + padding.calculateBottomPadding().roundToPx()
                    )
                }
                // Kayan bir sütunun içeriğindeyken: içeriğin uzandığından fazlası istenmez. Lazy bir listenin
                // öğesinde bu satır çıkarılır; orada sınırı listenin contentPadding'i tutar.
                grown.intersect(Rect(0f, 0f, own.size.width.toFloat(), own.size.height.toFloat()))
            }
        }
    }
}

BringIntoViewModifierNode ve bringIntoView: androidx.compose.ui.relocation; requireLayoutCoordinates, requireLayoutDirection, requireDensity: androidx.compose.ui.node.

Bu yalın hâlde iki kullanım iki ayrı modifier'dır (sınırında kesen ve kesmeyen). Pantrix'inki tektir: kaydırılan içeriği boyundan bulur, bulduğu yerde keser, bulamadığı yerde (lazy liste, kaydırıcısına sığan içerik) boşluğun tamamını ister (RoomInView.kt, withRoom).

İçteki lambda'da isAttached'e bakılır: istek, kaydırıcı onun için kaydırdığı sürece her karede yeniden sorulur ve o sırada modifier kompozisyondan çıkmış olabilir; requireLayoutCoordinates() bağlı olmayan bir node'da hata fırlatır.

5. Modifier'ın yapmadıkları

6. Test etmek

7. Doğrulama

Görülenler

Testler

Kaynakta okunanlar

Compose Multiplatform 1.12.1, foundation ve ui:

Doğrulanmayanlar

8. Bu depoda nerede

NeNerede
Modifierui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/a11y/ScrollPadding.kt
Kaydırılan içeriği bulan ve isteği sınırında kesen hesapaynı pakette RoomInView.kt (withRoom); odak halkası da onu kullanır (FocusRing.kt)
Bir halkanın taştığı kadarPantrixPlatform.focusRingReach (Platform.kt)
TestlerScrollPaddingTest, FocusRingInViewTest (ui/foundation/src/commonTest/.../a11y)
Demo'nun sayfasıapp/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/App.kt (DemoPage)
Demo'nun yana kayan çerçevesiapp/shared/.../demo/page/HorizontalScrollFrame.kt
Demo'da örnekScaffold sayfasında "Bar'ın altından kayan listede odak"