드래그 앤 드롭 Codelab

1. 시작하기 전에

이 Codelab에서는 뷰에서 드래그 앤 드롭 기능을 구현하는 기본사항에 관한 실용적인 안내를 제공합니다. 앱 내에서 그리고 여러 앱 간에 뷰를 드래그 앤 드롭하는 방법을 알아보고 앱 내에서 그리고 여러 앱 간에 드래그 앤 드롭 작용을 구현하는 방법도 살펴봅니다. 이 Codelab에서는 DropHelper를 사용하여 드래그 앤 드롭을 사용 설정하고, ShadowBuilder로 드래그하는 동안 시각적 피드백을 맞춤설정하고, 앱 간 드래그를 위한 권한을 추가하고, 보편적으로 작동하는 콘텐츠 수신자를 구현하는 방법을 안내합니다.

기본 요건

이 Codelab을 완료하려면 다음이 필요합니다.

실행할 작업

다음을 실행하는 간단한 앱을 만듭니다.

  • DragStartHelperDropHelper를 사용하여 드래그 앤 드롭 기능 구현
  • ShadowBuilder 변경
  • 앱 간에 드래그할 권한 추가
  • 보편적인 구현을 위해 리치 콘텐츠 수신기 구현

필요한 항목

2. 드래그 앤 드롭 이벤트

드래그 앤 드롭 프로세스는 4단계의 이벤트로 볼 수 있으며 단계는 다음과 같습니다.

  1. 시작됨: 시스템이 사용자의 드래그 동작에 응답하여 드래그 앤 드롭 작업을 시작합니다.
  2. 진행 중: 사용자가 계속 드래그하고, 타겟 뷰로 들어갈 때 dragshadow 빌더가 시작됩니다.
  3. 끝남: 사용자가 경계 상자 안에서 드래그를 해제하고 드롭 타겟 영역에 타겟을 드롭합니다.
  4. 종료됨: 시스템이 드래그 앤 드롭 작업을 종료하라는 신호를 보냅니다.

시스템은 DragEvent 객체에서 드래그 이벤트를 전송합니다. DragEvent 객체에는 다음 데이터가 포함될 수 있습니다.

  1. ActionType: 드래그 앤 드롭 이벤트의 수명 주기 이벤트에 기반한 이벤트의 작업 값입니다. 예: ACTION_DRAG_STARTED, ACTION_DROP
  2. ClipData: 드래그되고 ClipData 객체에 캡슐화되는 데이터입니다.
  3. ClipDescription: ClipData 객체에 대한 메타 정보입니다.
  4. Result: 드래그 앤 드롭 작업의 결과입니다.
  5. X: 드래그된 객체의 현재 위치의 x 좌표입니다.
  6. Y: 드래그된 객체의 현재 위치의 y 좌표입니다.

3. 설정

새 프로젝트를 만들고 'Empty Views Activity' 템플릿을 선택합니다.

2fbd2bca1483033f.png

모든 매개변수를 기본값으로 둡니다. 프로젝트가 동기화되고 색인을 생성하도록 허용합니다. activity_main.xml 뷰와 함께 MainActivity.kt가 생성된 것을 확인할 수 있습니다.

4. 뷰를 사용하여 드래그 앤 드롭

string.xml에서 문자열 값을 추가해 보겠습니다.

<resources>
    <string name="app_name">DragAndDropCodelab</string>
    <string name="drag_image">Drag Image</string>
    <string name="drop_image">drop image</string>
 </resources>

activity_main.xml 소스 파일을 열고 두 개의 ImageViews를 포함하도록 레이아웃을 수정합니다. 하나는 드래그 소스 역할을 하고 다른 하나는 드롭 타겟입니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/tv_greeting"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/iv_source"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/iv_source"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/drag_image"
        app:layout_constraintBottom_toTopOf="@id/iv_target"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_greeting" />

    <ImageView
        android:id="@+id/iv_target"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/drop_image"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_source" />
</androidx.constraintlayout.widget.ConstraintLayout>

build.gradle.kts에서 뷰 결합을 사용 설정합니다.

buildFeatures{
   viewBinding = true
}

build.gradle.kts에서 Glide용 종속 항목을 추가합니다.

