diff --git a/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureOutputDecoder.swift b/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureOutputDecoder.swift index 4cca08c..f375ec1 100644 --- a/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureOutputDecoder.swift +++ b/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureOutputDecoder.swift @@ -14,6 +14,12 @@ final class OSBARCCaptureOutputDecoder: NSObject, AVCaptureVideoDataOutputSample /// A hint, to scan a specific format (e.g. only qr code). `Nil` or `unknown` value means it can scan all. private var hint: OSBARCScannerHint? + /// Orientation of the camera preview. + /// Frames passed to Vision follow it, so that they match what's displayed on screen. + private var videoOrientation: AVCaptureVideoOrientation? + /// Last on-screen scan frame, kept to recompute the region of interest when the orientation changes. + private var scanFrame: CGRect? + /// The publisher's cancellable instance collector. private var cancellables: Set = [] @@ -34,8 +40,9 @@ final class OSBARCCaptureOutputDecoder: NSObject, AVCaptureVideoDataOutputSample .publisher(for: .scanFrameChanged) .receive(on: RunLoop.main) // receive this on the main thread .sink { // performed this when `scanFrameChanged` gets triggered (on screen rotation). - if let imageRect = $0.object as? CGRect, let regionOfInterest = self.scanRegionOfInterest(for: imageRect) { - self.detectBarcodeRequest.regionOfInterest = regionOfInterest // update `regionOfInterest` + if let imageRect = $0.object as? CGRect { + self.scanFrame = imageRect + self.updateRegionOfInterest() } } .store(in: &cancellables) @@ -67,7 +74,14 @@ final class OSBARCCaptureOutputDecoder: NSObject, AVCaptureVideoDataOutputSample ) try? imageRequestHandler.perform([self.detectBarcodeRequest]) } - + + /// Keeps the decoding aligned with the camera preview. Must be called on the main thread. + /// - Parameter videoOrientation: The orientation the camera preview is now displayed in. + func update(videoOrientation: AVCaptureVideoOrientation) { + self.videoOrientation = videoOrientation + self.updateRegionOfInterest() + } + /// Vision request to perform. It gets initialised only once and when needed. lazy private var detectBarcodeRequest: VNDetectBarcodesRequest = { let barcodeRequest = VNDetectBarcodesRequest(completionHandler: { request, error in @@ -100,13 +114,28 @@ private extension OSBARCCaptureOutputDecoder { /// - Parameter imageRect: The coordinates to convert /// - Returns: The converted coordinates. If can return `nil` if not able to fetch the screens bounds to base on. func scanRegionOfInterest(for imageRect: CGRect) -> CGRect? { - guard let screenBounds = UIApplication.firstKeyWindowForConnectedScenes?.windowScene?.screen.bounds else { return nil } - return VNNormalizedRectForImageRect(imageRect, Int(screenBounds.width), Int(screenBounds.height)) + // The window's bounds follow the layout the scan frame comes from; the screen's lag behind a forced rotation. + guard let windowBounds = UIApplication.firstKeyWindowForConnectedScenes?.bounds else { return nil } + return VNNormalizedRectForImageRect(imageRect, Int(windowBounds.width), Int(windowBounds.height)) } - + + /// Updates the Vision request's region of interest from the last scan frame. + func updateRegionOfInterest() { + if let scanFrame = self.scanFrame, let regionOfInterest = self.scanRegionOfInterest(for: scanFrame) { + self.detectBarcodeRequest.regionOfInterest = regionOfInterest + } + } + /// Converts device orientation into the intended image's display orientation. /// - Returns: The equivalent `CGImagePropertyOrientation` to use for the device's current orientation. func deviceExifOrientation() -> CGImagePropertyOrientation { + // The region of interest comes from the on-screen scan frame, so the image has to follow the preview's + // orientation, which differs from the device's when the scanner forces portrait or landscape. + if let videoOrientation = self.videoOrientation, + let orientation = CGImagePropertyOrientation(videoOrientation: videoOrientation) { + return orientation + } + switch UIDevice.current.orientation { case .landscapeRight: return .upMirrored case .portraitUpsideDown: return .leftMirrored @@ -123,3 +152,18 @@ private extension OSBARCCaptureOutputDecoder { } } } + +extension CGImagePropertyOrientation { + /// Maps the camera preview's orientation into the orientation of the frames passed to Vision. + /// - Parameter videoOrientation: Value to be converted from. Returns `nil` for unknown values. + init?(videoOrientation: AVCaptureVideoOrientation) { + switch videoOrientation { + case .portrait: self = .rightMirrored + case .portraitUpsideDown: self = .leftMirrored + // `AVCaptureVideoOrientation.landscapeLeft` is `UIDeviceOrientation.landscapeRight`. + case .landscapeLeft: self = .upMirrored + case .landscapeRight: self = .downMirrored + @unknown default: return nil + } + } +} diff --git a/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureSessionManager.swift b/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureSessionManager.swift index 327069e..6e27b76 100644 --- a/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureSessionManager.swift +++ b/Sources/OSBarcodeLib/Scanner/CameraManager/OSBARCCaptureSessionManager.swift @@ -92,7 +92,7 @@ final class OSBARCCaptureSessionManager: OSBARCCameraManager { Subsequent orientation changes are handled by `viewWillTransition(to:with:)`. */ if videoPreviewLayer.connection?.isVideoOrientationSupported == true, - let interfaceOrientation = UIApplication.firstKeyWindowForConnectedScenes?.windowScene?.interfaceOrientation, + let interfaceOrientation = UIApplication.currentInterfaceOrientation, var initialVideoOrientation = AVCaptureVideoOrientation(interfaceOrientation: interfaceOrientation) { /* If the orientation has to be portrait but the device is not on that mode, orientation is set to `portrait`. @@ -106,6 +106,7 @@ final class OSBARCCaptureSessionManager: OSBARCCameraManager { } videoPreviewLayer.connection?.videoOrientation = initialVideoOrientation + self.outputDecoder.update(videoOrientation: initialVideoOrientation) } } } @@ -143,9 +144,10 @@ final class OSBARCCaptureSessionManager: OSBARCCameraManager { videoPreviewLayer.frame = CGRect(x: 0, y: 0, width: rotationChange.value.width, height: rotationChange.value.height) if videoPreviewLayer.connection?.isVideoOrientationSupported == true, - let interfaceOrientation = UIApplication.firstKeyWindowForConnectedScenes?.windowScene?.interfaceOrientation, + let interfaceOrientation = UIApplication.currentInterfaceOrientation, let newVideoOrientation = AVCaptureVideoOrientation(interfaceOrientation: interfaceOrientation) { videoPreviewLayer.connection?.videoOrientation = newVideoOrientation + self.outputDecoder.update(videoOrientation: newVideoOrientation) } } } diff --git a/Sources/OSBarcodeLib/Scanner/Extensions/UIApplication+Window.swift b/Sources/OSBarcodeLib/Scanner/Extensions/UIApplication+Window.swift index 6fb614a..2801c59 100644 --- a/Sources/OSBarcodeLib/Scanner/Extensions/UIApplication+Window.swift +++ b/Sources/OSBarcodeLib/Scanner/Extensions/UIApplication+Window.swift @@ -9,4 +9,18 @@ extension UIApplication { .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } .last { $0.isKeyWindow } } + + /// The current interface orientation of the key window's scene. + static var currentInterfaceOrientation: UIInterfaceOrientation? { + self.firstKeyWindowForConnectedScenes?.windowScene?.currentInterfaceOrientation + } +} + +extension UIWindowScene { + /// The scene's current interface orientation, read from `effectiveGeometry` on iOS 16+ + /// (`interfaceOrientation` is deprecated as of iOS 26). + var currentInterfaceOrientation: UIInterfaceOrientation { + if #available(iOS 16.0, *) { return self.effectiveGeometry.interfaceOrientation } + return self.interfaceOrientation + } } diff --git a/Sources/OSBarcodeLib/Scanner/Extensions/UIInterfaceOrientationMask+OSBARCModelMappable.swift b/Sources/OSBarcodeLib/Scanner/Extensions/UIInterfaceOrientationMask+OSBARCModelMappable.swift index 8a16d2c..77a9bf8 100644 --- a/Sources/OSBarcodeLib/Scanner/Extensions/UIInterfaceOrientationMask+OSBARCModelMappable.swift +++ b/Sources/OSBarcodeLib/Scanner/Extensions/UIInterfaceOrientationMask+OSBARCModelMappable.swift @@ -10,4 +10,45 @@ extension UIInterfaceOrientationMask: OSBARCModelMappable { default: return Self.deviceTypeModel == .iphone ? .allButUpsideDown : .all } } + + /// Mask containing only the given interface orientation (`all` when it's unknown). + /// - Parameter interfaceOrientation: Value to be converted from. + init(interfaceOrientation: UIInterfaceOrientation) { + switch interfaceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeLeft + case .landscapeRight: self = .landscapeRight + default: self = .all + } + } + + /// Orientation to rotate to so the interface fits this mask, or `nil` when the current one already does. + /// Prefers how the device is held, so a landscape mask doesn't flip an interface that's already landscape. + /// - Parameters: + /// - currentOrientation: The interface's current orientation. + /// - deviceOrientation: The device's physical orientation. + func interfaceOrientationToRequest( + from currentOrientation: UIInterfaceOrientation, deviceOrientation: UIDeviceOrientation + ) -> UIInterfaceOrientation? { + guard !self.contains(.init(interfaceOrientation: currentOrientation)) else { return nil } + let candidates = [UIInterfaceOrientation(deviceOrientation: deviceOrientation)].compactMap { $0 } + + [.portrait, .landscapeRight, .landscapeLeft, .portraitUpsideDown] + return candidates.first { self.contains(.init(interfaceOrientation: $0)) } + } +} + +extension UIInterfaceOrientation { + /// Interface orientation matching the device's physical orientation. + /// Returns `nil` when the device is flat or the orientation is unknown. + /// - Parameter deviceOrientation: Value to be converted from. + init?(deviceOrientation: UIDeviceOrientation) { + switch deviceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeRight // the landscape cases are mirrored between device and interface + case .landscapeRight: self = .landscapeLeft + default: return nil + } + } } diff --git a/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewController.swift b/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewController.swift index 34c5a12..dc831bf 100644 --- a/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewController.swift +++ b/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewController.swift @@ -33,14 +33,54 @@ final class OSBARCScannerViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - + + self.geometryObservation?.invalidate() + self.geometryObservation = nil + self.cameraManager.stop() } + /// Observes the window scene's geometry, so the preview follows rotations + /// that don't trigger a transition or a new layout. + private var geometryObservation: NSKeyValueObservation? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if #available(iOS 16.0, *), let windowScene = self.view.window?.windowScene { + self.geometryObservation = windowScene.observe(\.effectiveGeometry) { [weak self] _, _ in + DispatchQueue.main.async { self?.syncPreviewWithView() } + } + } + self.syncPreviewWithView() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Keeps the preview in sync with the view's size, including rotations that don't go through + // `viewWillTransition(to:with:)`, such as the one requested when the scanner is presented + // with a forced orientation. + self.syncPreviewWithView() + } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - + let rotationToPerform: OSBARCCameraRotationChange = .init(value: (Int(size.height), Int(size.width))) try? self.cameraManager.apply(change: rotationToPerform) + + // The interface orientation only changes once the transition ends, + // and a 180° rotation doesn't trigger a new layout. + coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.syncPreviewWithView() } + } + + /// Resizes the camera preview to the view's bounds and aligns it with the current interface orientation. + private func syncPreviewWithView() { + let size = self.view.bounds.size + CATransaction.begin() + CATransaction.setDisableActions(true) + try? self.cameraManager.apply(change: OSBARCCameraRotationChange(value: (Int(size.height), Int(size.width)))) + CATransaction.commit() } } diff --git a/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewHostingController.swift b/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewHostingController.swift index d8ec730..5393d26 100644 --- a/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewHostingController.swift +++ b/Sources/OSBarcodeLib/Scanner/OSBARCScannerViewHostingController.swift @@ -20,4 +20,63 @@ class OSBARCScannerViewHostingController: UIHostingController } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .map(self.orientationModel) } + + /// Window scene the scanner is presented in, used to restore its orientation on dismissal. + private weak var presentingWindowScene: UIWindowScene? + /// Interface orientation in place before the scanner forced its own. + private var previousInterfaceOrientation: UIInterfaceOrientation? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // With the UIScene lifecycle, presenting a full-screen controller with narrower + // `supportedInterfaceOrientations` doesn't rotate the interface by itself, + // so a forced orientation has to be requested explicitly. + guard self.orientationModel != .adaptive, + let windowScene = self.view.window?.windowScene + ?? UIApplication.firstKeyWindowForConnectedScenes?.windowScene + else { return } + self.presentingWindowScene = windowScene + self.previousInterfaceOrientation = windowScene.currentInterfaceOrientation + Self.requestInterfaceOrientations(self.supportedInterfaceOrientations, in: windowScene, updating: self) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + guard let windowScene = self.presentingWindowScene, + let previousInterfaceOrientation = self.previousInterfaceOrientation + else { return } + // Follow how the device is held now, if the app supports it. + // Otherwise, go back to the orientation in place before scanning. + let rootViewController = windowScene.keyWindow?.rootViewController + let supportedOrientations = rootViewController?.supportedInterfaceOrientations ?? .all + let deviceOrientation = UIInterfaceOrientation(deviceOrientation: UIDevice.current.orientation) + let targetOrientation = deviceOrientation.flatMap { + supportedOrientations.contains(.init(interfaceOrientation: $0)) ? $0 : nil + } ?? previousInterfaceOrientation + Self.requestInterfaceOrientations( + .init(interfaceOrientation: targetOrientation), in: windowScene, updating: rootViewController + ) + } + + /// Asks the window scene to rotate its interface to one of the given orientations. + /// - Parameters: + /// - mask: The orientations to rotate to. + /// - windowScene: The window scene to rotate. + /// - viewController: The controller whose `supportedInterfaceOrientations` should be re-evaluated. + private static func requestInterfaceOrientations( + _ mask: UIInterfaceOrientationMask, in windowScene: UIWindowScene, updating viewController: UIViewController? + ) { + if #available(iOS 16.0, *) { + viewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) + } else { + guard let orientation = mask.interfaceOrientationToRequest( + from: windowScene.currentInterfaceOrientation, deviceOrientation: UIDevice.current.orientation + ) else { return } + UIDevice.current.setValue(orientation.rawValue, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + } } diff --git a/Tests/OSBarcodeLibTests/OSBARCManagerTests.swift b/Tests/OSBarcodeLibTests/OSBARCManagerTests.swift index 2a21bfd..307dd95 100644 --- a/Tests/OSBarcodeLibTests/OSBARCManagerTests.swift +++ b/Tests/OSBarcodeLibTests/OSBARCManagerTests.swift @@ -1,3 +1,4 @@ +import AVFoundation import XCTest @testable import OSBarcodeLib @@ -77,3 +78,53 @@ private extension OSBARCManagerTests { } } } + +final class OSBARCOrientationMappingTests: XCTestCase { + func testPreviewOrientationMapsToTheMatchingImageOrientation() { + XCTAssertEqual(CGImagePropertyOrientation(videoOrientation: .portrait), .rightMirrored) + XCTAssertEqual(CGImagePropertyOrientation(videoOrientation: .portraitUpsideDown), .leftMirrored) + XCTAssertEqual(CGImagePropertyOrientation(videoOrientation: .landscapeLeft), .upMirrored) + XCTAssertEqual(CGImagePropertyOrientation(videoOrientation: .landscapeRight), .downMirrored) + } + + func testDeviceOrientationMapsToTheMatchingInterfaceOrientation() { + XCTAssertEqual(UIInterfaceOrientation(deviceOrientation: .portrait), .portrait) + XCTAssertEqual(UIInterfaceOrientation(deviceOrientation: .portraitUpsideDown), .portraitUpsideDown) + XCTAssertEqual(UIInterfaceOrientation(deviceOrientation: .landscapeLeft), .landscapeRight) + XCTAssertEqual(UIInterfaceOrientation(deviceOrientation: .landscapeRight), .landscapeLeft) + XCTAssertNil(UIInterfaceOrientation(deviceOrientation: .faceUp)) + XCTAssertNil(UIInterfaceOrientation(deviceOrientation: .unknown)) + } + + func testInterfaceOrientationMapsToASingleOrientationMask() { + XCTAssertEqual(UIInterfaceOrientationMask(interfaceOrientation: .portrait), .portrait) + XCTAssertEqual(UIInterfaceOrientationMask(interfaceOrientation: .landscapeLeft), .landscapeLeft) + XCTAssertEqual(UIInterfaceOrientationMask(interfaceOrientation: .landscapeRight), .landscapeRight) + XCTAssertEqual(UIInterfaceOrientationMask(interfaceOrientation: .unknown), .all) + } + + func testOrientationToRequestKeepsAnOrientationAlreadyInTheMask() { + let mask = UIInterfaceOrientationMask.landscape + XCTAssertNil(mask.interfaceOrientationToRequest(from: .landscapeLeft, deviceOrientation: .landscapeRight)) + XCTAssertNil(mask.interfaceOrientationToRequest(from: .landscapeRight, deviceOrientation: .landscapeLeft)) + } + + func testOrientationToRequestPrefersHowTheDeviceIsHeld() { + let mask = UIInterfaceOrientationMask.landscape + let fromRight = mask.interfaceOrientationToRequest(from: .portrait, deviceOrientation: .landscapeRight) + let fromLeft = mask.interfaceOrientationToRequest(from: .portrait, deviceOrientation: .landscapeLeft) + XCTAssertEqual(fromRight, .landscapeLeft) + XCTAssertEqual(fromLeft, .landscapeRight) + } + + func testOrientationToRequestFallsBackWhenTheDeviceOrientationIsNotInTheMask() { + let flat = UIInterfaceOrientationMask.landscape.interfaceOrientationToRequest( + from: .portrait, deviceOrientation: .faceUp + ) + let landscape = UIInterfaceOrientationMask.portrait.interfaceOrientationToRequest( + from: .landscapeLeft, deviceOrientation: .landscapeLeft + ) + XCTAssertEqual(flat, .landscapeRight) + XCTAssertEqual(landscape, .portrait) + } +}