import SwiftUI import AVFoundation import UIKit import Combine /// 异常项快拍 · 局部相机。 /// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。 /// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。 /// /// 裁剪原理:先把拍到的照片 bake 成 `.up`(竖屏),再用纯几何 aspect-fill 反算把屏上小框 /// (view 点坐标)映射到照片像素 rect(见 `RegionImageCropper`)。 /// 不用 `metadataOutputRectConverted` —— 它返回传感器横向坐标,套到竖屏照片会轴对调裁出竖条。 struct RegionCameraView: View { let onCapture: (UIImage) -> Void let onCancel: () -> Void @StateObject private var controller = RegionCameraController() @State private var authState: AuthState = .checking @State private var isCapturing = false @State private var flash = false enum AuthState { case checking, authorized, denied } var body: some View { ZStack { Color.black.ignoresSafeArea() switch authState { case .checking: ProgressView().tint(.white) case .denied: deniedView case .authorized: cameraStack } if flash { Color.white.ignoresSafeArea().transition(.opacity) } } .task { await resolveAuth() } } // MARK: - 相机 + 小框 + 控件 private var cameraStack: some View { GeometryReader { proxy in let box = RegionFraming.box(in: proxy.size) ZStack { RegionCameraPreview(controller: controller) .ignoresSafeArea() // 框外压暗(even-odd 挖空),只突出小框内 Canvas { ctx, size in var path = Path(CGRect(origin: .zero, size: size)) path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md)) ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true)) } .ignoresSafeArea() .allowsHitTesting(false) // 小框边 RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Color.white.opacity(0.95), style: StrokeStyle(lineWidth: 2, dash: [8, 6])) .frame(width: box.width, height: box.height) .position(x: box.midX, y: box.midY) .allowsHitTesting(false) // 提示 Text("把异常项放进框里 · 对准一两行") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Capsule().fill(.black.opacity(0.4))) .position(x: box.midX, y: box.minY - 22) .allowsHitTesting(false) controlsOverlay } } } private var controlsOverlay: some View { VStack { HStack { Button { onCancel() } label: { Text("取消") .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 8) .background(Capsule().fill(.black.opacity(0.35))) } Spacer() } .padding(.horizontal, 18) .padding(.top, 8) Spacer() shutterButton .padding(.bottom, 36) } } private var shutterButton: some View { Button { capture() } label: { ZStack { Circle().fill(.white).frame(width: 72, height: 72) Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84) if isCapturing { ProgressView().tint(.black) } } } .disabled(isCapturing) .accessibilityLabel("拍摄异常项") } private var deniedView: some View { VStack(spacing: 16) { Image(systemName: "camera.fill") .font(.system(size: 40)) .foregroundStyle(.white.opacity(0.8)) Text("相机权限未开启") .font(.tjH2()) .foregroundStyle(.white) Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。") .font(.system(size: 13)) .foregroundStyle(.white.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 36) HStack(spacing: 12) { Button("取消") { onCancel() } .font(.system(size: 15)) .foregroundStyle(.white) .padding(.horizontal, 18).padding(.vertical, 10) .background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1)) Button("去设置") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.black) .padding(.horizontal, 18).padding(.vertical, 10) .background(Capsule().fill(.white)) } } } // MARK: - 行为 private func capture() { guard !isCapturing else { return } isCapturing = true withAnimation(.easeOut(duration: 0.08)) { flash = true } controller.capture { image in withAnimation(.easeIn(duration: 0.15)) { flash = false } isCapturing = false guard let image else { return } onCapture(image) } } private func resolveAuth() async { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: authState = .authorized case .notDetermined: let granted = await AVCaptureDevice.requestAccess(for: .video) authState = granted ? .authorized : .denied default: authState = .denied } } } // MARK: - 小框几何(UIView 与 SwiftUI 覆盖层共用,保证坐标一致) enum RegionFraming { /// 居中、略高于中心的小框。宽 84% 屏宽,高取 160 与 28% 屏高的较小值。 static func box(in size: CGSize) -> CGRect { guard size.width > 0, size.height > 0 else { return .zero } let w = size.width * 0.84 let h = min(160, size.height * 0.28) let x = (size.width - w) / 2 let y = (size.height - h) / 2 - size.height * 0.06 return CGRect(x: x, y: y, width: w, height: h) } } // MARK: - 裁剪纯函数 enum RegionImageCropper { /// 把屏上小框(view 点坐标)按 `.resizeAspectFill` 反算到 `.up` 照片的像素裁剪 rect。 /// 前提:预览以 aspect-fill 铺满 viewSize,照片与预览同源、同为竖屏方向。 /// 纯几何、方向自洽 —— 不用 `metadataOutputRectConverted`(它返回传感器**横向**坐标, /// 套到竖屏照片会 x/y 轴对调,把宽框裁成竖窄条,见 RegionImageCropperTests)。越界自动夹紧。 static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect { guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero } // aspect-fill:取较大缩放系数让照片铺满视图,溢出部分被裁。 let scale = max(viewSize.width / p.width, viewSize.height / p.height) let scaledW = p.width * scale let scaledH = p.height * scale // 缩放后照片相对视图居中,溢出维度的原点为负。 let ox = (viewSize.width - scaledW) / 2 let oy = (viewSize.height - scaledH) / 2 // 视图点 → 照片像素:先减去居中偏移,再除以缩放系数。 var x = (box.minX - ox) / scale var y = (box.minY - oy) / scale var w = box.width / scale var h = box.height / scale // 夹紧到照片范围内。 x = max(0, min(p.width, x)) y = max(0, min(p.height, y)) w = max(0, min(p.width - x, w)) h = max(0, min(p.height - y, h)) return CGRect(x: x, y: y, width: w, height: h).integral } /// 按屏上小框裁 `.up` 照片(`box` / `viewSize` 同为 view 点坐标);失败回退原图。 static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage { guard let cg = image.cgImage else { return image } let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height), box: box, in: viewSize) guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image } return UIImage(cgImage: cropped, scale: image.scale, orientation: .up) } } extension UIImage { /// 把 EXIF 方向 bake 进像素,返回 `.up` 方向图,便于按归一化 rect 直接裁 CGImage。 func normalizedUp() -> UIImage { if imageOrientation == .up { return self } let format = UIGraphicsImageRendererFormat.default() format.scale = scale let renderer = UIGraphicsImageRenderer(size: size, format: format) return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) } } } // MARK: - AVFoundation 桥接 /// SwiftUI 持有,作为快门触发的句柄(weak 指向真正的 UIView)。 final class RegionCameraController: ObservableObject { weak var view: RegionPreviewUIView? func capture(_ completion: @escaping (UIImage?) -> Void) { guard let view else { completion(nil); return } view.capture(completion: completion) } } struct RegionCameraPreview: UIViewRepresentable { let controller: RegionCameraController func makeUIView(context: Context) -> RegionPreviewUIView { let v = RegionPreviewUIView() controller.view = v return v } func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {} static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) { uiView.stop() } } /// 实时预览 + 单张拍摄,拍完按小框裁剪。 final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { private let session = AVCaptureSession() private let output = AVCapturePhotoOutput() private var previewLayer: AVCaptureVideoPreviewLayer? private var setupDone = false private var captureCompletion: ((UIImage?) -> Void)? override func didMoveToWindow() { super.didMoveToWindow() guard !setupDone, window != nil else { return } setupDone = true configure() } private func configure() { session.beginConfiguration() session.sessionPreset = .photo guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { session.commitConfiguration() return } session.addInput(input) if session.canAddOutput(output) { session.addOutput(output) } session.commitConfiguration() let preview = AVCaptureVideoPreviewLayer(session: session) preview.videoGravity = .resizeAspectFill preview.frame = bounds layer.addSublayer(preview) self.previewLayer = preview applyPortrait(preview.connection) DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } } /// 锁竖屏(iOS 17+ 用 videoRotationAngle,避免 videoOrientation 弃用告警)。 private func applyPortrait(_ connection: AVCaptureConnection?) { guard let connection else { return } if connection.isVideoRotationAngleSupported(90) { connection.videoRotationAngle = 90 } } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds } func capture(completion: @escaping (UIImage?) -> Void) { guard session.isRunning else { completion(nil); return } captureCompletion = completion applyPortrait(output.connection(with: .video)) output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) } func stop() { guard session.isRunning else { return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() } } func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { let completion = captureCompletion captureCompletion = nil // 代理回调在 AVFoundation 私有队列,SwiftUI 状态更新必须切回主线程。 let deliver: (UIImage?) -> Void = { result in DispatchQueue.main.async { completion?(result) } } guard error == nil, let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { deliver(nil) return } let upright = image.normalizedUp() guard previewLayer != nil else { deliver(upright) return } // 裁剪走纯几何映射:预览以 .resizeAspectFill 铺满 bounds,照片与预览同源同为竖屏, // 故屏上小框可按 aspect-fill 反算到照片像素 rect。读 bounds 几何回主线程更稳。 DispatchQueue.main.async { let viewSize = self.bounds.size let box = RegionFraming.box(in: viewSize) let cropped = RegionImageCropper.crop(upright, box: box, viewSize: viewSize) completion?(cropped) } } }