From 6f93d0c234b5be349d3be378864e28edd7194116 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 26 Jun 2024 00:22:44 +0200 Subject: [PATCH] Cleanup --- build-logic/convention/.gitignore | 1 + dragdrop/.gitignore | 1 + dragdrop/build.gradle.kts | 56 ++++++++ .../unbounddragdrop/DragDropHelper.kt | 136 ++++++++++++++++++ .../unbounddragdrop/DropListener.kt | 66 +++++++++ .../RecyclerItemClickListener.kt | 87 +++++++++++ 6 files changed, 347 insertions(+) create mode 100644 build-logic/convention/.gitignore create mode 100644 dragdrop/.gitignore create mode 100644 dragdrop/build.gradle.kts create mode 100644 dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DragDropHelper.kt create mode 100644 dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DropListener.kt create mode 100644 dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/RecyclerItemClickListener.kt diff --git a/build-logic/convention/.gitignore b/build-logic/convention/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/build-logic/convention/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dragdrop/.gitignore b/dragdrop/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/dragdrop/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dragdrop/build.gradle.kts b/dragdrop/build.gradle.kts new file mode 100644 index 0000000..0140d9f --- /dev/null +++ b/dragdrop/build.gradle.kts @@ -0,0 +1,56 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.convention.android.library) + alias(libs.plugins.convention.android.library.publish) +} + +android { + namespace = "dev.adriankuta.unbounddragdrop" + version = "0.0.2" + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + mavenPublishing { + coordinates("dev.adriankuta", "unbound-drag-drop", version.toString()) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + signAllPublications() + pom { + name = "Unbound Drag & Drop" + description = + "Unbound Drag & Drop enhances your Android apps by enabling drag and drop across multiple RecyclerViews, unlike the default single RecyclerView restriction. This feature allows users to seamlessly move items between different RecyclerViews, offering a more flexible and intuitive user experience." + inceptionYear = "2024" + url = "https://github.com/AdrianKuta/Unbound-Drag-Drop" + licenses { + license { + name.set("MIT License") + url.set("https://github.com/AdrianKuta/Unbound-Drag-Drop/blob/master/LICENSE") + distribution.set("repo") + } + } + developers { + developer { + id = "AdrianKuta" + name = "Adrian Kuta" + url = "https://adriankuta.dev/" + } + } + scm { + url = "https://github.com/AdrianKuta/Unbound-Drag-Drop" + connection = "scm:git:git://github.com/AdrianKuta/Unbound-Drag-Drop.git" + developerConnection = "scm:git:ssh://git@github.com/AdrianKuta/Unbound-Drag-Drop.git" + } + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DragDropHelper.kt b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DragDropHelper.kt new file mode 100644 index 0000000..df38747 --- /dev/null +++ b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DragDropHelper.kt @@ -0,0 +1,136 @@ +package dev.adriankuta.unbounddragdrop + +import android.content.ClipData +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.DRAG_FLAG_OPAQUE +import androidx.recyclerview.widget.RecyclerView.ViewHolder +/** + * Helper class to handle drag and drop functionality in a RecyclerView. + * + * @param callback A Callback object to handle the drag and drop events. + */ +class DragDropHelper(callback: Callback) : + RecyclerView.OnChildAttachStateChangeListener { + private var mRecyclerView: RecyclerView? = null + private var recyclerItemClickListener: RecyclerItemClickListener? = null + private val dropListener = DropListener(callback) + private val onItemLongClickListener: RecyclerItemClickListener.OnItemLongClickListener by lazy { + object : RecyclerItemClickListener.OnItemLongClickListener { + /** + * Called when an item is long-clicked. Starts the drag operation. + * + * @param view The view that was long-clicked. + * @param position The position of the item in the adapter. + */ + override fun onItemLongClick(view: View, position: Int) { + val data = ClipData.newPlainText("", "") + val shadowBuilder = View.DragShadowBuilder(view) + view.startDragAndDrop(data, shadowBuilder, view, DRAG_FLAG_OPAQUE) + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + } + } + + /** + * Attaches the DragDropHelper to the specified RecyclerView. + * + * @param recyclerView The RecyclerView to attach to. + */ + fun attachToRecyclerView(recyclerView: RecyclerView?) { + if (mRecyclerView === recyclerView) { + return // nothing to do + } + if (mRecyclerView != null) { + destroyCallbacks() + } + mRecyclerView = recyclerView + mRecyclerView?.let { + setupCallbacks() + } + } + + /** + * Sets up the necessary callbacks for the RecyclerView. + */ + private fun setupCallbacks() { + mRecyclerView?.apply { + recyclerItemClickListener = RecyclerItemClickListener( + context, + this, + onItemLongClickListener + ).also { + mRecyclerView?.addOnItemTouchListener(it) + } + addOnChildAttachStateChangeListener(this@DragDropHelper) + setOnDragListener(dropListener) + } + } + + /** + * Removes the callbacks from the RecyclerView. + */ + private fun destroyCallbacks() { + recyclerItemClickListener?.let { + mRecyclerView?.removeOnItemTouchListener(it) + } + mRecyclerView?.removeOnChildAttachStateChangeListener(this) + mRecyclerView?.setOnDragListener(null) + } + + /** + * Called when a child view is attached to the RecyclerView. + * + * @param view The child view that was attached. + */ + override fun onChildViewAttachedToWindow(view: View) { + view.setOnDragListener(dropListener) + } + + /** + * Called when a child view is detached from the RecyclerView. + * + * @param view The child view that was detached. + */ + override fun onChildViewDetachedFromWindow(view: View) { + view.setOnDragListener(null) + } + + /** + * Abstract class to handle drag and drop events. + */ + abstract class Callback { + + /** + * Called when an item is moved within or between RecyclerViews. + * + * @param recyclerView The RecyclerView containing the dragged item. + * @param viewHolder The ViewHolder of the dragged item. + * @param targetRecyclerView The RecyclerView where the item is dropped. + * @param targetViewHolder The ViewHolder of the target position. + * @return True if the move was handled, false otherwise. + */ + abstract fun onMove( + recyclerView: RecyclerView, + viewHolder: ViewHolder, + targetRecyclerView: RecyclerView, + targetViewHolder: ViewHolder? + ): Boolean + + /** + * Called when an item has been dropped. + * + * @param recyclerView The RecyclerView containing the dragged item. + * @param viewHolder The ViewHolder of the dragged item. + * @param targetRecyclerView The RecyclerView where the item is dropped. + * @param targetViewHolder The ViewHolder of the target position. + */ + abstract fun onMoved( + recyclerView: RecyclerView, + viewHolder: ViewHolder, + targetRecyclerView: RecyclerView, + targetViewHolder: ViewHolder? + ) + } +} \ No newline at end of file diff --git a/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DropListener.kt b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DropListener.kt new file mode 100644 index 0000000..65091b6 --- /dev/null +++ b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/DropListener.kt @@ -0,0 +1,66 @@ +package dev.adriankuta.unbounddragdrop + +import android.view.DragEvent +import android.view.View +import android.view.View.OnDragListener +import androidx.recyclerview.widget.RecyclerView + +internal class DropListener(private val callback: DragDropHelper.Callback) : OnDragListener { + + /** + * Handles drag events on the target view. + * + * @param targetView The view that is being dragged over or dropped onto. + * @param event The drag event. + * @return True if the event was handled, false otherwise. + */ + override fun onDrag(targetView: View?, event: DragEvent?): Boolean { + targetView ?: return false + event?.let { + if (it.action == DragEvent.ACTION_DROP) { + val sourceView = it.localState as View + val sourceRecyclerView = sourceView.parent as RecyclerView + val sourcePosition = sourceRecyclerView.getChildAdapterPosition(sourceView) + val sourceViewHolder = + sourceRecyclerView.findViewHolderForAdapterPosition(sourcePosition) + ?: return false + + val targetRecyclerView = getRecyclerView(targetView) ?: return false + val targetAdapter = targetRecyclerView.adapter + val targetPosition = if (targetView is RecyclerView) { + targetAdapter?.itemCount ?: 0 + } else { + targetRecyclerView.getChildAdapterPosition(targetView) + } + val targetViewHolder = + targetRecyclerView.findViewHolderForAdapterPosition(targetPosition) + + if (callback.onMove( + sourceRecyclerView, + sourceViewHolder, + targetRecyclerView, + targetViewHolder + ) + ) { + callback.onMoved( + sourceRecyclerView, + sourceViewHolder, + targetRecyclerView, + targetViewHolder + ) + } + } + } + return true + } + + /** + * Retrieves the RecyclerView associated with the given view. + * + * @param view The view to check. + * @return The RecyclerView if found, null otherwise. + */ + private fun getRecyclerView(view: View): RecyclerView? { + return view as? RecyclerView ?: view.parent as? RecyclerView + } +} \ No newline at end of file diff --git a/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/RecyclerItemClickListener.kt b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/RecyclerItemClickListener.kt new file mode 100644 index 0000000..c852a23 --- /dev/null +++ b/dragdrop/src/main/kotlin/dev/adriankuta/unbounddragdrop/RecyclerItemClickListener.kt @@ -0,0 +1,87 @@ +package dev.adriankuta.unbounddragdrop + +import android.content.Context +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +internal class RecyclerItemClickListener( + context: Context, + private val recyclerView: RecyclerView, + private val listener: OnItemLongClickListener? +) : RecyclerView.OnItemTouchListener { + + private val gestureDetector: GestureDetector = + GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + /** + * Called when a long press gesture is detected. + * + * @param e The motion event triggering the long press. + */ + override fun onLongPress(e: MotionEvent) { + val childView = recyclerView.findChildViewUnder(e.x, e.y) + if (childView != null && listener != null) { + listener.onItemLongClick( + childView, + recyclerView.getChildAdapterPosition(childView) + ) + } + } + + /** + * Called when a single tap up gesture is detected. + * + * @param e The motion event triggering the single tap. + * @return True to indicate the event is handled. + */ + override fun onSingleTapUp(e: MotionEvent): Boolean { + return true + } + }) + + /** + * Interface definition for a callback to be invoked when an item in this + * RecyclerView has been long clicked. + */ + interface OnItemLongClickListener { + /** + * Called when an item has been long clicked. + * + * @param view The view that was clicked. + * @param position The position of the view in the adapter. + */ + fun onItemLongClick(view: View, position: Int) + } + + /** + * Intercept touch events to determine if a long press has occurred. + * + * @param rv The RecyclerView. + * @param e The motion event. + * @return True if the event is intercepted, false otherwise. + */ + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + val childView = rv.findChildViewUnder(e.x, e.y) + return childView != null && childView.isLongClickable && gestureDetector.onTouchEvent(e) + } + + /** + * Handle touch events (not needed for this implementation). + * + * @param rv The RecyclerView. + * @param e The motion event. + */ + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + // Not needed + } + + /** + * Request to disallow intercepting touch events (not needed for this implementation). + * + * @param disallowIntercept True to disallow intercepting touch events. + */ + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + // Not needed + } +} \ No newline at end of file