457 lines
16 KiB
Swift
457 lines
16 KiB
Swift
//
|
|
// ImagePicker.swift
|
|
// SodaLive
|
|
//
|
|
// Created by klaus on 2023/08/09.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct ImagePicker: UIViewControllerRepresentable {
|
|
|
|
@Binding var isShowing: Bool
|
|
@Binding var selectedImage: UIImage?
|
|
|
|
let sourceType: UIImagePickerController.SourceType
|
|
|
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
let picker = UIImagePickerController()
|
|
picker.delegate = context.coordinator
|
|
picker.sourceType = sourceType
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
return Coordinator(self)
|
|
}
|
|
}
|
|
|
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
let parent: ImagePicker
|
|
|
|
init(_ parent: ImagePicker) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
|
if let image = info[.originalImage] as? UIImage {
|
|
parent.selectedImage = image
|
|
parent.isShowing = false
|
|
}
|
|
}
|
|
|
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
parent.isShowing = false
|
|
}
|
|
}
|
|
|
|
enum ImageCropAspectPolicy {
|
|
case square
|
|
case free
|
|
}
|
|
|
|
private enum CropHandle: CaseIterable, Identifiable {
|
|
case topLeading
|
|
case topTrailing
|
|
case bottomLeading
|
|
case bottomTrailing
|
|
|
|
var id: Self { self }
|
|
|
|
var xDirection: CGFloat {
|
|
switch self {
|
|
case .topLeading, .bottomLeading:
|
|
return -1
|
|
case .topTrailing, .bottomTrailing:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
var yDirection: CGFloat {
|
|
switch self {
|
|
case .topLeading, .topTrailing:
|
|
return -1
|
|
case .bottomLeading, .bottomTrailing:
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ImageCropEditorView: View {
|
|
|
|
let image: UIImage
|
|
let aspectPolicy: ImageCropAspectPolicy
|
|
let onCancel: () -> Void
|
|
let onComplete: (UIImage) -> Void
|
|
private let normalizedImage: UIImage
|
|
|
|
@State private var canvasSize: CGSize = .zero
|
|
@State private var scale: CGFloat = 1
|
|
@State private var lastScale: CGFloat = 1
|
|
@State private var offset: CGSize = .zero
|
|
@State private var lastOffset: CGSize = .zero
|
|
@State private var freeCropSize: CGSize = .zero
|
|
@State private var lastFreeCropSize: CGSize = .zero
|
|
|
|
init(
|
|
image: UIImage,
|
|
aspectPolicy: ImageCropAspectPolicy,
|
|
onCancel: @escaping () -> Void,
|
|
onComplete: @escaping (UIImage) -> Void
|
|
) {
|
|
self.image = image
|
|
self.aspectPolicy = aspectPolicy
|
|
self.onCancel = onCancel
|
|
self.onComplete = onComplete
|
|
self.normalizedImage = image.normalizedForCrop()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Button(action: onCancel) {
|
|
Text("취소")
|
|
.appFont(size: 16.7, weight: .bold)
|
|
.foregroundColor(Color.grayee)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
if let croppedImage = cropImage() {
|
|
onComplete(croppedImage)
|
|
} else {
|
|
onCancel()
|
|
}
|
|
}) {
|
|
Text("적용")
|
|
.appFont(size: 16.7, weight: .bold)
|
|
.foregroundColor(Color.button)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
.background(Color.black.opacity(0.96))
|
|
.zIndex(2)
|
|
|
|
GeometryReader { proxy in
|
|
let currentCanvasSize = proxy.size
|
|
let currentCropSize = cropSize(in: currentCanvasSize)
|
|
|
|
ZStack {
|
|
Color.black
|
|
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.scaleEffect(scale)
|
|
.offset(offset)
|
|
.frame(width: currentCanvasSize.width, height: currentCanvasSize.height)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
offset = CGSize(
|
|
width: lastOffset.width + value.translation.width,
|
|
height: lastOffset.height + value.translation.height
|
|
)
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize)
|
|
}
|
|
.onEnded { _ in
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize)
|
|
lastOffset = offset
|
|
}
|
|
)
|
|
.simultaneousGesture(
|
|
MagnificationGesture()
|
|
.onChanged { value in
|
|
scale = max(lastScale * value, 1)
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize)
|
|
}
|
|
.onEnded { _ in
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize)
|
|
lastOffset = offset
|
|
lastScale = scale
|
|
}
|
|
)
|
|
|
|
cropOverlay(cropSize: currentCropSize, canvasSize: currentCanvasSize)
|
|
.allowsHitTesting(false)
|
|
|
|
if aspectPolicy == .free {
|
|
ForEach(CropHandle.allCases) { handle in
|
|
Circle()
|
|
.fill(Color.button)
|
|
.frame(width: 24, height: 24)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.95), lineWidth: 2)
|
|
)
|
|
.position(handlePosition(handle: handle, cropSize: currentCropSize, canvasSize: currentCanvasSize))
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
resizeFreeCrop(
|
|
with: value.translation,
|
|
handle: handle,
|
|
canvasSize: currentCanvasSize,
|
|
currentCropSize: currentCropSize
|
|
)
|
|
}
|
|
.onEnded { _ in
|
|
lastFreeCropSize = freeCropSize
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: freeCropSize)
|
|
lastOffset = offset
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.clipped()
|
|
.onAppear {
|
|
canvasSize = currentCanvasSize
|
|
if aspectPolicy == .free, freeCropSize == .zero {
|
|
freeCropSize = defaultFreeCropSize(in: currentCanvasSize)
|
|
lastFreeCropSize = freeCropSize
|
|
}
|
|
adjustTransforms(canvasSize: currentCanvasSize, cropSize: currentCropSize)
|
|
lastOffset = offset
|
|
lastScale = scale
|
|
}
|
|
.onChange(of: proxy.size, perform: { newSize in
|
|
canvasSize = newSize
|
|
if aspectPolicy == .free, freeCropSize == .zero {
|
|
freeCropSize = defaultFreeCropSize(in: newSize)
|
|
lastFreeCropSize = freeCropSize
|
|
}
|
|
adjustTransforms(canvasSize: newSize, cropSize: cropSize(in: newSize))
|
|
lastOffset = offset
|
|
lastScale = scale
|
|
})
|
|
}
|
|
|
|
if aspectPolicy == .free {
|
|
Text("모서리 원을 드래그해서 크롭 영역 크기를 조정하세요")
|
|
.appFont(size: 13.3, weight: .medium)
|
|
.foregroundColor(Color.grayee)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
}
|
|
|
|
Color.black
|
|
.frame(height: 8)
|
|
}
|
|
.background(Color.black)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func cropOverlay(cropSize: CGSize, canvasSize: CGSize) -> some View {
|
|
let cropRect = CGRect(
|
|
x: (canvasSize.width - cropSize.width) / 2,
|
|
y: (canvasSize.height - cropSize.height) / 2,
|
|
width: cropSize.width,
|
|
height: cropSize.height
|
|
)
|
|
|
|
ZStack {
|
|
Color.black.opacity(0.6)
|
|
|
|
Rectangle()
|
|
.frame(width: cropSize.width, height: cropSize.height)
|
|
.position(x: canvasSize.width / 2, y: canvasSize.height / 2)
|
|
.blendMode(.destinationOut)
|
|
}
|
|
.compositingGroup()
|
|
|
|
Rectangle()
|
|
.stroke(Color.white.opacity(0.9), lineWidth: 1.5)
|
|
.frame(width: cropRect.width, height: cropRect.height)
|
|
.position(x: cropRect.midX, y: cropRect.midY)
|
|
}
|
|
|
|
private func cropSize(in canvas: CGSize) -> CGSize {
|
|
switch aspectPolicy {
|
|
case .square:
|
|
let side = max(120, min(canvas.width, canvas.height) * 0.72)
|
|
return CGSize(width: side, height: side)
|
|
case .free:
|
|
if freeCropSize == .zero {
|
|
return defaultFreeCropSize(in: canvas)
|
|
}
|
|
|
|
let minCropSize: CGFloat = 120
|
|
let maxCropWidth = max(minCropSize, canvas.width - 24)
|
|
let maxCropHeight = max(minCropSize, canvas.height - 24)
|
|
return CGSize(
|
|
width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth),
|
|
height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func defaultFreeCropSize(in canvas: CGSize) -> CGSize {
|
|
let width = max(120, min(canvas.width * 0.82, canvas.width - 24))
|
|
let height = max(120, min(canvas.height * 0.58, canvas.height - 24))
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint {
|
|
let centerX = canvasSize.width / 2
|
|
let centerY = canvasSize.height / 2
|
|
let halfWidth = cropSize.width / 2
|
|
let halfHeight = cropSize.height / 2
|
|
|
|
let x = centerX + (halfWidth * handle.xDirection)
|
|
let y = centerY + (halfHeight * handle.yDirection)
|
|
return CGPoint(x: x, y: y)
|
|
}
|
|
|
|
private func resizeFreeCrop(
|
|
with translation: CGSize,
|
|
handle: CropHandle,
|
|
canvasSize: CGSize,
|
|
currentCropSize: CGSize
|
|
) {
|
|
if lastFreeCropSize == .zero {
|
|
lastFreeCropSize = currentCropSize
|
|
}
|
|
|
|
let minCropSize: CGFloat = 120
|
|
let maxCropWidth = max(minCropSize, canvasSize.width - 24)
|
|
let maxCropHeight = max(minCropSize, canvasSize.height - 24)
|
|
|
|
let width = (lastFreeCropSize.width + (translation.width * handle.xDirection))
|
|
.clamped(min: minCropSize, max: maxCropWidth)
|
|
let height = (lastFreeCropSize.height + (translation.height * handle.yDirection))
|
|
.clamped(min: minCropSize, max: maxCropHeight)
|
|
|
|
freeCropSize = CGSize(width: width, height: height)
|
|
adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize)
|
|
}
|
|
|
|
private func adjustTransforms(canvasSize: CGSize, cropSize: CGSize) {
|
|
guard canvasSize.width > 0, canvasSize.height > 0 else {
|
|
return
|
|
}
|
|
|
|
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
|
|
guard fittedSize.width > 0, fittedSize.height > 0 else {
|
|
return
|
|
}
|
|
|
|
let minScale = max(cropSize.width / fittedSize.width, cropSize.height / fittedSize.height)
|
|
scale = scale.clamped(min: minScale, max: 6)
|
|
|
|
let scaledWidth = fittedSize.width * scale
|
|
let scaledHeight = fittedSize.height * scale
|
|
|
|
let limitX = max(0, (scaledWidth - cropSize.width) / 2)
|
|
let limitY = max(0, (scaledHeight - cropSize.height) / 2)
|
|
|
|
offset = CGSize(
|
|
width: offset.width.clamped(min: -limitX, max: limitX),
|
|
height: offset.height.clamped(min: -limitY, max: limitY)
|
|
)
|
|
}
|
|
|
|
private func cropImage() -> UIImage? {
|
|
let cropSize = cropSize(in: canvasSize)
|
|
guard canvasSize.width > 0, canvasSize.height > 0 else {
|
|
return nil
|
|
}
|
|
|
|
guard let cgImage = normalizedImage.cgImage else {
|
|
return nil
|
|
}
|
|
|
|
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
|
|
guard fittedSize.width > 0, fittedSize.height > 0 else {
|
|
return nil
|
|
}
|
|
|
|
let scaledWidth = fittedSize.width * scale
|
|
let scaledHeight = fittedSize.height * scale
|
|
guard scaledWidth > 0, scaledHeight > 0 else {
|
|
return nil
|
|
}
|
|
|
|
let cropXInScaled = ((scaledWidth - cropSize.width) / 2) - offset.width
|
|
let cropYInScaled = ((scaledHeight - cropSize.height) / 2) - offset.height
|
|
|
|
let xRatio = normalizedImage.size.width / scaledWidth
|
|
let yRatio = normalizedImage.size.height / scaledHeight
|
|
|
|
var cropRect = CGRect(
|
|
x: cropXInScaled * xRatio,
|
|
y: cropYInScaled * yRatio,
|
|
width: cropSize.width * xRatio,
|
|
height: cropSize.height * yRatio
|
|
).integral
|
|
|
|
cropRect.origin.x = cropRect.origin.x.clamped(min: 0, max: max(0, normalizedImage.size.width - 1))
|
|
cropRect.origin.y = cropRect.origin.y.clamped(min: 0, max: max(0, normalizedImage.size.height - 1))
|
|
cropRect.size.width = cropRect.size.width.clamped(min: 1, max: normalizedImage.size.width - cropRect.origin.x)
|
|
cropRect.size.height = cropRect.size.height.clamped(min: 1, max: normalizedImage.size.height - cropRect.origin.y)
|
|
|
|
guard let croppedCgImage = cgImage.cropping(to: cropRect) else {
|
|
return nil
|
|
}
|
|
|
|
let croppedImage = UIImage(cgImage: croppedCgImage, scale: normalizedImage.scale, orientation: .up)
|
|
return croppedImage.resizedToMaxDimension(800)
|
|
}
|
|
|
|
private func fittedImageSize(imageSize: CGSize, canvasSize: CGSize) -> CGSize {
|
|
guard imageSize.width > 0, imageSize.height > 0 else {
|
|
return .zero
|
|
}
|
|
|
|
let ratio = min(canvasSize.width / imageSize.width, canvasSize.height / imageSize.height)
|
|
return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio)
|
|
}
|
|
}
|
|
|
|
extension UIImage {
|
|
func normalizedForCrop() -> UIImage {
|
|
if imageOrientation == .up {
|
|
return self
|
|
}
|
|
|
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
return renderer.image { _ in
|
|
draw(in: CGRect(origin: .zero, size: size))
|
|
}
|
|
}
|
|
|
|
func resizedToMaxDimension(_ maxDimension: CGFloat) -> UIImage {
|
|
guard size.width > 0, size.height > 0 else {
|
|
return self
|
|
}
|
|
|
|
let largestSide = max(size.width, size.height)
|
|
guard largestSide > maxDimension else {
|
|
return self
|
|
}
|
|
|
|
let scaleRatio = maxDimension / largestSide
|
|
let targetSize = CGSize(width: size.width * scaleRatio, height: size.height * scaleRatio)
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|
format.scale = 1
|
|
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
|
|
|
return renderer.image { _ in
|
|
draw(in: CGRect(origin: .zero, size: targetSize))
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension CGFloat {
|
|
func clamped(min lower: CGFloat, max upper: CGFloat) -> CGFloat {
|
|
Swift.max(lower, Swift.min(self, upper))
|
|
}
|
|
}
|