jsdw_ios/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraView.s...

647 lines
21 KiB
Swift

//
// 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()
}
}