// // PigeonWatermarkCameraView.swift // QuickLocation // import UIKit import AVFoundation final class PigeonWatermarkCameraView: UIView { var onClose: (() -> Void)? var onConfirm: ((UIImage) -> Void)? private let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "pigeon.watermark.camera") private var previewLayer: AVCaptureVideoPreviewLayer? private var videoInput: AVCaptureDeviceInput? private var photoOutput: AVCapturePhotoOutput? private var cameraPosition: AVCaptureDevice.Position = .back private var isTorchOn = false private var isReviewing = false private var isCapturing = false private var didConfigureSession = false private var capturedImage: UIImage? private var captureDate = Date() private var addressText = "" override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() applyReviewState(false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { let session = self.session sessionQueue.async { if session.isRunning { session.stopRunning() } } } func startSession() { requestAccessAndConfigure() } func stopSession() { let session = self.session sessionQueue.async { if session.isRunning { session.stopRunning() } } } func updateAddress(_ address: String) { addressText = address if isReviewing { refreshWatermark(at: captureDate) } } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = previewHost.bounds shutterButton.setNeedsDisplay() } private func setupUI() { addSubview(gradientView) addSubview(navView) addSubview(previewContainer) previewContainer.addSubview(previewHost) previewContainer.addSubview(capturedImageView) previewContainer.addSubview(watermarkView) addSubview(flashButton) addSubview(shutterButton) addSubview(switchButton) addSubview(retakeButton) addSubview(confirmButton) gradientView.layoutChain .top() .edgesHorzontal() .height(135) navView.layoutChain.edges(excludingEdge: .bottom).height(kNaviHeight) navView.onBackTapped = { [weak self] in self?.onClose?() } previewContainer.layoutChain .top(kNaviHeight + 73) .edgesHorzontal(30) .heightToWidth(424/315) previewHost.layoutChain.edges() capturedImageView.layoutChain.edges() watermarkView.layoutChain .left(12) .top(12) watermarkView.rightAnchor.constraint( lessThanOrEqualTo: previewContainer.rightAnchor, constant: -12 ).isActive = true shutterButton.layoutChain .centerX() .bottom(kSafeBottomMargin + 18) .width(84) .height(84) flashButton.layoutChain .centerY(shutterButton) .rightToLeftOfView(shutterButton, offset: -52) .width(50) .height(50) switchButton.layoutChain .centerY(shutterButton) .leftToRightOfView(shutterButton, offset: 52) .width(50) .height(50) retakeButton.layoutChain .left(30) .bottom(kSafeBottomMargin + 24) .height(50) .width(110) confirmButton.layoutChain .leftToRightOfView(retakeButton, offset: 16) .right(30) .bottomToView(retakeButton) .height(50) } func requestAccessAndConfigure() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: configureSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { if granted { self?.configureSession() } else { DLToast.show(text: "请在设置中开启相机权限") } } } default: DLToast.show(text: "请在设置中开启相机权限") } } private func configureSession() { sessionQueue.async { [weak self] in guard let self else { return } if !self.didConfigureSession { self.session.beginConfiguration() self.session.sessionPreset = .photo _ = self.attachVideoInput(position: self.cameraPosition) let output = AVCapturePhotoOutput() if self.session.canAddOutput(output) { self.session.addOutput(output) self.photoOutput = output } self.session.commitConfiguration() self.didConfigureSession = true DispatchQueue.main.async { let layer = AVCaptureVideoPreviewLayer(session: self.session) layer.videoGravity = .resizeAspectFill layer.frame = self.previewHost.bounds self.previewHost.layer.insertSublayer(layer, at: 0) self.previewLayer = layer self.applyVideoOrientation() } } self.applyVideoOrientation() if !self.session.isRunning { self.session.startRunning() } } } @discardableResult private func attachVideoInput(position: AVCaptureDevice.Position) -> Bool { if let videoInput { session.removeInput(videoInput) self.videoInput = nil } guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return false } session.addInput(input) videoInput = input cameraPosition = position return true } private func applyVideoOrientation() { if let conn = photoOutput?.connection(with: .video), conn.isVideoOrientationSupported { conn.videoOrientation = .portrait if conn.isVideoMirroringSupported { conn.automaticallyAdjustsVideoMirroring = false conn.isVideoMirrored = cameraPosition == .front } } DispatchQueue.main.async { [weak self] in guard let self, let conn = self.previewLayer?.connection, conn.isVideoOrientationSupported else { return } conn.videoOrientation = .portrait } } @objc private func tapFlash() { guard !isReviewing else { return } guard cameraPosition == .back, let device = videoInput?.device, device.hasTorch else { DLToast.show(text: "当前镜头不支持闪光灯") return } do { try device.lockForConfiguration() isTorchOn.toggle() device.torchMode = isTorchOn ? .on : .off device.unlockForConfiguration() flashButton.alpha = isTorchOn ? 1 : 0.72 } catch { DLToast.show(text: "闪光灯开启失败") } } @objc private func tapSwitch() { guard !isReviewing else { return } let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back sessionQueue.async { [weak self] in guard let self else { return } self.session.beginConfiguration() _ = self.attachVideoInput(position: next) if self.isTorchOn, next == .front { self.isTorchOn = false } self.applyVideoOrientation() self.session.commitConfiguration() DispatchQueue.main.async { self.flashButton.alpha = self.isTorchOn ? 1 : 0.72 } } } @objc private func tapShutter() { guard !isReviewing, !isCapturing, let photoOutput else { return } isCapturing = true let settings = AVCapturePhotoSettings() if let device = videoInput?.device, device.hasFlash, cameraPosition == .back { settings.flashMode = isTorchOn ? .on : .off } photoOutput.capturePhoto(with: settings, delegate: self) } @objc private func tapRetake() { capturedImage = nil capturedImageView.image = nil isReviewing = false isCapturing = false applyReviewState(false) } @objc private func tapConfirm() { guard let image = makeWatermarkedImage() else { return } onConfirm?(image) } private func applyReviewState(_ reviewing: Bool) { capturedImageView.isHidden = !reviewing watermarkView.isHidden = !reviewing flashButton.isHidden = reviewing shutterButton.isHidden = reviewing switchButton.isHidden = reviewing retakeButton.isHidden = !reviewing confirmButton.isHidden = !reviewing } private func refreshWatermark(at date: Date) { let timeFormatter = DateFormatter() timeFormatter.locale = Locale(identifier: "zh_CN") timeFormatter.dateFormat = "HH:mm" timeLabel.text = timeFormatter.string(from: date) let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "zh_CN") dateFormatter.dateFormat = "yyyy-M-d" dateLabel.text = dateFormatter.string(from: date) let weekday = Calendar(identifier: .gregorian).component(.weekday, from: date) let names = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"] weekdayLabel.text = names[max(0, min(6, weekday - 1))] let hasAddress = !addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty locationRow.isHidden = !hasAddress addressLabel.text = addressText } private func makeWatermarkedImage() -> UIImage? { guard let capturedImage else { return nil } layoutIfNeeded() let target = previewContainer.bounds.size let watermarkSize = watermarkView.bounds.size let watermarkFrame = watermarkView.frame guard target.width > 1, target.height > 1, watermarkSize.width > 1 else { return capturedImage } watermarkBlurView.isHidden = true watermarkTintView.isHidden = true let borderWidth = watermarkView.layer.borderWidth watermarkView.layer.borderWidth = 0 layoutIfNeeded() let scale = UIScreen.main.scale let clearFormat = UIGraphicsImageRendererFormat.default() clearFormat.scale = scale clearFormat.opaque = false let contentImage = UIGraphicsImageRenderer(size: watermarkSize, format: clearFormat).image { ctx in ctx.cgContext.clear(CGRect(origin: .zero, size: watermarkSize)) watermarkView.drawHierarchy( in: CGRect(origin: .zero, size: watermarkSize), afterScreenUpdates: true ) } watermarkBlurView.isHidden = false watermarkTintView.isHidden = false watermarkView.layer.borderWidth = borderWidth let photoFormat = UIGraphicsImageRendererFormat.default() photoFormat.scale = scale photoFormat.opaque = true return UIGraphicsImageRenderer(size: target, format: photoFormat).image { _ in Self.drawAspectFill(capturedImage, in: CGRect(origin: .zero, size: target)) let card = UIBezierPath(roundedRect: watermarkFrame, cornerRadius: 14) UIColor.white.withAlphaComponent(0.28).setFill() card.fill() UIColor.white.setStroke() card.lineWidth = 1 card.stroke() contentImage.draw(in: watermarkFrame) } } private static func drawAspectFill(_ image: UIImage, in rect: CGRect) { let imgSize = image.size guard imgSize.width > 0, imgSize.height > 0 else { return } let scale = max(rect.width / imgSize.width, rect.height / imgSize.height) let scaled = CGSize(width: imgSize.width * scale, height: imgSize.height * scale) let origin = CGPoint( x: rect.midX - scaled.width / 2, y: rect.midY - scaled.height / 2 ) image.draw(in: CGRect(origin: origin, size: scaled)) } private lazy var gradientView: PigeonCameraGradientView = { let view = PigeonCameraGradientView() view.isUserInteractionEnabled = false return view }() private lazy var navView: BaseNavigationView = { BaseNavigationView(title: "水印拍照") }() private lazy var previewContainer: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#DDE1E5") view.layer.cornerRadius = 24 view.clipsToBounds = true return view }() private lazy var previewHost: UIView = { let view = UIView() view.backgroundColor = .black return view }() private lazy var capturedImageView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.clipsToBounds = true view.isHidden = true return view }() private lazy var watermarkBlurView: UIVisualEffectView = { let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight)) view.isUserInteractionEnabled = false return view }() private lazy var watermarkTintView: UIView = { let view = UIView() view.backgroundColor = UIColor.white.withAlphaComponent(0.08) view.isOpaque = false view.isUserInteractionEnabled = false return view }() private lazy var timeChipView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#00ADFE") view.layer.cornerRadius = 10 let logo = UIView() logo.backgroundColor = .white logo.layer.cornerRadius = 6 timeLabel.setContentHuggingPriority(.required, for: .horizontal) timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) view.addSubview(logo) view.addSubview(timeLabel) logo.layoutChain .left(6) .centerY() .width(22) .height(22) timeLabel.layoutChain .leftToRightOfView(logo, offset: 6) .right(8) .top(6) .bottom(6) return view }() private lazy var watermarkView: UIView = { let view = UIView() view.backgroundColor = .clear view.isOpaque = false view.layer.cornerRadius = 14 view.layer.borderWidth = 1 view.layer.borderColor = UIColor.white.cgColor view.clipsToBounds = true view.isHidden = true view.addSubview(watermarkBlurView) view.addSubview(watermarkTintView) watermarkBlurView.layoutChain.edges() watermarkTintView.layoutChain.edges() let dateColumn = UIStackView(arrangedSubviews: [dateLabel, weekdayLabel]) dateColumn.axis = .vertical dateColumn.spacing = 1 let topRow = UIStackView(arrangedSubviews: [timeChipView, dateColumn]) topRow.axis = .horizontal topRow.alignment = .center topRow.spacing = 8 let stack = UIStackView(arrangedSubviews: [topRow, locationRow]) stack.axis = .vertical stack.alignment = .fill stack.spacing = 8 view.addSubview(stack) stack.layoutChain .top(10) .left(10) .right(12) .bottom(10) return view }() private lazy var timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 22, weight: .semibold) label.textColor = .white return label }() private lazy var dateLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 11, weight: .medium) label.textColor = UIColor(hexStr: "#293445") return label }() private lazy var weekdayLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 11, weight: .medium) label.textColor = UIColor(hexStr: "#293445") return label }() private lazy var addressLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 11, weight: .medium) label.textColor = UIColor(hexStr: "#293445") label.numberOfLines = 2 label.lineBreakMode = .byTruncatingTail return label }() private lazy var locationRow: UIView = { let row = UIView() row.isHidden = true let pin = UIImageView(image: UIImage(named: "PigeonMessage/sparkle")) pin.contentMode = .scaleAspectFit row.addSubview(pin) row.addSubview(addressLabel) pin.layoutChain .left() .top(1) .width(11) .height(11) addressLabel.layoutChain .leftToRightOfView(pin, offset: 4) .right() .top() .bottom() return row }() private lazy var flashButton: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "PigeonMessage/flash"), for: .normal) button.alpha = 0.72 button.addTarget(self, action: #selector(tapFlash), for: .touchUpInside) return button }() private lazy var switchButton: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "PigeonMessage/camera_switch"), for: .normal) button.addTarget(self, action: #selector(tapSwitch), for: .touchUpInside) return button }() private lazy var shutterButton: PigeonShutterButton = { let button = PigeonShutterButton() button.addTarget(self, action: #selector(tapShutter), for: .touchUpInside) return button }() private lazy var retakeButton: UIButton = { let button = UIButton(type: .custom) button.setTitle("重拍", for: .normal) button.setTitleColor(UIColor(hexStr: "#13B6F1"), for: .normal) button.titleLabel?.font = FontManager.boboBold(18) button.backgroundColor = .white button.layer.cornerRadius = 16 button.layer.borderWidth = 1.5 button.layer.borderColor = UIColor(hexStr: "#13B6F1").cgColor button.addTarget(self, action: #selector(tapRetake), for: .touchUpInside) return button }() private lazy var confirmButton: UIButton = { let button = UIButton(type: .custom) button.setTitle("确定", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = FontManager.boboBold(18) button.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) button.clipsToBounds = true button.cornerRadius = 16 button.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside) return button }() } extension PigeonWatermarkCameraView: AVCapturePhotoCaptureDelegate { func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { isCapturing = false if let error { DLToast.show(text: "拍照失败:\(error.localizedDescription)") return } guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { DLToast.show(text: "拍照失败") return } DispatchQueue.main.async { [weak self] in guard let self else { return } self.captureDate = Date() self.capturedImage = image self.capturedImageView.image = image self.refreshWatermark(at: self.captureDate) self.isReviewing = true self.applyReviewState(true) } } } private final class PigeonCameraGradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } override init(frame: CGRect) { super.init(frame: frame) guard let gradient = layer as? CAGradientLayer else { return } gradient.colors = [ UIColor(hexStr: "#BAF4FF").cgColor, UIColor(hexStr: "#BAF4FF", alpha: 0).cgColor ] gradient.startPoint = CGPoint(x: 0.5, y: 0) gradient.endPoint = CGPoint(x: 0.5, y: 1) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private final class PigeonShutterButton: UIControl { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() setNeedsDisplay() } override func draw(_ rect: CGRect) { let color = UIColor(hexStr: "#13B6F1") let ring = UIBezierPath(ovalIn: rect.insetBy(dx: 3, dy: 3)) color.setStroke() ring.lineWidth = 4 ring.stroke() color.setFill() UIBezierPath(ovalIn: rect.insetBy(dx: 11, dy: 11)).fill() } }