From eca703cc7a206b06d0b0aab3df92c35a5ad6c391 Mon Sep 17 00:00:00 2001 From: PierreVieira Date: Tue, 11 Aug 2026 18:26:37 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20[iOS]=20Present=20the=20camera?= =?UTF-8?q?=20from=20a=20dedicated=20window=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no explicit presenter is supplied, openCameraPicker used to present the fullscreen UIImagePickerController from the top-most view controller of the main window. Hosts whose dialogs live in their own UIWindow above modal view controllers — Compose Multiplatform since 1.11 — end up with the camera presented underneath that dialog window, which corrupts touch handling app-wide after the picker dismissal (#638). The camera presentation is now hosted in a FileKit-managed transparent UIWindow made key above alerts, attached right before presenting and detached — restoring the previous key window — once the capture flow finishes, including failure paths. Apps passing an explicit presenter through FileKitOpenCameraSettings keep the previous behavior. --- .../vinceglb/filekit/dialogs/FileKit.ios.kt | 66 ++++++++++--------- .../dialogs/FileKitOpenCameraSettings.ios.kt | 5 +- .../dialogs/util/CameraPresenterWindow.kt | 47 +++++++++++++ 3 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraPresenterWindow.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..dd57504a 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 @@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialog.documentPickerDelegate import io.github.vinceglb.filekit.dialogs.FileKitDialog.phPickerDelegate import io.github.vinceglb.filekit.dialogs.FileKitDialog.phPickerDismissDelegate import io.github.vinceglb.filekit.dialogs.util.CameraControllerDelegate +import io.github.vinceglb.filekit.dialogs.util.CameraPresenterWindow import io.github.vinceglb.filekit.dialogs.util.DocumentPickerDelegate import io.github.vinceglb.filekit.dialogs.util.PhPickerDelegate import io.github.vinceglb.filekit.dialogs.util.PhPickerDismissDelegate @@ -287,37 +288,45 @@ public actual suspend fun FileKit.openCameraPicker( null } } - val presentation = prepareAppleCameraPresentation( - sourceAvailable = UIImagePickerController.isSourceTypeAvailable(cameraSource), - presenter = openCameraSettings.presenterViewController(), - requestedCamera = requestedCamera, - ) - - suspendCancellableCoroutine { continuation -> - cameraControllerDelegate = CameraControllerDelegate( - onImagePicked = { image -> - try { - continuation.resume( - requireAppleCameraImage(image), - ) - } catch (failure: FileKitDialogException) { - continuation.resumeWithException(failure) - } - }, - onPickerCancelled = { continuation.resume(null) }, + val presenterWindow = when (openCameraSettings.presenter) { + null -> CameraPresenterWindow() + else -> null + } + try { + val presentation = prepareAppleCameraPresentation( + sourceAvailable = UIImagePickerController.isSourceTypeAvailable(cameraSource), + presenter = openCameraSettings.presenter ?: presenterWindow?.attach(), + requestedCamera = requestedCamera, ) - val pickerController = UIImagePickerController() - pickerController.sourceType = cameraSource - pickerController.delegate = cameraControllerDelegate + suspendCancellableCoroutine { continuation -> + cameraControllerDelegate = CameraControllerDelegate( + onImagePicked = { image -> + try { + continuation.resume( + requireAppleCameraImage(image), + ) + } catch (failure: FileKitDialogException) { + continuation.resumeWithException(failure) + } + }, + onPickerCancelled = { continuation.resume(null) }, + ) - presentation.cameraDevice?.let { pickerController.cameraDevice = it } + val pickerController = UIImagePickerController() + pickerController.sourceType = cameraSource + pickerController.delegate = cameraControllerDelegate - presentation.presenter.presentViewController( - pickerController, - animated = true, - completion = null, - ) + presentation.cameraDevice?.let { pickerController.cameraDevice = it } + + presentation.presenter.presentViewController( + pickerController, + animated = true, + completion = null, + ) + } + } finally { + presenterWindow?.detach() } } ?: return null @@ -525,9 +534,6 @@ private fun FileKitDialogSettings.presenterViewController( activeViewController: () -> UIViewController? = ::activeAppleViewController, ): UIViewController? = presenter ?: activeViewController() -private fun FileKitOpenCameraSettings.presenterViewController(): UIViewController? = - presenter ?: UIApplication.sharedApplication.topMostViewController() - private fun FileKitShareSettings.presenterViewController(): UIViewController? = presenter ?: UIApplication.sharedApplication.topMostViewController() diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitOpenCameraSettings.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitOpenCameraSettings.ios.kt index 352cabdc..bf813a37 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitOpenCameraSettings.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitOpenCameraSettings.ios.kt @@ -5,7 +5,10 @@ import platform.UIKit.UIViewController /** * iOS implementation of [FileKitOpenCameraSettings]. * - * @property presenter The view controller used to present the camera picker. + * @property presenter The view controller used to present the camera picker. When null, FileKit + * presents the camera from a dedicated window placed above the app's windows, which keeps the + * picker compatible with hosts whose dialogs live in their own window, such as Compose + * Multiplatform 1.11+. */ public actual class FileKitOpenCameraSettings( public val presenter: UIViewController? = null, diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraPresenterWindow.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraPresenterWindow.kt new file mode 100644 index 00000000..eb543878 --- /dev/null +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraPresenterWindow.kt @@ -0,0 +1,47 @@ +package io.github.vinceglb.filekit.dialogs.util + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIApplication +import platform.UIKit.UIColor +import platform.UIKit.UIScreen +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowLevelAlert +import platform.UIKit.UIWindowScene + +/** + * Hosts the camera presentation in a dedicated transparent [UIWindow]. + * + * Since Compose Multiplatform 1.11, Compose dialogs and popups live in their own window placed + * above modally presented view controllers. Presenting the fullscreen camera from the top-most + * view controller of the main window puts it underneath such windows, which corrupts touch + * handling app-wide after the dismissal. Presenting from a dedicated key window above alerts + * avoids that; the previous key window is restored once the capture flow finishes. + */ +internal class CameraPresenterWindow { + private val hostViewController = UIViewController() + private var window: UIWindow? = null + private var previousKeyWindow: UIWindow? = null + + @OptIn(ExperimentalForeignApi::class) + fun attach(): UIViewController { + val application = UIApplication.sharedApplication + previousKeyWindow = application.keyWindow + val scene = application.connectedScenes.firstNotNullOfOrNull { it as? UIWindowScene } + val newWindow = scene?.let(::UIWindow) ?: UIWindow(frame = UIScreen.mainScreen.bounds) + newWindow.rootViewController = hostViewController + newWindow.windowLevel = UIWindowLevelAlert + 1.0 + newWindow.backgroundColor = UIColor.clearColor + newWindow.makeKeyAndVisible() + window = newWindow + return hostViewController + } + + fun detach() { + window?.setHidden(true) + window?.rootViewController = null + window = null + previousKeyWindow?.makeKeyAndVisible() + previousKeyWindow = null + } +}