Pantrix UI

Dosya seçmek, bırakmak ve kaydetmek

Bu belge, Compose Multiplatform'da kullanıcının uygulamaya bir dosya vermesinin (platformun seçicisinden seçerek ya da bir bölgenin üstüne sürükleyip bırakarak) neden ortak kodda doğrudan yapılamadığını, kütüphanenin bunun için ne verdiğini, dosyanın nasıl okunduğunu ve bunun nasıl sınandığını açıklar; uygulamanın yaptığı bir dosyayı kullanıcıya kaydettirmesini de. Sorun Pantrix'e özgü değildir: kullanıcıdan dosya alan ya da ona dosya veren her Compose Multiplatform projesinde aynı şekilde görülür.

Compose hakkında burada söylenen her şey Compose Multiplatform 1.12.1'in kaynaklarında okunmuş ya da bu depoda koşan bir testte, bir cihazda veya tarayıcıda görülmüştür; hangisinin nerede görüldüğü 7. ve 8. bölümlerde yazılıdır.

1. Sorun

Belirti

Neden

Compose'un bırakma olayı her platformda kendi türünü sarar (Compose Multiplatform 1.12.1, ui modülü):

PlatformDragAndDropEvent neyi taşırDosyalar nasıl okunur
AndroidtoAndroidDragEvent(): bir DragEventclipData'nın öğelerindeki içerik Uri'leri. Başka bir uygulamanınkiler ancak bırakma sürerken Activity.requestDragAndDropPermissions(event) istenirse okunabilir.
iOSitems: UIDragItem'lar (@ExperimentalComposeUiApi)Her öğenin itemProvider'ı dosyasını loadFileRepresentation… ile yükler. Verdiği dosya yükleme işleyicisi dönünce silinir: içinde kopyalanmalıdır.
MasaüstüdragData(): bir DragData (@ExperimentalComposeUiApi)DragData.FilesList.readFiles(): diskteki dosyaların adresleri (file:/…).
WebtransferData?.domDataTransferOrNull: tarayıcının DataTransfer'ıfiles. Tarayıcı bunları yalnızca drop olayının içinde verir: sonradan okunamaz.

Seçiciler için ortak bir şey hiç yoktur; her biri platformun kendi koduyla açılır ve sonucu kendi türünde verir.

2. Çözüm: beş parça

Foundation'da (com.pantrixui.foundation.files):

  1. PantrixFile: ad, boyut (bilinmiyorsa null), MIME türü ve suspend fun readBytes(). Hiçbir şey readBytes çağrılana kadar okunmaz. Platformun kendi türü de oradadır: Android'de uri, iOS'ta url, masaüstünde file, web'de domFile. Testler ve uygulamanın kendi ürettiği dosyalar için bellekten bir kurucusu vardır: PantrixFile(name, bytes, mimeType).

  2. PantrixFileType: bir MIME türü (alt türü * olabilir) ve uzantılar. Hazırları: Any, Images, Videos, Audio, Pdf, Text. Bir dosya MIME türü uyduğunda ya da uzantısı listede olduğunda türe uyar; platformun tür söylemediği bir dosya da adıyla tanınır.

  3. FilePicker ve rememberFilePicker(): pick(FilePickRequest(accept, multiple)) { files -> … }. Platformun belge seçicisini açar; yalnızca görsel istenince (FilePickRequest.imagesOnly) Android ve iOS fotoğraf seçicisini açar. Hiçbiri izin istemez. FilePicker tek yöntemli bir arayüzdür: test kendi seçicisini verir. LocalFilePicker bir seçici verdiğinde rememberFilePicker() platformunkinin yerine onu döndürür: bir test ya da önizleme bir ekranın bütün bölgelerine aynı seçiciyi bir kerede verir.

  4. Modifier.fileDropTarget(enabled, onOver) { files -> … }: bırakılanı PantrixFile listesine çevirir. Android'de okuma iznini ister, iOS'ta dosyaları uygulamanın geçici klasörüne kopyalar, web'de dosyaları bırakma sürerken alır. onOver, dosya tutabilecek bir şey üstüne gelince true, ayrılınca false söyler.

  5. FileSaver ve rememberFileSaver(): save(file) { result -> … }. Platformun kaydetme penceresini açar, dosyayı kullanıcının seçtiği yere yazar ve ne olduğunu söyler (FileSaveResult). LocalFileSaver bir ekrana kendi kaydedicisini verir.

