
2024 관광데이터 활용 공모전에서 내가 메인으로 개발했던 기능 중 '스크랩 에디터'를 공유하려 한다.
실제 앱에서는 REST API를 통해 캔버스 데이터를 불러오고 각 디바이스 사이즈에 맞게 재조정하고 배치하고..
작성할 코드가 매우 많아서 힘들게 개발했었고, 매우 더럽게(?) 개발했다.
여기서는 간단하게 어떤 원리로 개발할 수 있는지, 이런 방법도 있다는 것을 공유하는 차원에서 글을 쓴다.
동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.
사용한 UI 프레임워크: SwiftUI
주요 Skills: GeometryReader, 좌표, Gesture
찾아보니 'Canvas'를 사용하는 것도 있던데, 나는 ZStack을 이용해 구현할거다.
// CanvasView.swift
import SwiftUI
struct CanvasView: View {
// MARK: - ViewModel
@StateObject private var viewModel: CanvasViewModel = CanvasViewModel()
// MARK: - View
var body: some View {
VStack {
HStack {
// 버튼 들어갈 공간
}
GeometryReader { geo in
HStack {
ZStack {
}
.frame(width: viewModel.state.canvasSize.width, height: viewModel.state.canvasSize.height)
.background(Color.white)
}
.frame(maxWidth: geo.size.width, maxHeight: geo.size.height)
.onAppear {
viewModel.send(action: .canvasDidAppear(size: geo.size))
}
}
}
.padding(20)
.background(Color.gray)
}
}
// CanvasViewModel.swift
import Foundation
import SwiftUI
final class CanvasViewModel: ObservableObject {
// MARK: - Actions
enum Action {
case canvasDidAppear(size: CGSize)
}
// MARK: - States
struct State {
var canvasSize: CGSize = .zero
}
// MARK: - Properties
@Published var state: State = State()
// MARK: - Send
func send(action: Action) {
switch action {
case .canvasDidAppear(let size):
canvasDidAppear(with: size)
}
}
}
private extension CanvasViewModel {
func canvasDidAppear(with size: CGSize) {
setupCanvasSize(with: size)
}
}
// MARK: - User Functions
private extension CanvasViewModel {
func setupCanvasSize(with defaultSize: CGSize) {
var newWidth = defaultSize.width
var newHeight = defaultSize.width * 16 / 9
if newHeight > defaultSize.height {
newWidth = defaultSize.height * 9 / 16
newHeight = defaultSize.height
}
state.canvasSize = CGSize(width: newWidth, height: newHeight)
}
}
벌써 복잡해 보이네..
GeometryReader 활용해서 Canvas의 크기를 제어할거다.
GeometryReader는 현재 가질 수 있는 최대 공간을 가지고, 그 공간의 크기를 읽을 수 있다.
읽어온 크기를 ViewModel에서 9:16 비율로 잘라주는 작업을 한다. 그리고 진짜 캔버스가 될 ZStack의 크기로 잡아준다.
비율로 잘라주는 이유는 여러 기기에서 동일한 비율과 좌표값으로 제어하기 위해서다.
만약 이 처리를 안 하고 단순 좌표값으로 스티커나 텍스트를 배치하려고 한다면, 기기마다 좌표값이 다르기 때문에 대참사가 난다. (경험담)


