Pantrix UI

Pencerenin başlığı

Compose Multiplatform'da tek pencereli, çok ekranlı bir uygulamanın penceresinin neden hep aynı adı taşıdığı, kütüphanenin bunun için ne verdiği ve bunun nasıl sınandığı. Sorun Pantrix'e özgü değildir: web'de ve masaüstünde çalışan her Compose Multiplatform uygulamasında aynı şekilde görülür.

Compose hakkında burada söylenenler Compose Multiplatform 1.12.2'nin kaynaklarında (Window.desktop.kt, ComponentUpdater.kt, LocalAwtWindow.kt, ComposeWindowPanel.desktop.kt) okunmuştur. Doğrulanamayanlar 7. bölümde ayrıca sayılır.

1. Sorun

Belirti

Neden

Pencerenin başlığı ortak kodda yoktur. Her platformda başka bir nesnenin alanıdır:

PlatformPencerenin başlığıOrtak koddan
Web (js, wasmJs)document.title: sekmenin ve geçmişin adıYok; kotlinx.browser.document yalnızca web kaynak kümesinde
Masaüstü (JVM)java.awt.Frame.title: başlık çubuğu, görev çubuğuYok; pencereye LocalAwtWindow ile ulaşılır, o da @ExperimentalComposeUiApi
AndroidKullanıcının gördüğü bir pencere başlığı yok: son uygulamalar uygulamanın adını söylerGerek yok
iOSKullanıcının gördüğü bir pencere başlığı yok: uygulama değiştirici uygulamanın adını söylerGerek yok

İki ayrıntı işi büyütür:

  1. Aynı anda birden çok ekran vardır. Bir sayfa ötekinin üstüne kayarken ikisi de kompozisyondadır; geniş bir pencerede bir liste ve ayrıntısı yan yana durur. "Ekran açılınca başlığı yaz, kapanınca eskisine dön" yetmez: ikinci ekran açılır, birincisi kapanırken başlığı en baştakine geri koyar ve açık ekranın adı kaybolur.

  2. Masaüstünde Window da başlık yazar. Window(title = …) başlığı yalnızca kendi değeri değişince yeniden yazar (ComponentUpdater öncekiyle karşılaştırır); değişmedikçe bir ekranın verdiği başlık kalır, değişirse onun üstüne yazılır.

2. Çözüm

Her pencere için bir başlık yığını tutulur. Bir ekran kompozisyona girince başlığını yığının sonuna koyar, çıkınca kendi başlığını yığından çıkarır. Pencereye yığının sonundaki ad verilir; yığın boşalınca pencerenin kendi başlığı, ilk ekran gelmeden önce okunan, geri konur. Bir ekranın başlığı değişirse, o ekran yığının sonundaysa pencere de değişir. Uygulama adı bir biçimle eklenir: ekranın adını alıp pencereninkini veren bir fonksiyon, uygulamanın kökünde bir kez verilir.

3. Pantrix ile

import com.pantrixui.foundation.platform.LocalPantrixWindowTitleFormat
import com.pantrixui.foundation.platform.PantrixWindowTitle
import com.pantrixui.foundation.platform.PantrixWindowTitleFormat

// Uygulamanın kökünde, bir kez: her ekranın adının ardına uygulamanın adı.
CompositionLocalProvider(LocalPantrixWindowTitleFormat provides PantrixWindowTitleFormat { "$it · Yönetim" }) {
    App()
}

// Bir ekranda: pencere bu ekran gösterildikçe "Siparişler · Yönetim" adını taşır.
@Composable
fun OrdersScreen() {
    PantrixWindowTitle("Siparişler")
    OrdersList()
}

3.1. İç içe sayfalar

Bir sayfanın içeriğindeki başka bir sayfa, bir sayfanın önizlemesi ya da bir örneği gibi, pencereye ad vermez: başlığı içinde durduğu sayfaya bırakır. Yan yana duran iki sayfa, bir liste ve ayrıntısı, iç içe değildir; ikisi de ad verir ve en son gelen kazanır.

