Skip to content

Commit 2f11f30

Browse files
committed
🐛 [iOS] Present the camera from a dedicated window by default
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.
1 parent 1164133 commit 2f11f30

3 files changed

Lines changed: 90 additions & 31 deletions

File tree

filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialog.documentPickerDelegate
77
import io.github.vinceglb.filekit.dialogs.FileKitDialog.phPickerDelegate
88
import io.github.vinceglb.filekit.dialogs.FileKitDialog.phPickerDismissDelegate
99
import io.github.vinceglb.filekit.dialogs.util.CameraControllerDelegate
10+
import io.github.vinceglb.filekit.dialogs.util.CameraPresenterWindow
1011
import io.github.vinceglb.filekit.dialogs.util.DocumentPickerDelegate
1112
import io.github.vinceglb.filekit.dialogs.util.PhPickerDelegate
1213
import io.github.vinceglb.filekit.dialogs.util.PhPickerDismissDelegate
@@ -287,37 +288,45 @@ public actual suspend fun FileKit.openCameraPicker(
287288
null
288289
}
289290
}
290-
val presentation = prepareAppleCameraPresentation(
291-
sourceAvailable = UIImagePickerController.isSourceTypeAvailable(cameraSource),
292-
presenter = openCameraSettings.presenterViewController(),
293-
requestedCamera = requestedCamera,
294-
)
295-
296-
suspendCancellableCoroutine<UIImage?> { continuation ->
297-
cameraControllerDelegate = CameraControllerDelegate(
298-
onImagePicked = { image ->
299-
try {
300-
continuation.resume(
301-
requireAppleCameraImage(image),
302-
)
303-
} catch (failure: FileKitDialogException) {
304-
continuation.resumeWithException(failure)
305-
}
306-
},
307-
onPickerCancelled = { continuation.resume(null) },
291+
val presenterWindow = when (openCameraSettings.presenter) {
292+
null -> CameraPresenterWindow()
293+
else -> null
294+
}
295+
try {
296+
val presentation = prepareAppleCameraPresentation(
297+
sourceAvailable = UIImagePickerController.isSourceTypeAvailable(cameraSource),
298+
presenter = openCameraSettings.presenter ?: presenterWindow?.attach(),
299+
requestedCamera = requestedCamera,
308300
)
309301

310-
val pickerController = UIImagePickerController()
311-
pickerController.sourceType = cameraSource
312-
pickerController.delegate = cameraControllerDelegate
302+
suspendCancellableCoroutine<UIImage?> { continuation ->
303+
cameraControllerDelegate = CameraControllerDelegate(
304+
onImagePicked = { image ->
305+
try {
306+
continuation.resume(
307+
requireAppleCameraImage(image),
308+
)
309+
} catch (failure: FileKitDialogException) {
310+
continuation.resumeWithException(failure)
311+
}
312+
},
313+
onPickerCancelled = { continuation.resume(null) },
314+
)
313315

314-
presentation.cameraDevice?.let { pickerController.cameraDevice = it }
316+
val pickerController = UIImagePickerController()
317+
pickerController.sourceType = cameraSource
318+
pickerController.delegate = cameraControllerDelegate
315319

316-
presentation.presenter.presentViewController(
317-
pickerController,
318-
animated = true,
319-
completion = null,
320-
)
320+
presentation.cameraDevice?.let { pickerController.cameraDevice = it }
321+
322+
presentation.presenter.presentViewController(
323+
pickerController,
324+
animated = true,
325+
completion = null,
326+
)
327+
}
328+
} finally {
329+
presenterWindow?.detach()
321330
}
322331
} ?: return null
323332

@@ -525,9 +534,6 @@ private fun FileKitDialogSettings.presenterViewController(
525534
activeViewController: () -> UIViewController? = ::activeAppleViewController,
526535
): UIViewController? = presenter ?: activeViewController()
527536

528-
private fun FileKitOpenCameraSettings.presenterViewController(): UIViewController? =
529-
presenter ?: UIApplication.sharedApplication.topMostViewController()
530-
531537
private fun FileKitShareSettings.presenterViewController(): UIViewController? =
532538
presenter ?: UIApplication.sharedApplication.topMostViewController()
533539

filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitOpenCameraSettings.ios.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import platform.UIKit.UIViewController
55
/**
66
* iOS implementation of [FileKitOpenCameraSettings].
77
*
8-
* @property presenter The view controller used to present the camera picker.
8+
* @property presenter The view controller used to present the camera picker. When null, FileKit
9+
* presents the camera from a dedicated window placed above the app's windows, which keeps the
10+
* picker compatible with hosts whose dialogs live in their own window, such as Compose
11+
* Multiplatform 1.11+.
912
*/
1013
public actual class FileKitOpenCameraSettings(
1114
public val presenter: UIViewController? = null,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.github.vinceglb.filekit.dialogs.util
2+
3+
import kotlinx.cinterop.ExperimentalForeignApi
4+
import platform.UIKit.UIApplication
5+
import platform.UIKit.UIColor
6+
import platform.UIKit.UIScreen
7+
import platform.UIKit.UIViewController
8+
import platform.UIKit.UIWindow
9+
import platform.UIKit.UIWindowLevelAlert
10+
import platform.UIKit.UIWindowScene
11+
12+
/**
13+
* Hosts the camera presentation in a dedicated transparent [UIWindow].
14+
*
15+
* Since Compose Multiplatform 1.11, Compose dialogs and popups live in their own window placed
16+
* above modally presented view controllers. Presenting the fullscreen camera from the top-most
17+
* view controller of the main window puts it underneath such windows, which corrupts touch
18+
* handling app-wide after the dismissal. Presenting from a dedicated key window above alerts
19+
* avoids that; the previous key window is restored once the capture flow finishes.
20+
*/
21+
internal class CameraPresenterWindow {
22+
private val hostViewController = UIViewController()
23+
private var window: UIWindow? = null
24+
private var previousKeyWindow: UIWindow? = null
25+
26+
@OptIn(ExperimentalForeignApi::class)
27+
fun attach(): UIViewController {
28+
val application = UIApplication.sharedApplication
29+
previousKeyWindow = application.keyWindow
30+
val scene = application.connectedScenes.firstNotNullOfOrNull { it as? UIWindowScene }
31+
val newWindow = when (scene) {
32+
null -> UIWindow(frame = UIScreen.mainScreen.bounds)
33+
else -> UIWindow(windowScene = scene)
34+
}
35+
newWindow.rootViewController = hostViewController
36+
newWindow.windowLevel = UIWindowLevelAlert + 1.0
37+
newWindow.backgroundColor = UIColor.clearColor
38+
newWindow.makeKeyAndVisible()
39+
window = newWindow
40+
return hostViewController
41+
}
42+
43+
fun detach() {
44+
window?.setHidden(true)
45+
window?.rootViewController = null
46+
window = null
47+
previousKeyWindow?.makeKeyAndVisible()
48+
previousKeyWindow = null
49+
}
50+
}

0 commit comments

Comments
 (0)