SwiftUI onDrag & onDrop
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SwiftUI의 onDrag와 onDrop 메서드 사용에 대해 알아봅니다 🙋🏻
이전 포스팅에서 SwiftUI의 드래그 앤 드롭을 구현하기 위해서 draggable과 dropDestination에 대해 알아보고 적용해봤어요!
그런데, iOS 16 이상에서 사용 가능한 한계점도 있었어요.
그래서 이번에는 비교적 사용할 수 있는 OS 버전도 더 폭넓고 같은 기능을 해주는 onDrag와 onDrop에 대해 알아보겠습니다!
그럼 바로 가보시죠 😃
onDrag
드래그 앤 드롭 작업에서 드래그를 위한 메서드로 적용한 뷰를 드래그할 수 있도록 활성화해주는 뷰 모디파이어입니다.
func onDrag<V>(
_ data: @escaping () -> NSItemProvider,
@ViewBuilder preview: () -> V
) -> some View where V : View
기본 정의는 이러합니다.
data 파라미터가 존재하는데, 이는 드래그 시 특정한 로직을 수행하고 드래그 되는 data인 NSItemProvider 타입을 반환하도록 하는 이스케이핑 클로저의 역할을 해줍니다.
그리고, draggable에서 알아봤던것처럼 드래그 시 어떤 뷰가 드래그 되고 있는지 프리뷰로 제공해줄 수 있죠!
(이 프리뷰를 제공하는것은 선택입니다.)
프리뷰를 제공해주는건 iOS 15부터 사용가능하나 제공하지 않는다면 iOS 13.4 이상부터는 아래 메서드로 간단히 사용할 수 있습니다.
func onDrag(_ data: @escaping () -> NSItemProvider) -> some View
여기서 NSItemProvider가 뭘까요?
NSItemProvider는 드래그 앤 드롭 시 프로세스 간 데이터나 파일을 전달하기 위한 공급자 역할을 해주는 타입입니다.
Transferable과 비슷한 역할을 해준다고 이해하면 좋을것 같아요.
먼저 onDrag 사용에 대해 예시로 알아보기전, onDrop까지 먼저 개념을 잡고 한번에 예시 코드로 볼께요 🙋🏻
onDrop
지정한 클로저를 사용해서 드롭된 컨텐츠를 처리하는 역할을 하는 drop 메서드입니다.
func onDrop(
of supportedContentTypes: [UTType],
isTargeted: Binding<Bool>?,
perform action: @escaping ([NSItemProvider]) -> Bool
) -> some View
여기서 supportedContentTypes는 드래그 앤 드롭을 통해 허용할 수 있는 컨텐츠 타입을 나타내는 유형 식별자입니다.
즉, 해당 유형에 포함되있지 않다면 기능이 활성화 되지 않죠.
isTargeted는 드래그 앤 드롭 작업 시 드롭 대상 영역에 들어오거나 나갈 때 업데이트되는 바인딩 값입니다.
action은 실제 드롭 시 처리할 로직을 담아줄 수 있습니다.
이 메서드가 기본이라면, 이전 dropDestination 메서드에서도 알아봤듯 해당 드롭된 좌표 위치 공간에 대한 값도 얻어 사용할 수 있는 초기화 메서드가 존재합니다.
func onDrop(
of supportedContentTypes: [UTType],
isTargeted: Binding<Bool>?,
perform action: @escaping ([NSItemProvider], CGPoint) -> Bool
) -> some View
필요하다면 이 메서드로 초기화하여 드롭된 컨텐츠의 좌표 공간을 이용할 수 있습니다.
마지막으로, DropDelegate를 활용하는 초기화 메서드가 있습니다.
func onDrop(
of supportedContentTypes: [UTType],
delegate: any DropDelegate
) -> some View
드롭 동작에 관한 DropDelegate를 채택해 커스텀하게 정의한 딜리게이트를 담아 사용할 수 있죠.
우리의 해당 포스팅에서의 예제 코드도 이 DropDelegate를 활용해볼거에요!
참고로, onDrop은 모두 iOS 14.0 이상이면 사용 가능합니다.
그럼 하나 더! DropDelegate가 무엇인지 짚고 넘어갈께요.
DropDelegate
드롭을 허용하도록 수정된 뷰에서 드롭 작업과 상호 작용하기 위해 구현된 인터페이스입니다.
@MainActor
protocol DropDelegate
사용되는 필수 메서드로는 5가지가 존재해요.
1️⃣ dropEntered
func dropEntered(info: DropInfo)
드롭할 컨텐츠가 드롭을 적용시킨 뷰에 적용되었을때 (즉, 해당 영역에 드롭 했을때) 동작하는 메서드입니다.
2️⃣ dropExited
func dropExited(info: DropInfo)
드롭이 완료되었을때 동작할 메서드입니다.
3️⃣ dropUpdated
func dropUpdated(info: DropInfo) -> DropProposal?
드롭할 컨텐츠가 해당 드롭이 적용된 뷰 내부에 들어왔을때 (즉, 드롭을 하지 않아도 드롭된 컨텐츠가 해당 뷰에 접근했을때) 동작하는 메서드입니다.
4️⃣ validateDrop
func validateDrop(info: DropInfo) -> Bool
드롭을 허용하는 뷰에 입력이 되고 있는지 검증하는 메서드입니다.
5️⃣ performDrop
func performDrop(info: DropInfo) -> Bool
주어진 정보에서부터 item provider data를 요청할 수 있게 딜리게이트에 알려주는 메서드입니다.
해당 메서드는 필수로 만약 DropDelegate를 채택하여 커스텀하게 구현한다면 꼭 만들어줘야합니다.
나머지 1~4번 메서드는 DropDeleate 기본 구현된 메서드의 동작을 사용할 수 있어요.
여기서 나온 파라미터로 쓰이는 DropInfo 타입에는 아래와 같은 프로퍼티를 가지고 있어요.
1️⃣ location - 드롭된 현재 뷰의 좌표
2️⃣ hasItemConforming - 드롭된 최소 하나 이상의 아이템이 존재하는지에 대한 여부
3️⃣ itemProviders - 드롭된 최소 하나 이상의 NSItemProvider의 값들
그리고 마지막으로 dropUpdated 메서드 호출 시 DropProposal 타입을 반환합니다.
해당 타입은 드롭이 어떤 작업을 수행하도록 제안하는 값이에요.
총 기본 생성된 값에는 4가지가 존재해요.
1️⃣ move
2️⃣ copy
3️⃣ forbidden
4️⃣ cancel
해당 값에 따라 실제 뷰 내부에 드롭된 컨텐츠가 위치할때 우측 상단에 추가나 금지 아이콘 등 적절히 나타나게 됩니다.
실제로, forbidden으로 하게되면 드롭을 해도 로직을 타지 않고 일어나지 않는 등의 동작의 다름을 가져갑니다.
그럼 이제 진짜 실제로 한번 코드로 구현해볼까요?
실전 적용하기 🚀
먼저 코드부터 보시죠!
import SwiftUI
// MARK: - 뷰모델
final class ListViewModel: ObservableObject {
@Published var items: [Color] = [.red, .blue, .black, .green, .yellow]
@Published var currentItem: Color?
@Published var isDragging = false
@Published var draggingIndex: Int?
}
// MARK: - 메인 리스트 뷰
struct ListView: View {
@StateObject var viewModel = ListViewModel()
var body: some View {
VStack {
ItemView(viewModel: viewModel)
}
.padding(.horizontal, 20)
}
}
// MARK: - 리스트 아이템 뷰
private struct ItemView: View {
@ObservedObject var viewModel: ListViewModel
var body: some View {
VStack {
ForEach(viewModel.items.indices, id:\.self) { index in
if viewModel.draggingIndex != index {
RoundedRectangle(cornerRadius: 20)
.foregroundColor(viewModel.items[index])
.frame(width: 100, height: 100)
.overlay(
viewModel.currentItem == viewModel.items[index] && viewModel.isDragging
? Color.white.opacity(0.5)
: Color.clear
)
.onDrag {
viewModel.currentItem = viewModel.items[index]
viewModel.isDragging = true
viewModel.draggingIndex = index
return NSItemProvider(object: String(describing: index) as NSString)
}
.onDrop(of: [.text], delegate: ItemDropDelegate(index: index, viewModel: viewModel))
}
}
}
}
}
// MARK: - 커스텀한 DropDelegate
struct ItemDropDelegate: DropDelegate {
let index: Int
let viewModel: ListViewModel
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func dropEntered(info: DropInfo) {
viewModel.isDragging = true
guard let from = viewModel.items.firstIndex(of: viewModel.currentItem!),
from != index else {
return
}
let toIndex = from < index ? index + 1 : index
viewModel.items.move(fromOffsets: IndexSet(integer: from), toOffset: toIndex)
}
func performDrop(info: DropInfo) -> Bool {
viewModel.currentItem = nil
viewModel.isDragging = false
viewModel.draggingIndex = nil
return true
}
}
먼저 ViewModel에서 아이템의 정보들을 Color 값으로 가지고, 현재 선택된 아이템이나 드래그되고 있는 상태 그리고 드래그되고 있는 아이템의 Index값을 가진 뷰모델을 만들었습니다.
뷰로는 리스트뷰안에서 해당 컬러들을 아이템뷰로 만들어 라운드된 사각형 뷰를 보여주고 있습니다.
여기서 해당 아이템 뷰들에 onDrag를 통해 드래그될 수 있는 뷰라는걸 적용시키고 드래그 시 현재 선택된 아이템의 값과 드래그되고 있는 상태 그리고 드래그 되고 있는 아이템에 대한 인덱스 값을 뷰모델에 적절히 업데이트 시켜줍니다.
그리고 NSItemProvider 타입으로 return해줘야하는데 Color는 직접적으로 해당 타입에서 매칭시킬 수 없어 String으로 변경하여 전달해주죠.
onDrop 메서드로 해당 뷰에 드롭될 수 있도록 적용하며, string 타입으로 Color index를 drag하고 있으니 text나 plainText와 같은 타입으로 of 파라미터에 사용합니다.
그리고 드롭 시 커스텀한 동작을 위해 delegate를 커스텀하게 만든것으로 넣어주면 됩니다.
만약 Index가 아니라 Color의 RGB 값을 이용하고 싶다면, Color를 RGB값으로 변환하여 문자열로 만들고 해당 문자열을 NSItemProvider에 담아서 전달한 후 받은곳에서 다시 Color로 변환해 사용하는 등 커스텀하게 구현해줄 수 는 있습니다.
마지막으로 커스텀하게 생성한 DropDelegate를 보겠습니다.
우선, DropDelegate 프로토콜을 채택합니다.
그리고 드롭할 아이템의 index와 뷰모델 업데이트를 위해 viewModel을 프로퍼티로 가집니다.
이제 필요한 메서드들의 기능을 채워주면 됩니다.
dropUpdated 메서드를 통해 실제 해당 드롭될 뷰에 위치했을때 어떤 드롭 관련 제안을 해줄 지 반환해줍니다.
현재 구현에서는 위치 변경이기에 move 즉, 이동 동작을 하도록 합니다.
dropEntered를 통해 실제 드롭 영역에 들어오게되면 해당 위치한 컬러를 현재 드롭할 컬러로 변환시키는 로직을 수행합니다.
performDrop을 통해 드롭 시 다시 초기화를 할 수 있도록 기능 동작을 구현해줍니다.
이렇게 입맛대로 DropDelegate를 채택하여 커스텀하게 만들 수 있죠.
그럼 이제 한번 동작이 잘되는지 시뮬레이터로 확인해볼까요?
시뮬레이터 📱
선택된 아이템은 드래그가 시작되면 사라지고 실제 드롭한 위치에 적용되면서 컬러를 나타내는 뷰에서 순서가 변경되고 업데이트 되는걸 볼 수 있습니다!
마무리
이렇게 비교적 간단하게 핵심들만 짚어봤습니다.
각자 환경과 기획에서 적용할 방법에 따라 커스텀하게 이 기초 개념을 가지고 적용하면 좋을것 같네요!
혹시 해당 샘플 코드가 궁금하시면 아래 깃헙 링크를 참고해주세요!