dependencies {
    implementation("com.github.bumptech.glide:glide:4.16.0")
    annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
    
    //other dependencies
}

string.xml에 이미지 URL 및 인사말 텍스트를 추가합니다.

<string name="greeting">Drag and Drop</string>
<string name="target_url">https://services.google.com/fh/files/misc/qq2.jpeg</string>
<string name="source_url">https://services.google.com/fh/files/misc/qq10.jpeg</string>

MainActivity.kt에서 뷰를 초기화해 보겠습니다.

class MainActivity : AppCompatActivity() {
   val binding by lazy(LazyThreadSafetyMode.NONE) {
       ActivityMainBinding.inflate(layoutInflater)
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(binding.root)
       binding.tvGreeting.text = getString(R.string.greeting)
       Glide.with(this).asBitmap()
           .load(getString(R.string.source_url))
           .into(binding.ivSource)
       Glide.with(this).asBitmap()
           .load(getString(R.string.target_url))
           .into(binding.ivTarget)
   }
}

이 상태에서 앱은 인사말 텍스트와 세로 방향의 이미지 2개를 표시해야 합니다.

b0e651aaee336750.png

5. 뷰를 드래그 가능하도록 만들기

특정 뷰를 드래그 가능하도록 만들려면 뷰에서 드래그 동작 시 startDragAndDrop() 메서드를 구현해야 합니다.

사용자가 뷰에서 드래그를 시작할 때 onLongClickListener 콜백을 구현해 보겠습니다.

draggableView.setOnLongClickListener{ v ->
   //drag logic here
   true
}

길게 클릭할 수 없는 뷰라도 이 콜백은 뷰를 길게 클릭 가능하도록 만듭니다. 반환 값은 불리언입니다. True는 콜백이 드래그를 사용함을 나타냅니다.

ClipData 준비: 드래그할 데이터

드롭할 데이터를 정의해 보겠습니다. 데이터는 간단한 텍스트에서 동영상에 이르기까지 어떤 유형이든 될 수 있습니다. 이 데이터는 ClipData 객체에 캡슐화됩니다. ClipData 객체에 하나 이상의 복잡한 ClipItem이 있습니다.

다양한 mime 유형이 ClipDescription에 정의되어 있습니다.

소스 뷰의 이미지 URL을 드래그하고 있습니다. ClipData의 3가지 주요 구성요소는 다음과 같습니다.

  1. 라벨: 드래그되는 항목을 사용자에게 표시하는 간단한 텍스트
  2. Mime 유형: 드래그되는 항목의 MimeType
  3. ClipItem: ClipData.Item 객체에 캡슐화된 드래그되는 항목

ClipData를 만들어 보겠습니다.

val label = "Dragged Image Url"
val clipItem = ClipData.Item(v.tag as? CharSequence)
val mimeTypes = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
val draggedData = ClipData(
   label, mimeTypes, clipItem
)

드래그 앤 드롭 시작

이제 데이터를 드래그할 준비가 되었습니다. 드래그를 시작하겠습니다. 이를 위해 startDragAndDrop을 사용합니다.

startDragAndDrop 메서드가 인수 4개를 취합니다.

  1. 데이터: ClipData. 형식으로 드래그되는 데이터
  2. shadowBuilder: DragShadowBuilder를 사용하여 뷰의 그림자 빌드
  3. myLocalState: 드래그 앤 드롭 작업에 관한 로컬 데이터가 포함된 객체. 드래그 이벤트를 동일한 활동의 뷰로 전달할 때 이 객체는 DragEvent.getLocalState()를 통해 사용할 수 있습니다.
  4. 플래그: 드래그 앤 드롭 작업을 제어하는 플래그

이 함수가 호출되면 View.DragShadowBuilder 클래스를 기반으로 드래그 섀도우가 생깁니다. 시스템에 드래그 섀도우가 있으면 이벤트를 OnDragListener 인터페이스를 구현한 표시 가능한 뷰로 전송하여 드래그 앤 드롭 작업이 시작됩니다.

v.startDragAndDrop(
   draggedData,
   View.DragShadowBuilder(v),
   null,
   0
)

이를 사용하여 드래그를 위한 뷰를 구성하고 드래그할 데이터를 설정했습니다. 최종 구현은 다음과 같습니다.