Bileşenlerde (com.pantrixui.components.elements.filedrop) bunların üstünde PantrixFileDrop (basınca seçiciyi açan, üstüne bırakılanı alan, gelenleri tür, boyut ve sayıya göre eleyen bölge) ve PantrixFileItem (alınan bir dosyanın satırı) durur.

3. Pantrix ile

3.1. Bir formun ekleri

var attachments by remember { mutableStateOf(emptyList<PantrixFile>()) }
var refused by remember { mutableStateOf(emptyList<RejectedFile>()) }

PantrixFileDrop(
    onFiles = { files -> attachments += files },
    accept = listOf(PantrixFileType.Pdf, PantrixFileType.Images),
    multiple = true,
    maxSize = 10_000_000,
    onRejected = { refused = it },
    supportingText = if (refused.isEmpty()) "PDF ya da görsel, en çok 10 MB" else "Alınmadı: ${refused.first().file.name}",
    error = refused.isNotEmpty(),
    tagKey = "attachments"
)
attachments.forEach { file ->
    PantrixFileItem(name = file.name, size = file.size, onRemove = { attachments -= file })
}

3.2. Dosyayı okumak

scope.launch {
    val bytes = file.readBytes()          // Android, masaüstü ve iOS'ta ana iş parçacığının dışında
    upload(file.name, file.mimeType, bytes)
}

Büyük bir dosyayı akış olarak okumak isteyen platformun kendi türünü kullanır: Android'de contentResolver.openInputStream(file.uri!!), masaüstünde file.file!!.inputStream().

3.3. Bölge olmadan

Kendi tasarımı olan bir uygulama yalnızca foundation'ın parçalarını kullanabilir:

val picker = rememberFilePicker()
var over by remember { mutableStateOf(false) }
Box(
    Modifier
        .fileDropTarget(onOver = { over = it }) { files -> attach(files) }
        .clickable { picker.pick(FilePickRequest(listOf(PantrixFileType.Images))) { attach(it) } }
)

Web'de seçici yalnızca bir kullanıcı basışının içinde açılır: pick bir tıklamadan ya da bir tuştan çağrılmalıdır.

3.4. Dosya kaydettirmek

Uygulamanın yaptığı bir dosyayı (bir tema dosyası, bir rapor, bir grafiğin resmi) kullanıcıya vermek seçmenin tersidir: kullanıcı yerini ve adını seçer, uygulama oraya yazar.

val saver = rememberFileSaver()
PantrixButton(
    text = "Temayı indir",
    onClick = {
        saver.save(PantrixFile("Theme.kt", source.encodeToByteArray(), "text/plain")) { result ->
            message = when (result) {
                FileSaveResult.Saved -> "Kaydedildi"
                FileSaveResult.Started -> "İndirme başladı"
                FileSaveResult.Cancelled -> null
                FileSaveResult.Failed -> "Kaydedilemedi"
            }
        }
    },
    tagKey = "download"
)

4. Pantrix olmadan

Aşağıdakiler kütüphanesiz bir projeye kopyalanabilecek en kısa hâllerdir; Pantrix'in kodu (ui/foundation/src/*Main/kotlin/com/pantrixui/foundation/files/) aynı şeyi testleri ve kenar durumlarıyla yapar.

Android, seçici (androidx.activity gerekir; Compose UI onu zaten getirir, ama yalnızca çalışma zamanında: derlemek için açıkça yazılmalıdır):

