From 609173621b3703681f94d4972d4eed2576df7000 Mon Sep 17 00:00:00 2001 From: "anggrayudi.hardiannico" Date: Sun, 16 Aug 2026 17:11:12 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20End=20the=20wait=20when=20a=20do?= =?UTF-8?q?cument=20picker=20sheet=20is=20swiped=20away?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIDocumentPickerViewController is presented as a sheet, and a swipe dismissal does not always reach documentPickerWasCancelled — the reporter of #138 sees it when the swipe starts before the presentation animation has finished. The caller waits on a suspendCancellableCoroutine, so the picker never returns and the tap looks like it did nothing. PHPicker already guards this with a UIAdaptivePresentationControllerDelegate. Do the same for both document picker paths, but on the existing delegate rather than a second object: a dismissal often reports through both protocols, and resuming a continuation twice throws. One finishOnce() guard on one object makes that impossible. Setting presentationController?.delegate is a no-op where there is no presentation controller, so nothing changes for styles that have none. Closes #138 Co-Authored-By: Claude Opus 5 (1M context) --- .../vinceglb/filekit/dialogs/FileKit.ios.kt | 8 ++- .../dialogs/util/DocumentPickerDelegate.kt | 41 ++++++++++-- .../dialogs/DocumentPickerDelegateTest.kt | 67 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt index b5a57442..a4a08f0c 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt @@ -218,8 +218,10 @@ internal actual suspend fun FileKit.platformOpenFileSaver( // Set the initial directory directory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it.path) } - // Assign the delegate to the picker controller + // Assign the delegate to the picker controller. It answers for the presentation as well, + // so a swipe that never reaches documentPickerWasCancelled still ends the wait. pickerController.delegate = documentPickerDelegate + pickerController.presentationController?.delegate = documentPickerDelegate // Present the picker controller presenter.presentViewController( @@ -553,8 +555,10 @@ private suspend fun callPicker( // Set up the picker mode pickerController.allowsMultipleSelection = mode == Mode.Multiple - // Assign the delegate to the picker controller + // Assign the delegate to the picker controller. It answers for the presentation as well, + // so a swipe that never reaches documentPickerWasCancelled still ends the wait. pickerController.delegate = documentPickerDelegate + pickerController.presentationController?.delegate = documentPickerDelegate // Present the picker controller presentApplePickerController( diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt index 0f9dd708..3d6e70f8 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/DocumentPickerDelegate.kt @@ -1,31 +1,62 @@ package io.github.vinceglb.filekit.dialogs.util import platform.Foundation.NSURL +import platform.UIKit.UIAdaptivePresentationControllerDelegateProtocol import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIPresentationController import platform.darwin.NSObject internal class DocumentPickerDelegate( private val onFilesPicked: (List) -> Unit, private val onPickerCancelled: () -> Unit, ) : NSObject(), - UIDocumentPickerDelegateProtocol { + UIDocumentPickerDelegateProtocol, + UIAdaptivePresentationControllerDelegateProtocol { + private var hasFinished = false + override fun documentPicker( controller: UIDocumentPickerViewController, didPickDocumentAtURL: NSURL, ) { - onFilesPicked(listOf(didPickDocumentAtURL)) + if (finishOnce()) { + onFilesPicked(listOf(didPickDocumentAtURL)) + } } override fun documentPicker( controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>, ) { - val res = didPickDocumentsAtURLs.mapNotNull { it as? NSURL } - onFilesPicked(res) + if (finishOnce()) { + onFilesPicked(didPickDocumentsAtURLs.mapNotNull { it as? NSURL }) + } } override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { - onPickerCancelled() + if (finishOnce()) { + onPickerCancelled() + } + } + + /** + * Swiping the sheet away does not always reach [documentPickerWasCancelled] — notably when the + * dismissal starts before the presentation animation has finished. Without this the caller's + * continuation waits for a delegate call that never comes. See #138. + */ + override fun presentationControllerDidDismiss(presentationController: UIPresentationController) { + if (finishOnce()) { + onPickerCancelled() + } + } + + /** + * A dismissal often reports through both protocols, and the caller resumes a continuation that + * only accepts one answer, so every entry point above goes through this. + */ + private fun finishOnce(): Boolean { + if (hasFinished) return false + hasFinished = true + return true } } diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt new file mode 100644 index 00000000..bb1be904 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/DocumentPickerDelegateTest.kt @@ -0,0 +1,67 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.dialogs.util.DocumentPickerDelegate +import platform.Foundation.NSURL +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIPresentationController +import platform.UIKit.UIViewController +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The caller resumes a continuation from these callbacks, and a continuation only accepts one + * answer, so what matters is that exactly one of them ever gets through. + */ +class DocumentPickerDelegateTest { + @Test + fun DocumentPickerDelegate_swipeDismissWithoutCancelCallback_reportsCancelled() { + val recorder = Recorder() + + recorder.delegate.presentationControllerDidDismiss(presentationController()) + + assertEquals(expected = 0, actual = recorder.pickedCount) + assertEquals(expected = 1, actual = recorder.cancelledCount) + } + + @Test + fun DocumentPickerDelegate_cancelThenSwipeDismiss_reportsCancelledOnce() { + val recorder = Recorder() + + recorder.delegate.documentPickerWasCancelled(picker()) + recorder.delegate.presentationControllerDidDismiss(presentationController()) + + assertEquals(expected = 1, actual = recorder.cancelledCount) + } + + @Test + fun DocumentPickerDelegate_pickThenSwipeDismiss_reportsOnlyThePick() { + val recorder = Recorder() + + recorder.delegate.documentPicker(picker(), didPickDocumentAtURL = NSURL(string = "file:///tmp/a.txt")) + recorder.delegate.presentationControllerDidDismiss(presentationController()) + + assertEquals(expected = 1, actual = recorder.pickedCount) + assertEquals(expected = 0, actual = recorder.cancelledCount) + } + + private class Recorder { + var pickedCount = 0 + var cancelledCount = 0 + + val delegate = DocumentPickerDelegate( + onFilesPicked = { pickedCount++ }, + onPickerCancelled = { cancelledCount++ }, + ) + } + + private fun picker(): UIDocumentPickerViewController = + UIDocumentPickerViewController(forOpeningContentTypes = emptyList()) + + private fun presentationController(): UIPresentationController = + UIPresentationController( + presentedViewController = UIViewController(nibName = null, bundle = null), + presentingViewController = null, + ) +}