fun setupDrag(draggableView: View) {
   draggableView.setOnLongClickListener { v ->
       val label = "Dragged Image Url"
       val clipItem = ClipData.Item(v.tag as? CharSequence)
       val mimeTypes = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
       val draggedData = ClipData(
           label, mimeTypes, clipItem
       )
       v.startDragAndDrop(
           draggedData,
           View.DragShadowBuilder(v),
           null,
           0
       )
   }
}

이 단계에서 길게 클릭하면 뷰를 드래그할 수 있습니다.

526e9e2a7f3a90ea.gif

이제 드롭된 뷰를 구성해 보겠습니다.

6. DropTarget용 뷰 구성

OnDragListener 인터페이스를 구현했으므로 뷰는 드롭의 타겟 역할을 할 수 있습니다.

드롭 타겟이 되도록 두 번째 이미지 뷰를 구성해 보겠습니다.

private fun setupDrop(dropTarget: View) {
   dropTarget.setOnDragListener { v, event ->
       // handle drag events here
       true
   }
}

OnDragListener 인터페이스의 onDrag 메서드를 재정의합니다. onDrag 메서드에는 인수가 2개 있습니다.

  1. 드래그 이벤트를 수신한 뷰
  2. 드래그 이벤트의 이벤트 객체

이 메서드는 드래그 이벤트가 성공적으로 처리되면 True를 반환합니다. 그렇지 않은 경우 False를 반환합니다.

DragEvent

드래그 앤 드롭 작업의 여러 단계에서 시스템에 의해 전송되는 데이터 패키지를 나타냅니다. 이 데이터 패키지는 작업 자체 및 포함된 데이터와 관련된 중요한 정보를 캡슐화합니다.

DragEvent에는 드래그 앤 드롭 작업의 단계에 따라 다른 드래그 작업이 있습니다.

  1. ACTION_DRAG_STARTED: 드래그 앤 드롭 작업의 시작을 알립니다.
  2. ACTION _DRAG_LOCATION: 사용자가 타겟 드롭 영역의 경계 안쪽이 아닌 들어온 상태에서 드래그를 해제했음을 나타냅니다.
  3. ACTION_DRAG_ENTERED: 드래그된 뷰가 타겟 드롭 뷰의 경계 안쪽에 있음을 나타냅니다.
  4. ACTION_DROP: 사용자가 타겟 드롭 영역에서 드래그를 해제했음을 나타냅니다.
  5. ACTION_DRAG_ENDED: 드래그 앤 드롭 작업이 완료되었음을 나타냅니다.
  6. ACTION_DRAG_EXITED: 드래그 앤 드롭 작업이 종료되었음을 나타냅니다.

DragEvent 유효성 검사

ACTION_DRAG_STARTED 이벤트의 모든 제약 조건이 충족되면 드래그 앤 드롭 작업을 계속 진행할 수 있습니다. 예를 들어 이 예에서는 수신 데이터가 올바른 유형인지 확인할 수 있습니다.

DragEvent.ACTION_DRAG_STARTED -> {
   Log.d(TAG, "ON DRAG STARTED")
   if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
       (v as? ImageView)?.alpha = 0.5F
       v.invalidate()
       true
   } else {
       false
   }
}

이 예에서는 이벤트의 ClipDescription에 허용되는 MIME 유형이 있는지 확인했습니다. 그렇다면 드래그된 데이터가 처리되고 있음을 나타내는 시각적 신호를 제공하고 True를 반환합니다. 그렇지 않으면 드롭 타겟 뷰에 의해 드래그가 삭제된다는 것을 나타내기 위해 False를 반환됩니다.

드롭 데이터 처리

ACTION_DROP 이벤트에서 드롭된 데이터를 어떻게 처리할지 선택할 수 있습니다. 이 예에서는 ClipData에 추가한 URL을 텍스트로 추출합니다. 이 이미지를 URL에서 타겟 이미지 뷰로 옮깁니다.

DragEvent.ACTION_DROP -> {
   Log.d(TAG, "On DROP")
   val item: ClipData.Item = event.clipData.getItemAt(0)
   val dragData = item.text
   Glide.with(this).load(item.text).into(v as ImageView)
   (v as? ImageView)?.alpha = 1.0F
   true
}

