Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable> = []

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -106,6 +106,7 @@ final class OSBARCCaptureSessionManager: OSBARCCameraManager {
}

videoPreviewLayer.connection?.videoOrientation = initialVideoOrientation
self.outputDecoder.update(videoOrientation: initialVideoOrientation)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't iOS 16 the lowest we support these days?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Capacitor 9, yes. But this lib is also used by the Capacitor 8 barcode plugin and the Cordova plugin on MABS 12, which are both still on iOS 15, so I kept the pre-16 path.

return self.interfaceOrientation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
44 changes: 42 additions & 2 deletions Sources/OSBarcodeLib/Scanner/OSBARCScannerViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,63 @@ class OSBARCScannerViewHostingController<Content>: UIHostingController<Content>
}

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()
}
}
}
51 changes: 51 additions & 0 deletions Tests/OSBarcodeLibTests/OSBARCManagerTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AVFoundation
import XCTest
@testable import OSBarcodeLib

Expand Down Expand Up @@ -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)
}
}
Loading