Custom Android Views: Drag and Drop
You can view the all of the source code for this here:
https://github.com/SupahSoftware/AndroidExampleDragDrop
In this article, we will cover how to create a custom view that handles drag and drop functionality, and we will be writing tests for it as well!
Here are the libraries you will need in your app level build.gradle
file
build.gradle
dependencies {
// if using Kotlin
implementation 'androidx.core:core-ktx:1.3.0'
// if writing tests
testImplementation 'junit:junit:4.12'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
testImplementation 'org.robolectric:robolectric:4.3.1'
testImplementation 'androidx.test:core:1.2.0'
}
Next we are going to start building out our custom view that receives drag and drop events, let’s call it DragAndDropContainer.kt
DragAndDropContainer.kt
class DragAndDropContainer(
context: Context,
attrs: AttributeSet?
) : FrameLayout(context, attrs) {
// TODO
}
We are extending FrameLayout, one of Android’s most basic ViewGroups. We will let the user decide what kind of ViewGroup they want to house inside of that FrameLayout in the XML later.
We want to hold on to a reference of the containers current content (View or ViewGroup) so that we can easily access it, rather than having to dynamically strip it out of the container.
private var content: View? = null
To set and remove that content, we will need two more public functions, and two private helper methods.
fun setContent(view: View) {
removeAllViews()
addView(view)
updateChild()
}fun removeContent(view: View) {
view.setOnLongClickListener(null)
removeView(view)
updateChild()
}private fun updateChild() {
content = getFirstChild()
content?.setOnLongClickListener { startDrag() }
}private fun getFirstChild() = if (childCount == 1) getChildAt(0) else null
Let’s break this down. We want getFirstChild()
to return either the very first child of our container layout, or null. We will be ensuring the user is only allowed to have a maximum of 1 child view later. With this function, we will be setting the content when new content is dropped in, and setting it to null when content is dragged out.
As for the longClickListener
, we want to ‘initiate’ the drag and drop process once the user has performed a long press on our container’s child. We also want to make sure to set that to null in the removeContent()
method before we remove the view.
Before we get into ‘initiating’ the drag and drop process, let’s lock down our view a bit so that the user can only add one child to our DragAndDropContainer
in the XML declaration.
private fun validateChildCount() = check(childCount <= 1) {
"There should be a maximum of 1 child inside of a DragAndDropContainer, but there were $childCount"
}
This will throw
and IllegalStateException
if the the view declaration ever has more than 1 child view. You can remove this check if you want multiple dragable views to live in a container. We will call validateChildCount
from within the updateChild()
method first thing, so if the view is in a bad state, we abort and let the user know!
Let’s initiate the drag and drop process!
private fun startDrag(): Boolean {
content?.let {
val tag = it.tag as? CharSequence
val item = ClipData.Item(tag)
val data = ClipData(tag, arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), item)
val shadow = DragShadowBuilder(it)
if (Build.VERSION.SDK_INT >= 24) {
it.startDragAndDrop(data, shadow, it, 0)
} else {
it.startDrag(data, shadow, it, 0)
}
return true
} ?: return false
}
This functions main responsibility is to start the drag and drop process. The tag
item
and data
are for actually grabbing the view and making it possible to drop that view in another container. The shadow
is responsible for giving the user an indication that they are actually moving a view around. This is the default Android provided view shadow, which will basically look like an exact copy of the view you are dragging, except a little bit transparent. You can subclass Android’s DragShadowBuilder
to create your own shadow functionality, get creative!
Lastly for this class, we want to add a few more things -
private val dragAndDropListener by lazy { DragAndDropListener() }
init {
setOnDragListener(dragAndDropListener)
}override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
updateChild()
}
Firstly, we want to create a new object DragAndDropListener()
, we will create that class in just a moment. We then want to set that listener on our Container view by calling setOnDragListener()
. onLayout
allows our child and content to be updated on creation with whatever View or ViewGroup is defined inside of the container in the XML layout for the Activity.
Onward, to create our DragAndDropListener
!
DragAndDropListener.kt
class DragAndDropListener : View.OnDragListener {
override fun onDrag(view: View, event: DragEvent): Boolean {
}
}
We can start by extending Android’s View.OnDragListener
. This will force us to implement onDrag()
, which will pass the view (which is the container view that is receiving the event, not the view that is being dragged) and the DragEvent
which will tell us which event is being triggered. For the purposes of this tutorial, we will only care about 4 of these events.
- ACTION_DRAG_ENTERED — When a view being dragged has entered a new container that is able to accept dropped views.
- ACTION_DRAG_EXITED — When a view being dragged has exited a container that is able to accept dropped views.\
- ACTION_DROP — When a view being dragged has been dropped in a container, meaning someone lifted their finger from the screen while over that new container.
- ACTION_DRAG_STARTED — When the drag action has actually started. This is where we want to check if we want to allow them to start the drag or not.
You can check my source code if you’d like to see what I did in the other functions, but let’s just focus on the meat of this listener ACTION_DROP
.
class DragAndDropListener : View.OnDragListener {
override fun onDrag(view: View, event: DragEvent): Boolean {
return when (event.action) {
DragEvent.ACTION_DROP -> {
val draggingView = event.localState as View
val draggingViewParent =
draggingView.parent as DragAndDropContainer
draggingViewParent.removeContent(draggingView) val landingContainer = view as DragAndDropContainer
landingContainer.setContent(draggingView)
true
}
}
}
}
There are two main parts to this, removing the content from the old container, and adding it to the new container. Firstly, we want strip the view being dragged off of the event, we can do this because we set up all of that in the our custom view when the user performs a long press. We then call removeContent
on our container view. Then of course we want to add it to the new container, which is provided to us by the onDrag
callback function.
The boolean return value of this function is important, as we want to return true
if we handle the event and false
if we don’t, so Android can dispatch this event elsewhere.
Defining our DragAndDropContainer
in XML
<supahsoftware.androidexampledragdrop.DragAndDropContainer
android:id="@+id/container_1"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"> <FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"> <ImageView
android:id="@+id/content_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/banner"
android:scaleType="center" /> </FrameLayout></supahsoftware.androidexampledragdrop.DragAndDropContainer><supahsoftware.androidexampledragdrop.DragAndDropContainer
android:id="@+id/container_2"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp" /><supahsoftware.androidexampledragdrop.DragAndDropContainer
android:id="@+id/container_3"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp" />
As you can see, we defined one single ViewGroup as the child for our first DragAndDropContainer
. If we try and define two children, we will get an IllegalStateException
as described before. This FrameLayout
will be the view (and all of it’s recursive children) that get’s dragged around. This FrameLayout
and it’s children are also what the shadow is generated from.
You can also not define any children from your DragAndDropContainer
if you wish, just like the second two I have. If you have all of your containers empty, you can dynamically populate them with data that comes back from your server, just make sure that when you are dynamically setting the content you are only passing in a one child. Our view mostly protects from being able to add multiple child by removing all the views before setting the new view as the content.
And there you have it, a view you can now use to create drag and drop functionality around your application! Read further if you would like to see how I wrote unit tests for these classes!
BaseRobolectricTest.kt
@RunWith(RobolectricTestRunner::class)abstract class
BaseRobolectricTest {
protected val context: Application by lazy {
ApplicationProvider.getApplicationContext<Application>()
}
}
This will allow us to easily get context for working with our Views in tests.
I don’t want to paste all of the test code here, but please feel free to check out the direct links to the files in my Github repository below.
DragAndDropContainerTest.kt
Here is the test code . This was more difficult than the listener tests. Making sure View.startDragAndDrop()
was called and with the right values proved challenging, which is why I resorted to using a spy for the view. Also, neither the ClipData
or View.DragShadowBuilder
had helpful equal comparisons, so I had to fall back to argument captors and comparing values that I was able to strip off of them.
DragAndDropListenerTest.kt
Here is the test code . This was super straight forward. Create our test object as a DragAndDropListener()
, mock the DragEvent
we pass in to return 4 expected actions in order when it’s called, and assert the views change in the way we expect them to. In my DragAndDropListener specifically , you can see I change the background drawable for certain states, so that is what I am testing in the first test case.
In the second test case, I assert that when ACTION_DROP
is completed that the dragging view is now a child of the landing container and no longer part of the originating container.