드롭 처리 외에도 사용자가 타겟 드롭 뷰의 경계 상자에서 뷰를 드래그할 때 발생하는 작업과 사용자가 타겟 영역 밖으로 뷰를 드래그할 때 발생하는 결과를 구성할 수 있습니다.

드래그된 항목이 타겟 영역에 진입할 때 시각적 단서를 추가해 보겠습니다.

DragEvent.ACTION_DRAG_ENTERED -> {
   Log.d(TAG, "ON DRAG ENTERED")
   (v as? ImageView)?.alpha = 0.3F
   v.invalidate()
   true
}

또한 사용자가 타겟 드롭 뷰의 경계 상자 밖으로 뷰를 드래그할 때 더 많은 시각적 단서를 추가합니다.

DragEvent.ACTION_DRAG_EXITED -> {
   Log.d(TAG, "ON DRAG EXISTED")
   (v as? ImageView)?.alpha = 0.5F
   v.invalidate()
   true
}

드래그 앤 드롭 작업이 끝났음을 나타내는 시각적 단서를 더 추가합니다.

DragEvent.ACTION_DRAG_ENDED -> {
   Log.d(TAG, "ON DRAG ENDED")
   (v as? ImageView)?.alpha = 1.0F
   true
}

이 단계에서 이미지를 타겟 이미지 뷰로 드래그할 수 있습니다. 드롭되면 타겟 ImageView의 이미지에 변경사항이 반영됩니다.

114238f666d84c6f.gif

7. 멀티 윈도우 모드에서 드래그 앤 드롭

항목을 한 앱에서 다른 앱으로 드래그할 수 있습니다. 앱이 멀티 윈도우 모드를 통해 화면을 공유합니다. 앱 간에 드래그 앤 드롭을 사용 설정하는 구현은 동일하지만 드래그 중에 플래그를 추가하고 드롭 중에 권한을 추가해야 합니다.

드래그 중 플래그 구성

앞서 설명한 바와 같이 startDragAndDrop에는 플래그를 지정하는 인수가 하나 있으며 이 인수는 곧 드래그 앤 드롭 작업을 제어합니다.

v.startDragAndDrop(
   draggedData,
   View.DragShadowBuilder(v),
   null,
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ
)

View.DRAG_FLAG_GLOBAL은 드래그가 창 경계를 넘을 수 있음을 나타내고 View.DRAG_FLAG_GLOBAL_URI_READ는 드래그 수신자가 콘텐츠 URI를 읽을 수 있음을 나타냅니다.

드롭 타겟이 다른 앱에서 드래그된 데이터를 읽으려면 드롭 타겟 뷰에서 읽기 권한을 선언해야 합니다.

val dropPermission = requestDragAndDropPermissions(event)

또한 드래그된 데이터가 처리되면 권한을 해제합니다.

dropPermission.release()

드래그된 항목의 최종 처리는 다음과 같습니다.

DragEvent.ACTION_DROP -> {
   Log.d(TAG, "On DROP")
   val dropPermission = requestDragAndDropPermissions(event)
   val item: ClipData.Item = event.clipData.getItemAt(0)
   val dragData = item.text
   Glide.with(this).load(item.text).into(v as ImageView)
   (v as? ImageView)?.alpha = 1.0F
   dropPermission.release()
   true
}

이 단계에서 이 이미지를 다른 앱으로 드래그할 수 있어야 하며 다른 앱에서 드래그한 데이터도 올바르게 처리할 수 있습니다.

8. 라이브러리 드래그 앤 드롭

Jetpack은 드래그 앤 드롭 작업 구현을 간소화하기 위해 DragAndDrop 라이브러리를 제공합니다.

DragAndDrop 라이브러리를 사용하기 위해 build.gradle.kts에 종속 항목을 추가해 보겠습니다.

implementation("androidx.draganddrop:draganddrop:1.0.0")

이 활동에서는 세로 형식의 ImageView가 2개 있는 DndHelperActivity.kt라는 별도의 활동을 만듭니다. 그중 하나는 드래그 소스 역할을 하고 다른 하나는 드롭 타겟입니다.

strings.xml을 수정하여 문자열 리소스를 추가합니다.

<string name="greeting_1">DragStartHelper and DropHelper</string>
<string name="target_url_1">https://services.google.com/fh/files/misc/qq9.jpeg</string>
<string name="source_url_1">https://services.google.com/fh/files/misc/qq8.jpeg</string>