왼쪽은 iPhone SE 3세대, 오른쪽은 내 iPhone 13 Pro이다.
화면 사이즈가 다르기 때문에 비율 계산을 해주고나니, 왼쪽은 위, 아래 공간이 남고 오른쪽은 꽉 차 보인다.
벌써 캔버스를 완성했다!
이제 아이템 하나를 올려보고 제어해보자.
먼저 아이템을 정의하는 struct를 만들고, 그 배열을 ViewModel에 추가한다.
아이템도 이미지로 하나 추가해보자.
// CanvasItem.swift
...
class CanvasItem: Identifiable {
var type: ItemType
var position: CGPoint
var size: CGSize
var color: Color
init(type: ItemType, position: CGPoint, size: CGSize, color: Color) {
self.type = type
self.position = position
self.size = size
self.color = color
}
}
// CanvasViewModel.swift
...
// MARK: - States
struct State {
...
var canvasItems: [CanvasItem] = []
}
...
private extension CanvasViewModel {
...
func addSampleImage() {
state.canvasItems.append(
CanvasItem(
type: .image(Image(systemName: "gamecontroller.circle.fill")),
position: .zero,
size: CGSize(width: 50, height: 50),
color: Color.blue
)
)
}
}
그렇다. 이 배열에 있는 아이템들이 ZStack 위에 올라갈 아이템들이다.
이제 ZStack에 올리기 위해 View를 하나 만들어서 사용한다.
import SwiftUI
struct CanvasItemView: View {
// MARK: - Properties
let item: CanvasItem
// MARK: - Initializer
init(item: CanvasItem) {
self.item = item
}
// MARK: - View
var body: some View {
switch item.type {
case .image(let image):
image
.resizable()
case .text(let text):
Text(text)
}
}
}
// CanvasView.swift
GeometryReader { geo in
HStack {
ZStack {
ForEach(viewModel.state.canvasItems.indices, id: \.self) { itemIndex in
let item = viewModel.state.canvasItems[itemIndex]
CanvasItemView(item: item)
.frame(width: item.size.width, height: item.size.height)
.position(item.position)
.foregroundStyle(item.color)
}
}
...

위치가 이상한데, 정상적이다. 해당 객체의 Center를 기준으로 ZStack의 0, 0 위치에 배치된 것이다.
그래서 살짝 조정을 해보면?
let item = viewModel.state.canvasItems[itemIndex]
let itemPositionForCanvas = CGPoint(
x: item.position.x + item.size.width / 2,
y: item.position.y + item.size.height / 2
)
CanvasItemView(item: item)
.frame(width: item.size.width, height: item.size.height)
.position(itemPositionForCanvas)
.foregroundStyle(item.color)

이제 Gesture만 남았다.
Gesture는 각 Item마다 적용해줘야 하니 CanvasItemView에 적용해준다.
.gesture(
DragGesture()
.onChanged { gesture in
viewModel.send(action: .canvasItemDidDrag(index: itemIndex, gesture: gesture))
}
.onEnded { _ in
viewModel.send(action: .canvasItemDragDidEnd)
}
)
// CanvasViewModel.swift
enum Action {
...
case canvasItemDidDrag(index: Int, gesture: DragGesture.Value)
case canvasItemDragDidEnd
}
private var positionDiffer: CGPoint?
func send(action: Action) {
switch action {
...
case .canvasItemDidDrag(let index, let gesture):
canvasItemDidDrag(index: index, gesture: gesture)
case .canvasItemDragDidEnd:
canvasItemDragDidEnd()
}
}
...
func canvasItemDidDrag(index: Int, gesture: DragGesture.Value) {
var updatedItems = state.canvasItems
let selectedItem = updatedItems[index]
if positionDiffer == nil {
positionDiffer = CGPoint(
x: selectedItem.position.x - gesture.startLocation.x,
y: selectedItem.position.y - gesture.startLocation.y
)
}
let newPosition = calculateNewPosition(gesture: gesture)
selectedItem.position = newPosition
updatedItems[index] = selectedItem
state.canvasItems = updatedItems
}
func canvasItemDragDidEnd() {
positionDiffer = nil
}
...
func calculateNewPosition(gesture: DragGesture.Value) -> CGPoint {
var newPosition = CGPoint(
x: gesture.location.x < state.canvasSize.width ? gesture.location.x : state.canvasSize.width,
y: gesture.location.y < state.canvasSize.height ? gesture.location.y : state.canvasSize.height)
newPosition = CGPoint(
x: (gesture.location.x > 0 ? newPosition.x : 0) + positionDiffer!.x,
y: (gesture.location.y > 0 ? newPosition.y : 0) + positionDiffer!.y
)
return newPosition
}
Gesture가 동작할 때마다 ViewModel에서 newPosition을 구하고, 전달된 index에 맞는 item의 Position을 업데이트 한다.

이렇게 간단하게 Gesture를 적용하는 캔버스를 만들 수 있다.
기회가 된다면 화면 회전과 같은 더 복잡한 제스처와 제어 로직도 추가해봐야겠다.
GitHub - dudgkr2014/iOSSample_1_CanvasEditor
Contribute to dudgkr2014/iOSSample_1_CanvasEditor development by creating an account on GitHub.
github.com
'개발 > iOS' 카테고리의 다른 글
| [iOS] MVC에서 MVI로, AI를 쓰기 전에 개발자가 미리 적용하자 (MVI + Clean Architecture with. SwiftUI (2) | 2026.03.11 |
|---|