개발/iOS

[iOS] iOS Toy 1: 스크랩 에디터, 캔버스 에디터, 제스처 조작하기

BLEV 2025. 4. 26. 19:51

 

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