ImageViews를 포함하도록 activity_dnd_helper.xml을 업데이트합니다,

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:padding="24dp"
   tools:context=".DnDHelperActivity">

   <TextView
       android:id="@+id/tv_greeting"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toTopOf="@id/iv_source"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <ImageView
       android:id="@+id/iv_source"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:contentDescription="@string/drag_image"
       app:layout_constraintBottom_toTopOf="@id/iv_target"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toBottomOf="@id/tv_greeting" />

   <ImageView
       android:id="@+id/iv_target"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:contentDescription="@string/drop_image"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toBottomOf="@id/iv_source" />
</androidx.constraintlayout.widget.ConstraintLayout>

마지막으로 DnDHelperActivity.kt에서 뷰를 초기화합니다.

class DnDHelperActivity : AppCompatActivity() {
   private val binding by lazy(LazyThreadSafetyMode.NONE) {
       ActivityMainBinding.inflate(layoutInflater)
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(binding.root)
       binding.tvGreeting.text = getString(R.string.greeting)
       Glide.with(this).asBitmap()
           .load(getString(R.string.source_url_1))
           .into(binding.ivSource)
       Glide.with(this).asBitmap()
           .load(getString(R.string.target_url_1))
           .into(binding.ivTarget)
       binding.ivSource.tag = getString(R.string.source_url_1)
   }
}

DndHelperActivity를 런처 활동으로 사용하도록 AndroidManifest.xml을 업데이트해야 합니다.

<activity
   android:name=".DnDHelperActivity"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.MAIN" />
       <category android:name="android.intent.category.LAUNCHER" />
   </intent-filter>
</activity>

DragStartHelper

이전에는 onLongClickListener를 구현하고 startDragAndDrop을 호출하여 드래그 가능한 뷰를 구성했습니다. DragStartHelper는 유틸리티 메서드를 제공하여 구현을 간소화합니다.

DragStartHelper(draggableView)
{ view: View, _: DragStartHelper ->
   // prepare clipData

   // startDrag and Drop
}.attach()

DragStartHelper는 드래그되는 뷰를 인수로 취합니다. 여기에서는 clipdata를 준비하고 드래그 앤 드롭 작업을 시작하는 OnDragStartListener 메서드를 구현했습니다.

최종 구현은 다음과 같습니다.

DragStartHelper(draggableView)
{ view: View, _: DragStartHelper ->
   val item = ClipData.Item(view.tag as? CharSequence)
   val dragData = ClipData(
       view.tag as? CharSequence,
       arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN),
       item
   )
   view.startDragAndDrop(
       dragData,
       View.DragShadowBuilder(view),
       null,
       0
   )
}.attach()

DropHelper

DropHelperconfigureView라는 유틸리티 메서드를 제공하여 타겟 드롭 뷰 구성을 간소화합니다.

configureView는 인수 4개를 취합니다.

  1. 활동: 현재 활동
  2. dropTarget: 구성 중인 뷰
  3. mimeTypes: 삭제되는 데이터 항목의 mimeTypes
  4. OnReceiveContentListener: 드롭된 데이터를 처리하기 위한 인터페이스

타겟 드롭 강조표시를 맞춤설정합니다.

DropHelper.configureView(
   This, // Current Activity
   dropTarget,
   arrayOf("text/*"),
   DropHelper.Options.Builder().build()
) {
   // handle the dropped data
}

OnRecieveContentListener가 삭제된 콘텐츠를 수신합니다. 여기에는 두 가지 매개변수가 있습니다.

  1. 뷰: 콘텐츠가 드롭되는 위치
  2. 페이로드: 드롭되는 실제 콘텐츠
private fun setupDrop(dropTarget: View) {
   DropHelper.configureView(
       this,
       dropTarget,
       arrayOf("text/*"),
   ) { _, payload: ContentInfoCompat ->
       // TODO: step through clips if one cannot be loaded
       val item = payload.clip.getItemAt(0)
       val dragData = item.text
       Glide.with(this)
           .load(dragData)
           .centerCrop().into(dropTarget as ImageView)
       // Consume payload by only returning remaining items
       val (_, remaining) = payload.partition { it == item }
       remaining
   }
}

