SwiftUI 사진 콜라주 앱 개발 완벽 가이드 — 드래그 앤 드롭, 확대·축소, 회전과 고화질 이미지 저장
사진 콜라주 앱은 여러 장의 사진을 하나의 캔버스에 배치하고 편집한 뒤 새로운 이미지로 저장하는 앱입니다.
단순히 사진 두 장을 합치는 기능이라면 UIGraphicsImageRenderer만으로도 구현할 수 있습니다.
하지만 실제 편집기에서는 사용자가 사진을 직접 이동하고, 확대·축소하고, 회전하고, 여러 사진의 앞뒤 순서를 변경할 수 있어야 합니다.
이러한 기능을 구현하려면 화면에 표시되는 이미지의 위치와 크기뿐 아니라 최종 저장할 이미지의 좌표까지 관리해야 합니다.
이번 글에서는 SwiftUI와 UIKit을 함께 사용해 기본적인 사진 콜라주 편집기를 만들어 보겠습니다.
1. 이번 글에서 구현할 기능
PhotosPicker로 여러 사진 선택
자유 배치형 콜라주 캔버스
사진 드래그 이동
핀치 제스처 확대·축소
두 손가락 회전
선택한 사진 앞으로 가져오기
사진 삭제
캔버스 배경색 변경
편집 상태 관리
고화질 이미지 렌더링
사진 앱에 결과물 저장
메모리 사용량 최적화
개발 환경
| 항목 | 내용 |
|---|---|
| 언어 | Swift |
| UI | SwiftUI |
| 최소 지원 | iOS 17 |
| 사진 선택 | PhotosUI |
| 이미지 처리 | UIKit / Core Graphics |
| 상태 관리 | Observation |
| 사진 저장 | Photos |
2. Android와 iOS 콜라주 개발 비교
Android에서 사진 편집기를 구현해 본 개발자라면 다음 개념이 익숙할 것입니다.
| Android | SwiftUI / iOS | 역할 |
|---|---|---|
| Canvas | GraphicsContext / CGContext | 이미지 그리기 |
| Matrix | CGAffineTransform | 좌표 변환 |
| GestureDetector | DragGesture 등 | 터치 제스처 |
| ScaleGestureDetector | MagnificationGesture | 확대·축소 |
| Bitmap | UIImage / CGImage | 이미지 데이터 |
| Photo Picker | PhotosPicker | 사진 선택 |
| MediaStore | Photos | 결과물 저장 |
| ViewModel | @Observable | 편집 상태 관리 |
SwiftUI는 선언형 UI이기 때문에 화면 요소의 위치, 크기, 회전값을 상태로 관리하고 해당 상태에 따라 화면을 다시 구성하는 방식이 자연스럽습니다.
3. 콜라주 편집기의 전체 구조
이번 예제의 프로젝트 구조는 다음과 같습니다.
PhotoCollage/
├── PhotoCollageApp.swift
│
├── Models/
│ └── CollageItem.swift
│
├── ViewModels/
│ └── CollageViewModel.swift
│
├── Views/
│ ├── CollageEditorView.swift
│ └── CollagePhotoView.swift
│
└── Services/
├── CollageRenderer.swift
└── PhotoLibrarySaver.swift각 파일의 역할을 구분하겠습니다.
CollageItem: 사진의 위치, 크기, 회전 등 편집 정보CollageViewModel: 사진 추가, 삭제, 순서 변경, 선택 상태 관리CollageEditorView: 전체 편집 화면CollagePhotoView: 개별 사진 제스처 처리CollageRenderer: 최종 콜라주 이미지 생성PhotoLibrarySaver: 사진 앱 저장
이번 예제에서는 이해하기 쉽도록 선택한 사진을 메모리에 보관합니다. 실제 출시용 앱에서는 원본 파일을 디스크에 저장하고 축소된 미리보기만 메모리에 유지하는 방식으로 확장하는 것이 좋습니다.
4. 콜라주 좌표계 설계
사진 편집기에서 가장 중요한 것은 화면 좌표와 출력 이미지 좌표를 구분하는 것입니다.
예를 들어 편집 화면의 캔버스가 350×350포인트라고 가정하겠습니다.
최종 저장 이미지는 2000×2000픽셀로 만들 수 있습니다.
편집 화면
350 × 350 pt
│
│ 좌표 비율 변환
▼
최종 결과물
2000 × 2000 px화면에서 사진의 중심이 (175, 175)라면 결과물에서는 (1000, 1000)이 됩니다.
좌표 변환 비율은 다음과 같습니다.
[
\text{출력 배율}=\frac{2000}{350}\approx5.714
]
이 비율을 위치와 크기에 적용하면 화면과 유사한 결과물을 고해상도로 렌더링할 수 있습니다.
이번 예제에서는 캔버스의 논리적 크기를 1000×1000으로 정의하고, 실제 화면에서는 해당 캔버스를 축소해 표시하겠습니다.
이렇게 하면 기기 화면 크기가 달라도 편집 좌표를 일관되게 관리하기 쉽습니다.
5. 사진 편집 모델 만들기
CollageItem.swift
import Foundation
import UIKit
struct CollageItem: Identifiable {
let id: UUID
let image: UIImage
var center: CGPoint
var size: CGSize
var scale: CGFloat
var rotation: AngleValue
init(
id: UUID = UUID(),
image: UIImage,
center: CGPoint,
size: CGSize,
scale: CGFloat = 1,
rotation: AngleValue = .init()
) {
self.id = id
self.image = image
self.center = center
self.size = size
self.scale = scale
self.rotation = rotation
}
}
struct AngleValue {
var radians: Double = 0
}여기서 center는 이미지의 중심 좌표입니다.
size는 기본 표시 크기이고, scale은 확대·축소 비율입니다.
rotation은 회전 각도를 라디안 단위로 저장합니다.
사진을 편집할 때 실제 UIImage를 매번 수정하지 않고 이러한 변환값만 변경하는 것이 핵심입니다.
6. 편집 상태를 관리하는 ViewModel
CollageViewModel.swift
import Foundation
import UIKit
import Observation
@MainActor
@Observable
final class CollageViewModel {
var items: [CollageItem] = []
var selectedItemID: UUID?
var backgroundColor: UIColor = .white
let canvasSize = CGSize(
width: 1000,
height: 1000
)
func addImage(_ image: UIImage) {
let index = items.count
let item = CollageItem(
image: image,
center: CGPoint(
x: 500 + CGFloat(index % 3) * 30,
y: 500 + CGFloat(index % 3) * 30
),
size: CGSize(
width: 420,
height: 420
)
)
items.append(item)
selectedItemID = item.id
}
func select(_ id: UUID) {
selectedItemID = id
}
func update(
id: UUID,
transform: (inout CollageItem) -> Void
) {
guard let index = items.firstIndex(
where: { $0.id == id }
) else {
return
}
transform(&items[index])
}
func removeSelected() {
guard let selectedItemID else {
return
}
items.removeAll {
$0.id == selectedItemID
}
self.selectedItemID = nil
}
func bringSelectedToFront() {
guard let selectedItemID,
let index = items.firstIndex(
where: { $0.id == selectedItemID }
) else {
return
}
let item = items.remove(at: index)
items.append(item)
}
}배열의 마지막 항목이 화면에서 가장 앞에 표시되도록 구성하겠습니다.
따라서 선택한 항목을 배열 끝으로 이동하면 앞으로 가져오는 기능을 구현할 수 있습니다.
7. 개별 사진 View 만들기
사진의 위치와 크기를 SwiftUI에 적용해 보겠습니다.
CollagePhotoView.swift
import SwiftUI
struct CollagePhotoView: View {
let item: CollageItem
let isSelected: Bool
let canvasDisplaySize: CGFloat
var body: some View {
let ratio = canvasDisplaySize / 1000
Image(uiImage: item.image)
.resizable()
.scaledToFill()
.frame(
width: item.size.width * ratio,
height: item.size.height * ratio
)
.clipped()
.scaleEffect(item.scale)
.rotationEffect(
.radians(item.rotation.radians)
)
.overlay {
if isSelected {
Rectangle()
.stroke(
.blue,
lineWidth: 2
)
}
}
.position(
x: item.center.x * ratio,
y: item.center.y * ratio
)
}
}position()은 캔버스 내에서 이미지 중심의 위치를 지정합니다.
scaleEffect()는 확대·축소를 적용하고, rotationEffect()는 회전을 적용합니다.
다만 선택 테두리는 실제 편집 UI에서 변환된 사진과 일치하도록 배치해야 합니다. 이후 제스처를 포함한 버전에서는 선택 표시를 이미지의 변환과 함께 처리하겠습니다.
8. DragGesture로 사진 이동하기
사진을 손가락으로 끌어 이동하려면 DragGesture를 사용할 수 있습니다.
중요한 점은 드래그 도중 매번 이전 이동값을 누적하면 위치가 과도하게 변경될 수 있다는 것입니다.
따라서 제스처가 시작되었을 때의 위치를 기준으로 현재 이동량을 계산해야 합니다.
@GestureState private var dragTranslation = CGSize.zero다음과 같이 제스처를 구성할 수 있습니다.
DragGesture()
.updating($dragTranslation) {
value, state, _ in
state = value.translation
}이때 translation은 화면 포인트 단위입니다.
편집 모델은 1000×1000 논리 좌표를 사용하므로 화면 배율을 역으로 적용해야 합니다.
예를 들어 화면에 표시된 캔버스 크기가 350포인트라면 다음과 같습니다.
let logicalX = translation.width * 1000 / 350
let logicalY = translation.height * 1000 / 3509. MagnificationGesture로 확대·축소하기
iOS 17 이상에서는 MagnifyGesture를 사용할 수 있습니다.
MagnifyGesture()
.onChanged { value in
let magnification = value.magnification
print(magnification)
}확대·축소 값은 일반적으로 1을 기준으로 해석할 수 있습니다.
1.0: 원래 크기1.5: 1.5배2.0: 2배0.5: 절반 크기
실제 편집기에서는 확대 비율에 제한을 두는 것이 좋습니다.
let limitedScale = min(
max(newScale, 0.2),
5.0
)이렇게 하면 사진이 지나치게 작아지거나 커지는 것을 방지할 수 있습니다.
10. RotationGesture로 사진 회전하기
두 손가락 회전 제스처에는 RotateGesture를 사용할 수 있습니다.
RotateGesture()
.onChanged { value in
print(value.rotation.radians)
}회전값은 제스처가 시작된 시점의 회전 각도에 더하는 방식으로 관리할 수 있습니다.
예를 들어 기존 회전 각도가 30도이고 제스처에서 20도를 추가했다면 최종 회전은 50도가 됩니다.
11. 이동·확대·회전 제스처 결합
이번에는 실제로 사용할 수 있는 사진 편집 View를 구현하겠습니다.
CollagePhotoView.swift를 다음 코드로 교체합니다.
import SwiftUI
struct CollagePhotoView: View {
let item: CollageItem
let isSelected: Bool
let canvasDisplaySize: CGFloat
let onSelect: () -> Void
let onUpdate: ((inout CollageItem) -> Void) -> Void
@GestureState private var dragOffset = CGSize.zero
@GestureState private var magnification: CGFloat = 1
@GestureState private var rotation: Angle = .zero
private var ratio: CGFloat {
canvasDisplaySize / 1000
}
var body: some View {
Image(uiImage: item.image)
.resizable()
.scaledToFill()
.frame(
width: item.size.width * ratio,
height: item.size.height * ratio
)
.clipped()
.overlay {
Rectangle()
.stroke(
isSelected ? .blue : .clear,
lineWidth: 2
)
}
.scaleEffect(
item.scale * magnification
)
.rotationEffect(
.radians(
item.rotation.radians
+ rotation.radians
)
)
.position(
x: item.center.x * ratio
+ dragOffset.width,
y: item.center.y * ratio
+ dragOffset.height
)
.onTapGesture {
onSelect()
}
.simultaneousGesture(dragGesture)
.simultaneousGesture(magnifyGesture)
.simultaneousGesture(rotateGesture)
}
private var dragGesture: some Gesture {
DragGesture()
.updating($dragOffset) {
value, state, _ in
state = value.translation
}
.onEnded { value in
let dx = value.translation.width / ratio
let dy = value.translation.height / ratio
onUpdate { item in
item.center.x += dx
item.center.y += dy
}
}
}
private var magnifyGesture: some Gesture {
MagnifyGesture()
.updating($magnification) {
value, state, _ in
state = value.magnification
}
.onEnded { value in
onUpdate { item in
item.scale = min(
max(
item.scale * value.magnification,
0.2
),
5.0
)
}
}
}
private var rotateGesture: some Gesture {
RotateGesture()
.updating($rotation) {
value, state, _ in
state = value.rotation
}
.onEnded { value in
onUpdate { item in
item.rotation.radians +=
value.rotation.radians
}
}
}
}이 구현은 제스처가 진행되는 동안 @GestureState로 임시 변환을 표시하고, 제스처가 끝나면 ViewModel에 최종 값을 반영합니다.
이렇게 하면 제스처 중의 이동값과 저장된 이동값이 중복 적용되는 문제를 줄일 수 있습니다.
여러 손가락 제스처를 동시에 사용할 때의 인식 우선순위와 동작은 실제 iPhone에서 검증해야 합니다.
12. PhotosPicker로 여러 사진 추가하기
이제 사진 선택 기능을 연결하겠습니다.
CollageEditorView.swift
import SwiftUI
import PhotosUI
struct CollageEditorView: View {
@State private var viewModel = CollageViewModel()
@State private var selectedItems: [PhotosPickerItem] = []
@State private var isImporting = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
collageCanvas
editingToolbar
}
.padding()
.navigationTitle("사진 콜라주")
.onChange(
of: selectedItems
) { _, newItems in
Task {
await importPhotos(newItems)
}
}
.alert(
"오류",
isPresented: Binding(
get: { errorMessage != nil },
set: {
if !$0 {
errorMessage = nil
}
}
)
) {
Button("확인", role: .cancel) {}
} message: {
Text(errorMessage ?? "")
}
}
private var collageCanvas: some View {
GeometryReader { geometry in
let side = min(
geometry.size.width,
geometry.size.height
)
ZStack {
Color(uiColor: viewModel.backgroundColor)
ForEach(viewModel.items) { item in
CollagePhotoView(
item: item,
isSelected:
viewModel.selectedItemID == item.id,
canvasDisplaySize: side,
onSelect: {
viewModel.select(item.id)
},
onUpdate: { transform in
viewModel.update(
id: item.id,
transform: transform
)
}
)
}
}
.frame(
width: side,
height: side
)
.clipped()
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
}
.aspectRatio(1, contentMode: .fit)
}
private var editingToolbar: some View {
VStack(spacing: 12) {
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 9,
matching: .images
) {
Label(
"사진 추가",
systemImage: "plus"
)
}
.disabled(isImporting)
HStack {
Button("앞으로") {
viewModel.bringSelectedToFront()
}
Spacer()
Button(
"삭제",
role: .destructive
) {
viewModel.removeSelected()
}
}
.disabled(viewModel.selectedItemID == nil)
if isImporting {
ProgressView("사진 불러오는 중...")
}
}
}
@MainActor
private func importPhotos(
_ items: [PhotosPickerItem]
) async {
guard !items.isEmpty else {
return
}
isImporting = true
defer {
isImporting = false
}
for item in items {
do {
guard let data =
try await item.loadTransferable(
type: Data.self
),
let image = UIImage(data: data)
else {
continue
}
viewModel.addImage(image)
} catch {
errorMessage = "사진을 불러오지 못했습니다."
}
}
selectedItems = []
}
}이제 사진을 여러 장 선택하면 캔버스에 추가할 수 있습니다.
추가한 사진은 드래그, 확대·축소, 회전 제스처로 편집할 수 있습니다.
주의: 이 코드는 편집기의 기본 구조를 보여주는 예제입니다. 실제 출시용 앱에서는 사진 선택 작업이 겹치지 않도록 요청을 관리하고, 가져오기 실패 항목을 사용자에게 구체적으로 안내해야 합니다.
13. 캔버스 배경색 변경
콜라주 앱에서는 배경색을 변경하는 기능도 유용합니다.
예를 들어 흰색, 검은색, 회색 배경을 제공할 수 있습니다.
ViewModel에 이미 다음 속성이 있습니다.
var backgroundColor: UIColor = .whiteSwiftUI에서 배경색 선택 버튼을 추가할 수 있습니다.
HStack {
Button("흰색") {
viewModel.backgroundColor = .white
}
Button("검정") {
viewModel.backgroundColor = .black
}
Button("회색") {
viewModel.backgroundColor = .lightGray
}
}다만 UIKit의 UIColor를 SwiftUI Observation 모델에 저장할 때는 상태 변경 추적과 값 비교 정책을 고려해야 합니다.
단순한 색상 선택 기능이라면 별도의 색상 열거형을 사용하는 것도 좋은 방법입니다.
14. 고화질 이미지 렌더링의 핵심
화면에 표시된 콜라주를 그대로 스크린샷으로 저장하면 출력 해상도가 화면 크기에 제한될 수 있습니다.
예를 들어 편집 화면이 350×350포인트이고 화면 배율이 3배라면 단순 화면 캡처 결과는 대략 1050×1050픽셀 수준일 수 있습니다.
하지만 사진 콜라주 앱에서는 2000×2000픽셀이나 그 이상의 결과물이 필요할 수 있습니다.
따라서 최종 이미지는 편집 화면을 캡처하는 대신 편집 모델의 위치·크기·회전값을 사용해 별도로 렌더링하는 것이 좋습니다.
15. CollageRenderer 구현
CollageRenderer.swift
import UIKit
struct CollageRenderer {
func render(
items: [CollageItem],
backgroundColor: UIColor,
outputSize: CGSize = CGSize(
width: 2000,
height: 2000
)
) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(
size: outputSize,
format: format
)
return renderer.image { context in
let cgContext = context.cgContext
backgroundColor.setFill()
cgContext.fill(
CGRect(
origin: .zero,
size: outputSize
)
)
let ratioX = outputSize.width / 1000
let ratioY = outputSize.height / 1000
for item in items {
cgContext.saveGState()
let center = CGPoint(
x: item.center.x * ratioX,
y: item.center.y * ratioY
)
let width =
item.size.width * ratioX * item.scale
let height =
item.size.height * ratioY * item.scale
cgContext.translateBy(
x: center.x,
y: center.y
)
cgContext.rotate(
by: item.rotation.radians
)
let rect = CGRect(
x: -width / 2,
y: -height / 2,
width: width,
height: height
)
item.image.draw(in: rect)
cgContext.restoreGState()
}
}
}
}이 코드는 편집 모델의 위치, 확대 비율, 회전값을 이용해 최종 이미지를 렌더링합니다.
format.scale = 1을 지정했기 때문에 출력 이미지의 픽셀 크기를 outputSize와 일치시키기 쉽습니다.
다만 화면의 scaledToFill().clipped()와 위 렌더러의 draw(in:)는 이미지 비율 처리 방식이 다릅니다. 화면과 최종 결과물을 정확히 일치시키려면 렌더러에서도 Aspect Fill 크롭을 적용해야 합니다.
16. Aspect Fill을 적용한 이미지 렌더링
사진의 비율을 유지하면서 지정된 영역을 가득 채우려면 다음과 같이 계산할 수 있습니다.
func drawAspectFill(
image: UIImage,
in rect: CGRect
) {
let imageSize = image.size
let scale = max(
rect.width / imageSize.width,
rect.height / imageSize.height
)
let drawSize = CGSize(
width: imageSize.width * scale,
height: imageSize.height * scale
)
let drawRect = CGRect(
x: rect.midX - drawSize.width / 2,
y: rect.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
let context = UIGraphicsGetCurrentContext()
context?.saveGState()
context?.clip(to: rect)
image.draw(in: drawRect)
context?.restoreGState()
}이 함수를 CollageRenderer 내부에서 item.image.draw(in: rect) 대신 호출하면 이미지 비율을 유지하면서 지정된 영역을 채울 수 있습니다.
이때 drawAspectFill() 함수는 CollageRenderer에서 호출 가능한 범위에 정의해야 합니다.
17. 최종 이미지 저장하기
고화질 콜라주를 생성한 뒤 사진 앱에 저장하겠습니다.
PhotoLibrarySaver.swift
import UIKit
import Photos
enum PhotoSaveError: LocalizedError {
case permissionDenied
var errorDescription: String? {
"사진 앱에 저장할 권한이 없습니다."
}
}
struct PhotoLibrarySaver {
func save(_ image: UIImage) async throws {
let status = await PHPhotoLibrary.requestAuthorization(
for: .addOnly
)
guard status == .authorized else {
throw PhotoSaveError.permissionDenied
}
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(
from: image
)
}
}
}Info.plist에는 다음 권한 설명이 필요합니다.
<key>NSPhotoLibraryAddUsageDescription</key>
<string>완성한 콜라주 이미지를 사진 앱에 저장하기 위해 사용합니다.</string>사진 저장은 사용자가 저장 버튼을 누른 시점에 실행하는 것이 좋습니다.
18. 저장 버튼 연결
CollageEditorView에 다음 상태를 추가합니다.
@State private var isExporting = false
@State private var saveMessage: String?그리고 편집 도구 영역에 저장 버튼을 추가합니다.
Button {
Task {
await exportCollage()
}
} label: {
Label(
"콜라주 저장",
systemImage: "square.and.arrow.down"
)
}
.disabled(
viewModel.items.isEmpty || isExporting
)저장 함수는 다음과 같습니다.
@MainActor
private func exportCollage() async {
isExporting = true
defer {
isExporting = false
}
let renderer = CollageRenderer()
let image = renderer.render(
items: viewModel.items,
backgroundColor: viewModel.backgroundColor,
outputSize: CGSize(
width: 2000,
height: 2000
)
)
do {
try await PhotoLibrarySaver().save(image)
saveMessage = "콜라주 이미지를 저장했습니다."
} catch {
saveMessage = error.localizedDescription
}
}saveMessage를 화면의 .alert에 연결하면 저장 성공 또는 실패 결과를 표시할 수 있습니다.
성능 주의: 이 예제의 렌더링은 메인 액터에서 실행됩니다. 2000×2000 이상의 이미지나 여러 장의 고해상도 사진을 처리할 때는 UI 멈춤을 방지하도록 렌더링 작업을 분리해야 합니다.
19. 콜라주 이미지의 메모리 사용량
출력 이미지가 2000×2000픽셀이라고 가정하겠습니다.
RGBA 4바이트 픽셀 버퍼 기준으로 대략 다음과 같은 메모리가 필요할 수 있습니다.
[
2000 \times 2000 \times 4 = 16,000,000\text{ bytes}
]
약 15.3MiB입니다.
하지만 실제 편집 과정에서는 원본 이미지, 미리보기, 렌더링 버퍼, 저장 인코딩 버퍼가 동시에 존재할 수 있습니다.
따라서 최종 이미지 한 장의 메모리만 계산해서는 안 됩니다.
예를 들어 4000×3000 사진 9장을 모두 디코딩하면 상당한 메모리가 필요할 수 있습니다.
실제 서비스에서는 다음 구조를 권장합니다.
원본 사진 파일
│
├── 디스크 저장
│
└── 축소 미리보기
│
▼
SwiftUI 편집
│
▼
최종 출력 시에만
원본 읽기
│
▼
고화질 렌더링20. 원본 사진을 유지해야 하는 이유
사진을 여러 번 회전하거나 크기를 조절할 때 매번 UIImage를 새로 생성하면 화질과 메모리 측면에서 불리할 수 있습니다.
특히 JPEG로 반복 저장하면 손실 압축으로 인해 화질이 저하될 수 있습니다.
따라서 사진 편집 앱에서는 원본 이미지와 편집 명령을 분리하는 것이 좋습니다.
예를 들어 다음과 같은 데이터를 저장할 수 있습니다.
{
"photoId": "photo-001",
"centerX": 500,
"centerY": 500,
"width": 420,
"height": 420,
"scale": 1.5,
"rotation": 0.3
}이 정보만 있으면 원본 사진을 다시 불러와 동일한 편집 결과를 재구성할 수 있습니다.
이러한 방식을 비파괴 편집이라고 부릅니다.
21. 편집 프로젝트 저장하기
실제 콜라주 앱에서는 사용자가 편집 중이던 작업을 나중에 다시 열 수 있어야 합니다.
이 경우 사진 파일과 편집 상태를 분리해 저장합니다.
CollageProjects/
└── project-001/
├── project.json
├── photo-001.heic
├── photo-002.jpg
└── thumbnail.jpgproject.json에는 다음 정보를 저장할 수 있습니다.
캔버스 크기
배경색
사진 파일명
사진 위치
사진 크기
확대 비율
회전 각도
레이어 순서
이렇게 하면 앱을 종료했다가 다시 실행해도 이전 작업을 복원할 수 있습니다.
22. 드래그 앤 드롭과 제스처 이동의 차이
SwiftUI에서 사진을 손가락으로 움직이는 기능은 DragGesture로 구현할 수 있습니다.
하지만 다른 앱이나 화면에서 파일을 끌어오는 시스템 드래그 앤 드롭은 별도의 API를 사용합니다.
대표적으로 draggable과 dropDestination을 사용할 수 있습니다.
따라서 두 기능을 구분해야 합니다.
| 기능 | SwiftUI API |
|---|---|
| 캔버스 안에서 사진 이동 | DragGesture |
| 사진 확대·축소 | MagnifyGesture |
| 사진 회전 | RotateGesture |
| 앱 간 데이터 드래그 | draggable |
| 외부 드롭 데이터 수신 | dropDestination |
이번 글에서는 콜라주 캔버스 안에서 사진을 자유롭게 이동하는 기능을 구현했습니다.
23. 실제 사진 콜라주 앱에서 추가할 기능
기본 편집기를 완성했다면 다음 기능을 확장할 수 있습니다.
정사각형·가로형·세로형 캔버스 선택
사진 간격 및 테두리 설정
여러 가지 콜라주 템플릿
사진 모서리 둥글게 처리
사진 그림자
텍스트 스티커
배경 패턴
실행 취소 및 다시 실행
프로젝트 자동 저장
고해상도 PNG 및 JPEG 내보내기
특히 실행 취소 기능은 사진 편집 앱의 사용성을 크게 개선합니다.
단순한 상태 복사 방식부터 편집 명령을 기록하는 Command 패턴까지 다양한 구현 방법이 있습니다.
24. 테스트 체크리스트
PhotosPicker 여러 장 선택
선택한 사진 캔버스에 추가
사진 선택 표시
사진 드래그 이동
핀치 확대·축소
두 손가락 회전
제스처 연속 사용
사진 앞으로 가져오기
선택한 사진 삭제
배경색 변경
화면 크기 변경 시 좌표 유지
고화질 렌더링
화면과 출력 결과의 비율 일치
사진 앱 저장 권한
저장 실패 처리
여러 장의 고해상도 사진 테스트
메모리 사용량 측정
앱 재실행 후 프로젝트 복원 설계
실제 iPhone 터치 테스트
25. 마무리
SwiftUI를 이용하면 사진을 선택하고 캔버스에서 자유롭게 이동·확대·회전하는 콜라주 편집기를 구현할 수 있습니다.
이번 글에서 살펴본 핵심 내용을 정리하면 다음과 같습니다.
PhotosPicker로 여러 장의 사진을 가져올 수 있습니다.
사진의 위치와 변환값은 별도 모델로 관리하는 것이 좋습니다.
DragGesture로 사진 이동을 구현할 수 있습니다.
MagnifyGesture로 확대·축소를 처리할 수 있습니다.
RotateGesture로 사진 회전을 구현할 수 있습니다.
여러 제스처를 조합해 편집기를 만들 수 있습니다.
레이어 순서를 배열로 관리할 수 있습니다.
UIGraphicsImageRenderer로 고해상도 결과물을 생성할 수 있습니다.
Photos 프레임워크로 결과물을 사진 앱에 저장할 수 있습니다.
실제 서비스에서는 비파괴 편집과 메모리 최적화가 중요합니다.
Android에서 Canvas와 Matrix를 사용해 본 개발자라면 SwiftUI에서도 비슷한 편집 기능을 구현할 수 있습니다.
다만 SwiftUI는 선언형 UI이기 때문에 화면 요소를 직접 조작하기보다 사진의 위치, 크기, 회전값을 상태로 관리하고 그 상태를 화면에 반영하는 방식으로 설계하는 것이 좋습니다.
사진 콜라주 앱의 핵심은 단순한 이미지 합성이 아니라 화면에서 편집한 결과를 원본 화질을 최대한 유지하면서 정확하게 출력하는 것입니다.
참고 자료
SwiftUI Gestures
https://developer.apple.com/documentation/swiftui/gesturesDragGesture
https://developer.apple.com/documentation/swiftui/draggestureMagnifyGesture
https://developer.apple.com/documentation/swiftui/magnifygestureRotateGesture
https://developer.apple.com/documentation/swiftui/rotategesturePhotosPicker
https://developer.apple.com/documentation/photosui/photospickerUIGraphicsImageRenderer
https://developer.apple.com/documentation/uikit/uigraphicsimagerendererCore Graphics
https://developer.apple.com/documentation/coregraphics
태그: iOS, Swift, SwiftUI, 사진 콜라주, PhotosPicker, DragGesture, MagnifyGesture, RotateGesture, 이미지 편집, Core Graphics, UIGraphicsImageRenderer, 사진 저장, MVVM, iPhone 앱 개발