CropBox를 활용해 이미지 자르기

이전까지 PHAsset을 활용해 사진을 가져오고 가져온 사진을 Core Image를 활용해 필터를 적용하는 방식까지 알아봤다

오늘은 이미지 편집 과정 중 크롭에 대해서 알아보려고 한다.

먼저 EditCropViewModel을 만들어 VM에서 해야할 역할을 만들어 주었다. 이 VM은 이미지의 크롭을 관리하는 주요 속성과 메서드를 가지게 된다.

@Published var cropRect: CGRect = .zero
@Published var cropApplied: Bool = false
@Published var isCropBoxVisible: Bool = true
@Published var croppedImage: UIImage?
var image: UIImage

먼저 속성을 정의해야한다.

 

cropRect는 크롭박스의 현재 위치와 크기를 나타낸다.

cropApplied는 크롭 작업이 실제로 적용되었는지를 나타낸다.

isCropBoxVisible은 크롭 박스가 화면에 보이는지 여부를 제어하는 역할을 한다.

croppedImage는 크롭된 이미지를 저장한다.

image는 원본 이미지를 가져온 형태이다

 

그 다음 크롭 박스의 역할을 하나하나 지정할 수 있도록 각각 메서드 화 해서 만들어 보자

func initializeCropBox(for imageSize: CGSize, in viewSize: CGSize) {
    let imageAspectRatio = imageSize.width / imageSize.height
    let viewAspectRatio = viewSize.width / viewSize.height
    
    var cropWidth: CGFloat
    var cropHeight: CGFloat
    
    if imageAspectRatio > viewAspectRatio {
        cropWidth = viewSize.width
        cropHeight = viewSize.width / imageAspectRatio
    } else {
        cropHeight = viewSize.height
        cropWidth = viewSize.height * imageAspectRatio
    }
    
    let x = (viewSize.width - cropWidth) / 2
    let y = (viewSize.height - cropHeight) / 2
    
    self.cropRect = CGRect(x: x, y: y, width: cropWidth, height: cropHeight)
    self.isCropBoxVisible = true
}

이 메서드는 초기화 버튼을 누르면 이전 변경 값, 크롭한 이미지 등 값이 바뀐 이미지를 원래의 이미지 크기와 이미지 크기에 맞게 크롭박스를 위치시키도록 초기 상태로 되돌려 주는 메서드 이다.

func applyActionCrop(with rect: CGRect, imageViewSize: CGSize, to image: UIImage) -> UIImage? {
    let scaleX = image.size.width / imageViewSize.width
    let scaleY = image.size.height / imageViewSize.height
    let scaledCropArea = CGRect(
        x: rect.origin.x * scaleX,
        y: rect.origin.y * scaleY,
        width: rect.size.width * scaleX,
        height: rect.size.height * scaleY
    )
    
    if let croppedCGImage = image.cgImage?.cropping(to: scaledCropArea) {
        cropApplied = true
        return UIImage(cgImage: croppedCGImage)
    }
    return image
}

그 다음은 크기를 임의로 설정한 크롭 박스 영역을 기준으로 이미지를 자르고 크롭 된 이미지를 반환하는 핵심 적인 역할을 하는 메서드이다.

 

scaleX scaleY를 사용해 크롭 박스를 이미지 크기에 맞게 조정한다. 그 이후 조정 된 크롭 영역을 UIImage의 cropping메서드를 활용해 원하는 영역 만큼 이미지를 자르게 된다.

 

여기까지 일단 크롭 박스를 초기화 하고 원하는 사이즈로 이미지를 자르는 메서드를 구현하고

자르기 옵션을 눌렀을때 크롭 박스가 나타나도록 구현할 예정이다.

 

먼저 이미지 위에 위치할 크롭박스를 별도의 컴포넌트 그룹에 추가해 별도로 관리 할 수 있도록 한다.

@State private var initialRect: CGRect? = nil
@State private var frameSize: CGSize = .zero
@State private var draggedCorner: UIRectCorner? = nil

private var rectDrag: some Gesture {
    DragGesture()
        .onChanged { gesture in
            if initialRect == nil {
                initialRect = rect
                draggedCorner = closestCorner(point: gesture.startLocation, rect: rect)
            }

            if let draggedCorner {
                self.rect = dragResize(
                    initialRect: initialRect!,
                    draggedCorner: draggedCorner,
                    frameSize: frameSize,
                    translation: gesture.translation
                )
            } else {
                self.rect = drag(
                    initialRect: initialRect!,
                    frameSize: frameSize,
                    translation: gesture.translation
                )
            }
        }
        .onEnded { _ in
            initialRect = nil
            draggedCorner = nil
        }
}

@State private var initialRect: CGRect? = nil 은 사용자가 드래그를 시작할 때 크롭 박스의 초기 위치와 크기를 저장하는 변수이다. 사용자가 드래그를 시작하면 이 변수에 현재 rect의 값을 저장하고 드래그가 끝나면 초기화 한다.

 

@State private var frameSize: CGSize = .zero 크롭 박스가 위치한 부모 뷰의 크기를 저장하는 변수이다. 이 값은 크롭 박스의 이동 및 크기 조정 작업에서 사용된다. 이 값은 보통 지오메트리리더를 사용해 결정 된다.

 

@State private var draggedCorner: UIRectCorner? = nil 사용자가 크롭 박스의 모서리를 드래그하고 있는지 저장하는 변수이다. 이 변수는 크기 조정 작업에서 주요하게 작동한다. 사용자가 드래그를 시작할 때 이 값을 설정하고, 드래그가 끝나면 초기화 된다.

 