이 단계에서 DragStartHelper 및 DropHelper를 사용하여 데이터를 드래그 앤 드롭할 수 있어야 합니다.

2e32d6cd80e19dcb.gif

드롭 영역 강조표시 구성

드래그한 항목이 드롭 영역으로 들어가면 드롭 영역이 강조표시됩니다. DropHelper.Options를 사용하면 드래그한 항목이 뷰 경계에 들어갈 때 드롭 영역이 강조 표시되는 방식을 맞춤설정할 수 있습니다.

DropHelper.Options는 강조 색상을 구성하고 드롭 타겟 영역의 모서리 반경을 강조표시하는 데 사용할 수 있습니다.

DropHelper.Options.Builder()
   .setHighlightColor(getColor(R.color.green))
   .setHighlightCornerRadiusPx(16)
   .build()

이러한 옵션은 DropHelper에서 configureView 메서드에 인수로 전달되어야 합니다.

private fun setupDrop(dropTarget: View) {
   DropHelper.configureView(
       this,
       dropTarget,
       arrayOf("text/*"),
       DropHelper.Options.Builder()
           .setHighlightColor(getColor(R.color.green))
           .setHighlightCornerRadiusPx(16)
           .build(),
   ) { _, payload: ContentInfoCompat ->
       // TODO: step through clips if one cannot be loaded
       val item = payload.clip.getItemAt(0)
       val dragData = item.text
       Glide.with(this)
           .load(dragData)
           .centerCrop().into(dropTarget as ImageView)
       // Consume payload by only returning remaining items
       val (_, remaining) = payload.partition { it == item }
       remaining
   }
}

드래그 앤 드롭하는 동안 강조 색상과 반경을 볼 수 있어야 합니다.

9d5c1c78ecf8575f.gif

9. 리치 콘텐츠 받기

OnReceiveContentListener는 텍스트, HTML, 이미지, 동영상 등을 포함한 리치 콘텐츠를 수신하는 통합 API입니다. 키보드, 드래그 또는 클립보드를 사용해 뷰에 콘텐츠를 삽입할 수 있습니다. 각 입력 메커니즘에 대해 콜백을 유지하는 것은 번거로울 수 있습니다. OnReceiveContentListener는 단일 API를 사용하여 텍스트, 마크업, 오디오, 동영상, 이미지 등의 콘텐츠를 수신하는 데 사용할 수 있습니다. OnReceiveContentListener API는 구현할 단일 API를 만들어 이러한 다양한 코드 경로를 통합하므로 개발자는 앱별 로직에 집중하고 나머지는 플랫폼에서 처리하도록 할 수 있습니다.

이 활동에서는 세로 형식의 ImageView가 2개 있는 ReceiveRichContentActivity.kt라는 별도의 활동을 만듭니다. 그중 하나는 드래그 소스 역할을 하고 다른 하나는 드롭 타겟입니다.

strings.xml을 수정하여 문자열 리소스를 추가합니다.

<string name="greeting_2">Rich Content Receiver</string>
<string name="target_url_2">https://services.google.com/fh/files/misc/qq1.jpeg</string>
<string name="source_url_2">https://services.google.com/fh/files/misc/qq3.jpeg</string>

ImageView를 포함하도록 activity_receive_rich_content.xml을 업데이트합니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".ReceiveRichContentActivity">

   <TextView
       android:id="@+id/tv_greeting"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toTopOf="@id/iv_source"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <ImageView
       android:id="@+id/iv_source"
       android:layout_width="320dp"
       android:layout_height="wrap_content"
       android:contentDescription="@string/drag_image"
       app:layout_constraintBottom_toTopOf="@id/iv_target"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toBottomOf="@id/tv_greeting" />

   <ImageView
       android:id="@+id/iv_target"
       android:layout_width="320dp"
       android:layout_height="wrap_content"
       android:contentDescription="@string/drop_image"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toBottomOf="@id/iv_source" />
</androidx.constraintlayout.widget.ConstraintLayout>

마지막으로 ReceiveRichContentActivity.kt에서 뷰를 초기화합니다.

