import SwiftUI import AVFoundation import UIKit import Combine /// 异常项快拍 · 局部相机。 /// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。 /// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。 /// /// 裁剪原理:`previewLayer.metadataOutputRectConverted(fromLayerRect:)` 把屏上小框换算成 /// 归一化(0-1)裁剪 rect;先把拍到的照片方向 bake 成 `.up`,再按归一化 rect 裁 CGImage。 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 { /// 按归一化 rect(原点左上、范围 0-1、与显示方向一致)裁剪 `.up` 方向的图。 /// 入参 image 须已 bake 成 `.up`(见 `UIImage.normalizedUp()`)。越界自动夹紧;失败回退原图。 static func crop(_ image: UIImage, normalizedRect: CGRect) -> UIImage { guard let cg = image.cgImage else { return image } let w = CGFloat(cg.width), h = CGFloat(cg.height) let nx = max(0, min(1, normalizedRect.origin.x)) let ny = max(0, min(1, normalizedRect.origin.y)) let nw = max(0, min(1 - nx, normalizedRect.size.width)) let nh = max(0, min(1 - ny, normalizedRect.size.height)) let rect = CGRect(x: nx * w, y: ny * h, width: nw * w, height: nh * h).integral 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 let preview = previewLayer else { deliver(upright) return } // metadataOutputRectConverted 读 previewLayer 几何,回主线程算更稳。 DispatchQueue.main.async { let box = RegionFraming.box(in: self.bounds.size) let normalized = preview.metadataOutputRectConverted(fromLayerRect: box) let cropped = RegionImageCropper.crop(upright, normalizedRect: normalized) completion?(cropped) } } }