From e4b77979782e39b83fa3fec5eb9eaf7a5bbe6607 Mon Sep 17 00:00:00 2001 From: ZayanKhan-12 Date: Sat, 19 Sep 2026 13:08:21 -0400 Subject: [PATCH] fix(ios): correct ratioOverlay aspect calculation and orientation `ratioOverlay` produced a window that was both inverted and the wrong shape for every ratio except `1:1`. Two separate defects: 1. Parsing was reversed. `RatioOverlayData` read `values[0]` into `height` and `values[1]` into `width`, so `'4:5'` was treated as a 5:4 window. The README documents the prop as `'16:9'`, i.e. width:height, and that is what callers expect. 2. The geometry was wrong. The centre window was derived from the opposite axis of the preview: `centerSize.height` was `frame.height * ratio` (and `centerSize.width` was `frame.width / ratio`) instead of being derived from the axis being held fixed. The result depended on the preview's own aspect ratio, so the window never actually matched the requested ratio. `1:1` was hard-coded on a separate branch, which is why only `1:1` looked right. On a 375x812 preview, master produced: input window actual ratio bars 1:1 375x375 1.000 top/bottom ok 4:5 300x812 0.369 left/right wrong 5:4 375x650 0.577 top/bottom wrong 16:9 375x457 0.821 top/bottom wrong The ratio window is now computed as a plain aspect fit: the largest rect of the requested ratio that fits the preview, centred, with the leftover strips masked. That collapses the three near-duplicate branches into one and makes `1:1` fall out of the general case rather than being special. Also guards against zero/negative components and zero-sized previews, which previously produced a NaN or an inverted frame instead of simply hiding the overlay. Fixes #195 --- README.md | 2 +- .../RatioOverlayView.swift | 144 +++++++++--------- 2 files changed, 69 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index ce44e5236e..cd3815f607 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ To have Play Services [pre-download the model in the background after the app is | `onError` | Function | Android only. Callback when camera fails to initialize. Ex: `onError={(e) => console.log(e.nativeEvent.errorMessage)}`. | | `shutterPhotoSound` | `boolean` | Android only. Enable or disable the shutter sound when capturing a photo. Default: `true` | | **iOS only** | -| `ratioOverlay` | `'int:int'` | Show a guiding overlay in the camera preview for the selected ratio. Does not crop image as of v9.0. Example: `'16:9'` | +| `ratioOverlay` | `'width:height'` | Show a guiding overlay in the camera preview for the selected ratio, expressed as `width:height`. The largest area of that ratio is centred in the preview and the rest is masked, so `'4:5'` is taller than it is wide and `'16:9'` is wider than it is tall. Does not crop image as of v9.0. Examples: `'16:9'`, `'1:1'`, `'4:5'` | | `ratioOverlayColor` | Color | Any color with alpha. Default: `'#ffffff77'` | | `resetFocusTimeout` | `number` | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. | | `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. | diff --git a/ios/ReactNativeCameraKit/RatioOverlayView.swift b/ios/ReactNativeCameraKit/RatioOverlayView.swift index a98e2cc246..f19dd7eea9 100644 --- a/ios/ReactNativeCameraKit/RatioOverlayView.swift +++ b/ios/ReactNativeCameraKit/RatioOverlayView.swift @@ -5,6 +5,12 @@ import UIKit +/* + * A `width:height` ratio, as accepted by the `ratioOverlay` prop, eg. "16:9" or "4:5". + * + * Width comes first, so "4:5" describes a window that is taller than it is wide, + * and "16:9" one that is wider than it is tall. + */ struct RatioOverlayData: CustomStringConvertible { let width: Float let height: Float @@ -14,24 +20,50 @@ struct RatioOverlayData: CustomStringConvertible { let values = inputString.split(separator: ":") if values.count == 2, - let inputHeight = Float(values[0]), - let inputWidth = Float(values[1]), - inputHeight != 0, - inputWidth != 0 { - height = inputHeight + let inputWidth = Float(values[0]), + let inputHeight = Float(values[1]), + inputWidth > 0, + inputHeight > 0 { width = inputWidth + height = inputHeight ratio = width / height } else { - height = 0 width = 0 + height = 0 ratio = 0 } } + /* + * The largest rect of this ratio that fits inside `size`, centered. + * + * Returns nil when there is nothing to draw, ie. the ratio was unparseable + * or the container has not been laid out yet. + */ + func centeredWindow(fitting size: CGSize) -> CGRect? { + guard ratio > 0, size.width > 0, size.height > 0 else { + return nil + } + + let targetRatio = CGFloat(ratio) + let containerRatio = size.width / size.height + + // Aspect fit: the tighter of the two axes spans the container, + // the other one is derived from the requested ratio. + let windowSize = targetRatio > containerRatio + ? CGSize(width: size.width, height: size.width / targetRatio) + : CGSize(width: size.height * targetRatio, height: size.height) + + return CGRect(x: (size.width - windowSize.width) / 2.0, + y: (size.height - windowSize.height) / 2.0, + width: windowSize.width, + height: windowSize.height) + } + // MARK: CustomStringConvertible var description: String { - return "height:\(height) width:\(width) ratio:\(ratio)" + return "width:\(width) height:\(height) ratio:\(ratio)" } } @@ -41,8 +73,10 @@ struct RatioOverlayData: CustomStringConvertible { class RatioOverlayView: UIView { private var ratioData: RatioOverlayData? - private let topView: UIView = UIView() - private let bottomView: UIView = UIView() + // The two bars framing the ratio window: above/below it when letterboxing, + // left/right of it when pillarboxing. + private let leadingBarView: UIView = UIView() + private let trailingBarView: UIView = UIView() // MARK: - Lifecycle @@ -54,8 +88,8 @@ class RatioOverlayView: UIView { let color = overlayColor ?? UIColor.black.withAlphaComponent(0.3) setColor(color) - addSubview(topView) - addSubview(bottomView) + addSubview(leadingBarView) + addSubview(trailingBarView) setRatio(ratioString) } @@ -82,15 +116,14 @@ class RatioOverlayView: UIView { } func setColor(_ color: UIColor) { - topView.backgroundColor = color - bottomView.backgroundColor = color + leadingBarView.backgroundColor = color + trailingBarView.backgroundColor = color } // MARK: - Private - // swiftlint:disable:next function_body_length private func setOverlayParts() { - guard let ratioData, ratioData.ratio != 0 else { + guard let window = ratioData?.centeredWindow(fitting: bounds.size) else { isHidden = true return @@ -98,67 +131,26 @@ class RatioOverlayView: UIView { isHidden = false - var centerSize = CGSize.zero - var sideSize = CGSize.zero - var centerFrame: CGRect - - if ratioData.width < ratioData.height { - centerSize.width = frame.size.width - centerSize.height = frame.size.height * CGFloat(ratioData.ratio) - - sideSize.width = centerSize.width - sideSize.height = (frame.size.height - centerSize.height) / 2.0 - - topView.frame = CGRect(x: 0, - y: 0, - width: sideSize.width, - height: sideSize.height) - centerFrame = CGRect(x: 0, - y: topView.frame.size.height + topView.frame.origin.y, - width: centerSize.width, - height: centerSize.height) - bottomView.frame = CGRect(x: 0, - y: centerFrame.size.height + centerFrame.origin.y, - width: sideSize.width, - height: sideSize.height) - } else if ratioData.width > ratioData.height { - centerSize.width = frame.size.width / CGFloat(ratioData.ratio) - centerSize.height = frame.size.height - - sideSize.width = (frame.size.width - centerSize.width) / 2.0 - sideSize.height = centerSize.height - - topView.frame = CGRect(x: 0, - y: 0, - width: sideSize.width, - height: sideSize.height) - centerFrame = CGRect(x: topView.frame.size.width + topView.frame.origin.x, - y: 0, - width: centerSize.width, - height: centerSize.height) - bottomView.frame = CGRect(x: centerFrame.size.width + centerFrame.origin.x, - y: 0, - width: sideSize.width, - height: sideSize.height) - } else { // ratio is 1:1 - centerSize.width = frame.size.width - centerSize.height = frame.size.width - - sideSize.width = centerSize.width - sideSize.height = (frame.size.height - centerSize.height) / 2.0 - - topView.frame = CGRect(x: 0, - y: 0, - width: sideSize.width, - height: sideSize.height) - centerFrame = CGRect(x: 0, - y: topView.frame.size.height + topView.frame.origin.y, - width: centerSize.width, - height: centerSize.height) - bottomView.frame = CGRect(x: 0, - y: centerFrame.size.height + centerFrame.origin.y, - width: sideSize.width, - height: sideSize.height) + if window.height < bounds.height { + // Letterbox: mask the strips above and below the ratio window. + leadingBarView.frame = CGRect(x: 0, + y: 0, + width: bounds.width, + height: window.minY) + trailingBarView.frame = CGRect(x: 0, + y: window.maxY, + width: bounds.width, + height: bounds.height - window.maxY) + } else { + // Pillarbox: mask the strips left and right of the ratio window. + leadingBarView.frame = CGRect(x: 0, + y: 0, + width: window.minX, + height: bounds.height) + trailingBarView.frame = CGRect(x: window.maxX, + y: 0, + width: bounds.width - window.maxX, + height: bounds.height) } } }