val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> /* … */ }
launcher.launch(arrayOf("application/pdf", "image/*"))
// Yalnızca görsel: ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest(PickVisualMedia.ImageOnly)

Android, bırakma:

Modifier.dragAndDropTarget(
    shouldStartDragAndDrop = { event -> event.toAndroidDragEvent().clipDescription?.hasMimeType("*/*") == true },
    target = object : DragAndDropTarget {
        override fun onDrop(event: DragAndDropEvent): Boolean {
            val drag = event.toAndroidDragEvent()
            activity.requestDragAndDropPermissions(drag)   // bırakma sürerken
            val uris = (0 until drag.clipData.itemCount).mapNotNull { drag.clipData.getItemAt(it).uri }
            return uris.isNotEmpty()
        }
    }
)

iOS, seçici (asCopy = true: dosya uygulamanın klasörüne kopyalanır, güvenlik kapsamı gerekmez):

val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypePDF), asCopy = true)
picker.delegate = delegate              // UIKit temsilciyi zayıf tutar: bir yerde saklanmalıdır
LocalUIViewController.current.presentViewController(picker, animated = true, completion = null)

Masaüstü, seçici:

val dialog = FileDialog(null as Frame?, "Dosya seçin", FileDialog.LOAD).apply {
    isMultipleMode = true
    filenameFilter = FilenameFilter { _, name -> name.endsWith(".pdf", ignoreCase = true) }
    file = "*.pdf"                       // Windows süzgeci çağırmaz, adı okur
    isVisible = true                     // kullanıcı kapatana kadar bekler
}
val files = dialog.files

Web, seçici ve bırakma:

val input = document.createElement("input") as HTMLInputElement
input.type = "file"; input.accept = "application/pdf,.pdf"; input.multiple = true
input.addEventListener("change", { /* input.files */ })
input.click()                            // bir kullanıcı basışının içinde

// Bırakma: event.transferData?.domDataTransferOrNull?.files, drop olayının içinde okunur.

5. Yardımcının yapmadıkları

6. Test etmek

7. Tuzaklar

8. Doğrulama

Testler

NeNerede
Tür eşleşmesi, MIME tablosu, bellekteki dosya, seçici isteği, LocalFilePicker'ın verdiği seçici, LocalFileSaver'ın verdiği kaydediciui/foundation/src/commonTest/.../files/ (JVM, iOS, JS, Wasm)
Masaüstünde seçilen yere yazmak, yazılamayan yerFileSaverOnTheDesktopTest
Android'in yeni belge penceresi: tür, önerilen ad, yazılan baytlar, kapatılan pencere, yazılamayan yer (Robolectric)FileSaverOnAndroidTest
İndirmenin Blob'u: tür ve bayt sayısıFilesOnTheWebTest (JS ve Wasm)
Geçici klasördeki kopya ve test ikilisindeki cevapFileSaverOnIosTest
Masaüstünün süzgeci, Windows kalıbı, bırakılan dosya listesiFilesOnTheDesktopTest
Android'in belge ve fotoğraf seçicisi (Robolectric), LocalFilePicker'ın verdiği seçicinin yerine geçmesi, bırakmanın türleri ve adresleriFilesOnAndroidTest
Tarayıcıda <input>, DataTransfer ile bırakma, baytların okunmasıFilesOnTheWebTest (JS ve Wasm)
Tekdüze türler, geçici klasöre kopyalamaFilesOnIosTest
Bölge: basış ve klavye, eleme, sözler, duyuru, analitik; satır; renkler; stillerui/components/src/commonTest/.../File*Test.kt, MiddleCutTest

Elle bakılanlar

Doğrulanmayanlar

9. Bu depoda nerede

10. Açık kalanlar

docs/OPEN_ITEMS.md'de: akış olarak okuma, kamera, sürüklerken tür denetimi, PantrixText'in ortadan kısaltmasının her platformda çalışması, cihazlarda bırakma.