Pantrix UI

Sorting by a language’s alphabet

Why a list of names is not sorted by the language's alphabet in the common code of Compose Multiplatform, what the library gives for it, and how it is tested. The problem is not Pantrix's own: it shows the same way in every Kotlin Multiplatform project that sorts names, cities or titles in its common code with sorted() or sortedBy { it.name }.

Which of what is said here was seen in a test that runs in this repository, and which was not verified, is written out in section 7.

1. The problem

Symptom

listOf("zeytin", "şeker", "öğle", "iğne", "ılık", "çilek", "Çay", "cam").sorted()
// [cam, iğne, zeytin, Çay, çilek, öğle, ılık, şeker]

Cause

In Kotlin, comparing two texts (String.compareTo) looks at the letters' numbers in Unicode. This is an order that gives the same result on every platform, is fast, and is the alphabet of no language: the capitals' numbers are smaller than the small letters', and ç (231), ö (246), ş (351) and ı (305) are greater than z (122).

Sorting by a language's alphabet is a separate job, called "collation", and Kotlin's common library has nothing for it. Each platform has a tool of its own:

PlatformToolReached from common code
Androidjava.text.Collator (with ICU behind it)No, androidMain
Desktop (JVM)java.text.CollatorNo, jvmMain
iOSNSString.compare(_:options:range:locale:)No, iosMain
Web (js, wasmJs)Intl.CollatorNo, webMain

Neither of the two short cuts is enough:

  1. Comparing with lowercase(). It solves the capitals, not the alphabet: ç still comes after z.

  2. String.CASE_INSENSITIVE_ORDER. On the JVM it counts İ and i as equal; in JS, in Wasm and on iOS it does not: the same list is sorted differently on the desktop and in a browser.

2. The solution

Putting each platform's own tool behind one Comparator<String>: an expect in common code, and its counterpart in four source sets.

Three decisions are kept the same on every platform, so that a list does not change from platform to platform:

3. With Pantrix

PantrixLocale.textOrder() gives the comparator of that language; rememberTextOrder() keeps the theme's language's in a composition.

// In common code, outside a composition
val order = PantrixLocale.Turkish.textOrder()
val sorted = customers.sortedWith(compareBy(order) { it.name })

// In a composition: by the theme's language, the same object while the language does not change
val order = rememberTextOrder()
val sorted = remember(customers, order) { customers.sortedWith(compareBy(order) { it.name }) }

PantrixDataTable's text column takes it directly:

val order = rememberTextOrder()
val columns = remember(order) {
    listOf(
        DataTableColumn.text<Customer>(key = "name", header = name, sortable = true, textOrder = order) { it.name }
    )
}
PantrixDataTable(rows = remember(customers, sort) { customers.sortedWith(sort.comparator(columns)) }, columns = columns)

A text column given no textOrder sorts as before, by the letters' numbers and without telling capitals apart.

Keep the comparator. Setting up a comparator is not free, and a sort asks it thousands of times. Outside a composition it is kept in a field, inside one with rememberTextOrder(); it is not set up again for each comparison.

4. Without Pantrix

Four files. In common code:

// commonMain
expect fun textOrder(languageTag: String): Comparator<String>

On Android and on the desktop (the same file in two source sets):

// androidMain, jvmMain
actual fun textOrder(languageTag: String): Comparator<String> {
    val collator = Collator.getInstance(Locale.forLanguageTag(languageTag))
    collator.strength = Collator.SECONDARY // tells accents apart, not capitals
    return Comparator { first, second -> collator.compare(first, second) }
}

On iOS:

// iosMain
@OptIn(ExperimentalForeignApi::class)
actual fun textOrder(languageTag: String): Comparator<String> {
    val locale = NSLocale(localeIdentifier = languageTag)
    return Comparator { first, second ->
        val whole = NSMakeRange(0u, first.length.toULong())
        @Suppress("CAST_NEVER_SUCCEEDS")
        (first as NSString).compare(second, options = NSCaseInsensitiveSearch, range = whole, locale = locale).toInt()
    }
}

On the web, in the source set js and wasmJs share:

// webMain
actual fun textOrder(languageTag: String): Comparator<String> =
    Comparator { first, second -> compareInLanguage(first, second, languageTag) }

@OptIn(ExperimentalWasmJsInterop::class)
private fun compareInLanguage(first: String, second: String, languageTag: String): Int = js(
    """(function () {
        try {
            var kept = globalThis.__textOrders || (globalThis.__textOrders = {});
            var order = kept[languageTag] ||
                (kept[languageTag] = new Intl.Collator(languageTag, { sensitivity: 'accent' }));
            return order.compare(first, second);
        } catch (refused) {
            return first < second ? -1 : (first > second ? 1 : 0);
        }
    })()"""
)

Two details on the web:

5. What the helper does not do

6. Testing

What is to be tested is that the platform's tool really knows the rules of the language; that is why the test has to run on every target. Turkish is a good test: all six of ç, ğ, ı, ö, ş, ü fall in the wrong place by the letters' numbers.

@Test
fun turkish_texts_are_in_the_order_of_the_turkish_alphabet() {
    val words = listOf("zeytin", "şeker", "öğle", "iğne", "ılık", "çilek", "Çay", "cam")
    assertEquals(
        listOf("cam", "Çay", "çilek", "ılık", "iğne", "öğle", "şeker", "zeytin"),
        words.sortedWith(PantrixLocale.Turkish.textOrder())
    )
}

Do not test the equality of capitals and small letters with the Turkish "I" and "ı": the platforms' mapping of capitals can part on that pair. "Çay" and "çay" are equal on every platform.

7. Verification

What was seen in the tests that run in this repository (2026-10-11, TextOrderTest, TextOrderOnAndroidTest):

WhatJVMiOS simulatorJS (headless Chrome)Wasm (headless Chrome)Android (host)
Eight Turkish words in the order of the alphabet✅✅✅✅✅
The Turkish alphabet's 29 letters in order✅✅✅✅
"ada" and "Ada" equal, the list stable✅✅✅✅✅
"resume" before "résumé"✅✅✅✅✅
A tag it does not know gives no error✅✅✅✅✅

Not verified:

8. Where it is in this repository

FileWhat
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/l10n/TextOrder.ktPantrixLocale.textOrder(), rememberTextOrder()
ui/foundation/src/{androidMain,jvmMain,iosMain,webMain}/kotlin/com/pantrixui/foundation/l10n/TextOrder.*.ktThe platforms' counterparts
ui/foundation/src/commonTest/kotlin/com/pantrixui/foundation/l10n/TextOrderTest.ktThe tests that run on four targets
ui/foundation/src/androidHostTest/kotlin/com/pantrixui/foundation/l10n/TextOrderOnAndroidTest.ktAndroid's source set
ui/components/src/commonMain/kotlin/com/pantrixui/components/data/datatable/DataTableColumn.ktDataTableColumn.text(textOrder = …)