rectDrag는 DragGesture를 사용해 사용자가 크롭 박스를 드래그하거나 크기를 조정할 수 있게하는 제스처 이다. 이 제스처는 onChanged와 onEnded 두 가지 주요 단계로 구성된다.

DragGesture()
    .onChanged { gesture in
        if initialRect == nil {
            initialRect = rect
            draggedCorner = closestCorner(point: gesture.startLocation, rect: rect)
        }

        if let draggedCorner {
            self.rect = dragResize(
                initialRect: initialRect!,
                draggedCorner: draggedCorner,
                frameSize: frameSize,
                translation: gesture.translation
            )
        } else {
            self.rect = drag(
                initialRect: initialRect!,
                frameSize: frameSize,
                translation: gesture.translation
            )
        }
    }

여기에서 onChange는 드래그 제스처가 감지 되었을때 호출 되고 드래그가 진행 중인 동안 반복적으로 호출되며, 드래그 위치에 따라 크롭 박스의 위치나 크기를 업데이트 합니다.

 

initialRect 은 드래그가 처음 시작될 때만 실행되며 initialRect가 nil일 때 현재 값을 저장하고 드래그된 모서리를 확인해 draggedCorner 변수에 저장한다.

 

closestCorner는 사용자가 드래그를 시작한 위치와 크롭 박스의 각 모서리 위치를 비교해 가장 가까운 모서리를 찾게 된다. 그 이후 draggedCorner에 저장한다.

 

그리고 모서리를 움직이지 않고 전체를 움직이는 else 에서는 사용자가 드래그한 방향과 거리를 바탕으로 크롭 박스의 크기를 조정하는 역할을 담당한다. 그 이후 사용자의 드래그 변위를 인자로 받아 크롭 박스의 크기를 계산 한다.

 

그리고 initialRect, frameSize 를 참조해 크롭 박스가 화면 밖으로 벗어나지 않도록 구현했다.

private var box: some View {
    ZStack {
        grid
        pins
    }
    .border(Color.blue, width: 2)
    .background(Color.white.opacity(0.001))
    .frame(width: rect.width, height: rect.height)
    .position(x: rect.midX, y: rect.midY)
    .gesture(rectDrag)
}

private var pins: some View {
    VStack {
        HStack {
            pin(corner: .topLeft)
            Spacer()
            pin(corner: .topRight)
        }
        Spacer()
        HStack {
            pin(corner: .bottomLeft)
            Spacer()
            pin(corner: .bottomRight)
        }
    }
}

private func pin(corner: UIRectCorner) -> some View {
    var offX = 1.0
    var offY = 1.0

    switch corner {
    case .topLeft:      offX = -1; offY = -1
    case .topRight:     offY = -1
    case .bottomLeft:   offX = -1
    case .bottomRight: break
    default: break
    }

    return Circle()
        .fill(Color.blue)
        .frame(width: 16, height: 16)
        .offset(x: offX * 8, y: offY * 8)
}

private var grid: some View {
    ZStack {
        HStack {
            Spacer()
            Rectangle()
                .frame(width: 1)
                .frame(maxHeight: .infinity)
            Spacer()
            Rectangle()
                .frame(width: 1)
                .frame(maxHeight: .infinity)
            Spacer()
        }
        VStack {
            Spacer()
            Rectangle()
                .frame(height: 1)
                .frame(maxWidth: .infinity)
            Spacer()
            Rectangle()
                .frame(height: 1)
                .frame(maxWidth: .infinity)
            Spacer()
        }
    }
    .foregroundColor(.gray)
}

그 다음 크롭 박스안의 격자를 구성하는 뷰를 만들어줬다. box는 크롭 박스의 테두리와 내부의 격자선을 그리고 사용자가 이미지에서 크롭할 영역을 시각적으로 확인 할 수 있도록 구현했다.

 

pins는 크롭 박스의 모서리에 배치된 작은 원형 핸들로 이 핸들을 사용해 크롭 박스의 크기를 조장할 수 있도록 했다.

 

grid는 크롭 박스 내부에 표시되는 격자선으로 이미지를 더 정밀하게 크롭할 수 있도록 도와준다.

func resetEdits() {
    filterViewModel.resetFilters()
    cropViewModel.cropApplied = false
    selectedAction = nil
}

resetEdits 사용자가 모든 편집을 초기화할 때 호출 되고 필터와 크롭 상태를 초기화 한다.

func applyCropToImage(_ image: UIImage) -> UIImage? {
    return cropViewModel.applyActionCrop(with: cropViewModel.cropRect, imageViewSize: CGSize.zero, to: image)
}

applyCropToImage 현재 크롭 상태를 원본 이미지에 적용하여 크롭된 이미지를 반환한다.

 

이렇게 구현한 메서드를 적용하면 자르기 버튼을 누르고 크롭박스를 자유롭게 움직이고 크기도 변형이 가능하며 원하는 크롭박스 사이즈를 만들어 해당 사이즈로 사진을 자를 수 있게 된다 🙂

여기까지 크롭 박스를 만들고 크기를 조절해 이미지 자르는 작업이 생각보다 어렵다고 느껴졌다. 그치만 끝까지 해내서 굉장히 뿌듯한 작업이었다. 이 내용 잘 정리해서 다시 적용할 때 참고 해야지