class ReceiveRichContentActivity : AppCompatActivity() {
   private val binding by lazy(LazyThreadSafetyMode.NONE) {
       ActivityReceiveRichContentBinding.inflate(layoutInflater)
   }
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(binding.root)
       binding.tvGreeting.text = getString(R.string.greeting_2)
       Glide.with(this).asBitmap()
           .load(getString(R.string.source_url_2))
           .into(binding.ivSource)
       Glide.with(this).asBitmap()
           .load(getString(R.string.target_url_2))
           .into(binding.ivTarget)
       binding.ivSource.tag = getString(R.string.source_url_2)
   }
}

DndHelperActivity를 런처 활동으로 사용하도록 AndroidManifest.xml을 업데이트해야 합니다.

<activity
   android:name=".ReceiveRichContentActivity"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.MAIN" />
       <category android:name="android.intent.category.LAUNCHER" />
   </intent-filter>
</activity>

먼저 OnReceiveContentListener.를 구현하는 콜백을 만들어 보겠습니다.

val listener = OnReceiveContentListener { view, payload ->
   val (textContent, remaining) =
       payload.partition { item: ClipData.Item -> item.text != null }
   if (textContent != null) {
       val clip = textContent.clip
       for (i in 0 until clip.itemCount) {
           val currentText = clip.getItemAt(i).text
           Glide.with(this)
               .load(currentText)
               .centerCrop().into(view as ImageView)
       }
   }
   remaining
}

여기에서 OnRecieveContentListener 인터페이스를 구현했습니다. onRecieveContent 메서드에 인수가 2개 있습니다.

  1. 데이터를 수신하는 현재 뷰
  2. 키보드, 드래그 또는 클립보드의 ContentInfoCompat 형식 데이터 페이로드

이 메서드는 처리되지 않은 페이로드를 반환합니다.

여기서는 Partition 메서드를 사용하여 페이로드를 텍스트 콘텐츠와 기타 콘텐츠로 분리했습니다. 필요에 따라 텍스트 데이터를 처리하고 나머지 페이로드를 반환합니다.

드래그한 데이터로 하고 싶은 작업을 처리해 보겠습니다.

val listener = OnReceiveContentListener { view, payload ->
   val (textContent, remaining) =
       payload.partition { item: ClipData.Item -> item.text != null }
   if (textContent != null) {
       val clip = textContent.clip
       for (i in 0 until clip.itemCount) {
           val currentText = clip.getItemAt(i).text
           Glide.with(this)
               .load(currentText)
               .centerCrop().into(view as ImageView)
       }
   }
   remaining
}

이제 리스너가 준비되었습니다. 이 리스너를 타겟 뷰에 추가해 보겠습니다.

ViewCompat.setOnReceiveContentListener(
   binding.ivTarget,
   arrayOf("text/*"),
   listener
)

이 단계에서는 이미지를 드래그하여 타겟 영역으로 드롭할 수 있습니다. 드롭하면 드래그한 이미지가 드롭 타겟 뷰의 원본 이미지를 대체합니다.

e4c3a3163c51135d.gif

10. 축하합니다

이제 Android 앱에 드래그 앤 드롭을 능숙하게 구현할 수 있습니다. 이 Codelab을 통해 Android 앱 내에서 그리고 다양한 앱 간에 대화형 드래그 앤 드롭 상호작용을 만들어 사용자 환경과 기능을 향상하는 방법을 알아봤습니다. 배운 내용은 다음과 같습니다.

  • 드래그 앤 드롭의 기본사항: 드래그 앤 드롭 이벤트의 4단계(시작됨, 진행 중, 끝남, 종료됨) 및 DragEvent 객체 내의 주요 데이터 이해
  • 드래그 앤 드롭 사용 설정: 뷰를 드래그 가능하게 만들고 DragEvent를 처리하여 타겟 뷰에서 드롭 처리
  • 멀티 윈도우 모드에서 드래그 앤 드롭: 적절한 플래그 및 권한을 설정하여 앱 간 드래그 앤 드롭 사용 설정
  • DragAndDrop 라이브러리 사용: Jetpack 라이브러리를 사용하여 드래그 앤 드롭 구현 간소화
  • 리치 콘텐츠 수신: 통합 API를 사용하여 다양한 입력 방법에서 다양한 콘텐츠 유형(텍스트, 이미지, 동영상 등)을 처리하도록 구현

자세히 알아보기