4. Pantrix olmadan

4.1. Ortak kod (commonMain)

/** Bir pencerenin başlıkları: en son verilen önce. */
class WindowTitles(private val read: () -> String, private val write: (String) -> Unit) {
    private val given = mutableListOf<Entry>()
    private var own: String? = null

    inner class Entry(title: String) {
        var title: String = title
            set(value) {
                if (field == value) return
                field = value
                if (given.lastOrNull() === this) write(value)
            }

        fun add() {
            if (given.isEmpty()) own = read()
            given.remove(this)
            given += this
            write(title)
        }

        fun remove() {
            val wasLast = given.lastOrNull() === this
            if (!given.remove(this) || !wasLast) return
            val before = given.lastOrNull()
            if (before != null) write(before.title) else own?.let(write).also { own = null }
        }
    }
}

@Composable
expect fun windowTitles(): WindowTitles?

@Composable
fun WindowTitle(title: String) {
    val titles = windowTitles() ?: return
    val entry = remember(titles) { titles.Entry(title) }
    DisposableEffect(entry) {
        entry.add()
        onDispose { entry.remove() }
    }
    SideEffect { entry.title = title }
}

4.2. Platform karşılıkları

// webMain: sayfanın tek bir başlığı vardır.
private val documentTitles = WindowTitles(read = { document.title }, write = { document.title = it })

@Composable
actual fun windowTitles(): WindowTitles? = documentTitles
// jvmMain: her pencerenin kendi başlıkları, pencere bırakılınca onunla gider.
private val frameTitles = WeakHashMap<Frame, WindowTitles>()

@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun windowTitles(): WindowTitles? {
    val frame = LocalAwtWindow.current as? Frame ?: return null
    return remember(frame) {
        synchronized(frameTitles) {
            frameTitles.getOrPut(frame) { WindowTitles(read = { frame.title.orEmpty() }, write = { frame.title = it }) }
        }
    }
}
// androidMain ve iosMain: kullanıcıya görünen bir pencere başlığı yok.
@Composable
actual fun windowTitles(): WindowTitles? = null

5. Test etmek

Yığının kuralları platformsuzdur ve her hedefte sınanır: en son verilen ad kazanır, çıkan ad öncekine yer bırakır, son ad gidince pencerenin kendi başlığı döner, sonda olmayan bir adın değişmesi pencereye dokunmaz.

val titles = WindowTitles(read = { window }, write = { window = it })
val orders = titles.Entry("Siparişler")
val order = titles.Entry("Sipariş 42")
orders.add()
order.add()
assertEquals("Sipariş 42", window)
order.remove()
assertEquals("Siparişler", window)

Web'de gerçek bir tarayıcıda document.title okunur: bir ekran onu biçimiyle yazar, başlığı değişince değiştirir, kompozisyondan çıkınca sayfanın kendi <title>'ını geri koyar. Masaüstünün testleri bir pencere açmaz (LocalAwtWindow testte null'dır): orada PantrixWindowTitle bir şey yapmaz; sayfanın JVM testleri onu böyle çağırır.

6. Tuzaklar

7. Doğrulama

Testler

NeNeredeHedef
Yığının kurallarıui/foundation/src/commonTest/…/platform/WindowTitlesTest.ktJVM, iOS, JS, Wasm
Tarayıcının sekmesiui/foundation/src/webTest/…/platform/WindowTitleOnTheWebTest.ktJS, Wasm
Sayfanın sekmeye ad vermesi, iç içe sayfanın vermemesiui/components/src/webTest/…/PageScaffoldOnTheWebTest.ktJS, Wasm

Elle bozulan davranışlar

Web'in karşılığı null döndürünce iki tarayıcı testi de kırmızı oldu; sayfa iç içe olduğuna bakmadan pencereye ad verince sayfanın tarayıcı testi kırmızı oldu.

Doğrulanmayanlar

8. Bu depoda nerede