diff --git a/packages/image_picker/image_picker_ios/CHANGELOG.md b/packages/image_picker/image_picker_ios/CHANGELOG.md index 510ad6fed9c0..d89ca6e11f0a 100644 --- a/packages/image_picker/image_picker_ios/CHANGELOG.md +++ b/packages/image_picker/image_picker_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.13+5 + +* Fixes camera confirmation buttons (e.g., Retake/Use Photo) taps passing through to the underlying Flutter UI while the picker is dismissing on some iOS versions (e.g., iOS 26). + ## 0.8.13+4 * Improves compatibility with `UIScene`. diff --git a/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m b/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m index ca90224ecbf9..734c0df56bf6 100644 --- a/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m +++ b/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m @@ -13,6 +13,12 @@ #import +@interface FLTImagePickerPlugin (Testing) +@property(nonatomic, strong) UIWindow *interactionBlockerWindow; +@property(nonatomic, weak) UIWindow *previousKeyWindow; +- (void)removeInteractionBlocker; +@end + @interface MockViewController : UIViewController @property(nonatomic, retain) UIViewController *mockPresented; @end @@ -317,6 +323,54 @@ - (void)testPluginPickImageDeviceCancelClickMultipleTimes { [plugin imagePickerControllerDidCancel:controller]; } +- (void)testCameraPickerInteractionBlockerWindowIsAddedAndRemoved { + id mockUIImagePicker = OCMClassMock([UIImagePickerController class]); + id mockAVCaptureDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub(ClassMethod( + [mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + OCMStub(ClassMethod( + [mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) + .andReturn(YES); + OCMStub([mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + UIWindow *window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *rootViewController = [[UIViewController alloc] init]; + window.rootViewController = rootViewController; + if ([rootViewController respondsToSelector:@selector(loadViewIfNeeded)]) { + [rootViewController loadViewIfNeeded]; + } else { + [rootViewController view]; + } + [window makeKeyAndVisible]; + + StubViewProvider *viewProvider = + [[StubViewProvider alloc] initWithViewController:rootViewController]; + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] initWithViewProvider:viewProvider]; + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + [plugin setImagePickerControllerOverrides:@[ controller ]]; + + [plugin pickImageWithSource:[FLTSourceSpecification makeWithType:FLTSourceTypeCamera + camera:FLTSourceCameraRear] + maxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:YES + completion:^(NSString *_Nullable result, FlutterError *_Nullable error){ + }]; + + XCTAssertNotNil(plugin.interactionBlockerWindow); + XCTAssertEqual(plugin.previousKeyWindow, window); + XCTAssertGreaterThan(plugin.interactionBlockerWindow.windowLevel, window.windowLevel); + XCTAssertFalse(window.isKeyWindow); + + [plugin removeInteractionBlocker]; + + XCTAssertNil(plugin.interactionBlockerWindow); + XCTAssertNil(plugin.previousKeyWindow); + XCTAssertTrue(window.isKeyWindow); +} + #pragma mark - Test video duration - (void)testPickingVideoWithDuration { diff --git a/packages/image_picker/image_picker_ios/example/pubspec.yaml b/packages/image_picker/image_picker_ios/example/pubspec.yaml index c5b71c804f6f..e3fcb490a192 100755 --- a/packages/image_picker/image_picker_ios/example/pubspec.yaml +++ b/packages/image_picker/image_picker_ios/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the image_picker plugin. publish_to: none environment: - sdk: ^3.10.0 - flutter: ">=3.38.0" + sdk: ^3.9.0 + flutter: ">=3.35.0" dependencies: flutter: diff --git a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m index 5151fbcbc80b..82991980d0c7 100644 --- a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m @@ -40,6 +40,14 @@ @interface FLTImagePickerPlugin () /// The view provider to use for displaying native view controllers. @property(nonatomic, nonnull) NSObject *viewProvider; +/// A temporary UIWindow placed above Flutter's window to swallow all user +/// interactions while UIImagePickerController is dismissing. This prevents +/// stray taps from leaking to the Flutter view during the dismissal animation. +@property(strong, nonatomic) UIWindow *interactionBlockerWindow; + +/// The previously active key window before the interactionBlockerWindow is +/// shown. Stored so we can restore the original key window after dismissal. +@property(weak, nonatomic) UIWindow *previousKeyWindow; @end @@ -317,9 +325,9 @@ - (void)showCamera:(UIImagePickerControllerCameraDevice)device [UIImagePickerController isCameraDeviceAvailable:device]) { imagePickerController.sourceType = UIImagePickerControllerSourceTypeCamera; imagePickerController.cameraDevice = device; - [self.viewProvider.viewController presentViewController:imagePickerController - animated:YES - completion:nil]; + UIViewController *presentingController = + [self presentingViewControllerForImagePickerInNewWindow]; + [presentingController presentViewController:imagePickerController animated:YES completion:nil]; } else { UIAlertController *cameraErrorAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"Alert title when camera unavailable") @@ -526,7 +534,11 @@ - (void)picker:(PHPickerViewController *)picker - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { NSURL *videoURL = info[UIImagePickerControllerMediaURL]; - [picker dismissViewControllerAnimated:YES completion:nil]; + __weak typeof(self) weakSelf = self; + [picker dismissViewControllerAnimated:YES + completion:^{ + [weakSelf removeInteractionBlocker]; + }]; // The method dismissViewControllerAnimated does not immediately prevent // further didFinishPickingMediaWithInfo invocations. A nil check is necessary // to prevent below code to be unwantly executed multiple times and cause a @@ -612,7 +624,11 @@ - (void)imagePickerController:(UIImagePickerController *)picker } - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { - [picker dismissViewControllerAnimated:YES completion:nil]; + __weak typeof(self) weakSelf = self; + [picker dismissViewControllerAnimated:YES + completion:^{ + [weakSelf removeInteractionBlocker]; + }]; [self sendCallResultWithSavedPathList:nil]; } @@ -668,4 +684,66 @@ - (void)sendCallResultWithError:(FlutterError *)error { self.callContext = nil; } +/// Why a separate UIWindow for the interaction blocker? +/// Flutter renders inside a UIWindow owned by FlutterViewController. UIImagePickerController is +/// presented in that same window and dismisses with an animation; during that brief transition, +/// taps can “leak” to the Flutter view underneath. +/// +/// A view-based blocker (added to the host view hierarchy) isn’t reliable because the host view’s +/// bounds/constraints/rotation/transitions can change during presentation/dismissal, causing the +/// blocker to move or be removed. +/// +/// Instead we create a dedicated UIWindow (windowLevel + 1) with its own root VC to swallow all +/// touches during the dismissal window, then restore the previous key window afterward. +/// The image picker is presented on this blocker window so it appears above everything. +/// +/// @return The view controller that should be used to present the image picker. +- (UIViewController *)presentingViewControllerForImagePickerInNewWindow { + if (self.interactionBlockerWindow != nil) { + return self.interactionBlockerWindow.rootViewController; + } + UIViewController *topController = self.viewProvider.viewController; + UIWindow *presentingWindow = topController.view.window; + if (!presentingWindow) { + return topController; + } + self.previousKeyWindow = presentingWindow; + UIWindow *blockerWindow; + if (@available(iOS 13.0, *)) { + if (presentingWindow.windowScene) { + blockerWindow = [[UIWindow alloc] initWithWindowScene:presentingWindow.windowScene]; + } else { + blockerWindow = [[UIWindow alloc] initWithFrame:presentingWindow.bounds]; + } + } else { + blockerWindow = [[UIWindow alloc] initWithFrame:presentingWindow.bounds]; + } + blockerWindow.frame = presentingWindow.bounds; + blockerWindow.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + blockerWindow.windowLevel = presentingWindow.windowLevel + 1; + UIViewController *vc = [[UIViewController alloc] init]; + vc.view.backgroundColor = [UIColor clearColor]; + vc.view.userInteractionEnabled = YES; + blockerWindow.rootViewController = vc; + [blockerWindow makeKeyAndVisible]; + self.interactionBlockerWindow = blockerWindow; + return vc; +} + +/// Removes the temporary interaction-blocking window and restores the previous +/// key window. This is called after UIImagePickerController has fully dismissed, +/// once it is safe for Flutter to receive user input again. +- (void)removeInteractionBlocker { + if (!self.interactionBlockerWindow) { + return; + } + self.interactionBlockerWindow.hidden = YES; + if (self.previousKeyWindow) { + [self.previousKeyWindow makeKeyWindow]; + } + self.interactionBlockerWindow = nil; + self.previousKeyWindow = nil; +} + @end diff --git a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap index 52e9ad7e43a0..d374614f7d53 100644 --- a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap +++ b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap @@ -4,8 +4,9 @@ framework module image_picker_ios { export * module * { export * } + header "FIPViewProvider.h" + explicit module Test { - header "FIPViewProvider.h" header "FLTImagePickerPlugin_Test.h" header "FLTImagePickerImageUtil.h" header "FLTImagePickerMetaDataUtil.h" diff --git a/packages/image_picker/image_picker_ios/pubspec.yaml b/packages/image_picker/image_picker_ios/pubspec.yaml index 9523821707c7..298b57a8a275 100755 --- a/packages/image_picker/image_picker_ios/pubspec.yaml +++ b/packages/image_picker/image_picker_ios/pubspec.yaml @@ -2,11 +2,11 @@ name: image_picker_ios description: iOS implementation of the image_picker plugin. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.13+4 +version: 0.8.13+5 environment: - sdk: ^3.10.0 - flutter: ">=3.38.0" + sdk: ^3.9.0 + flutter: ">=3.35.0" flutter: plugin: