From 8d0fd0727a77bddb57072dec72e1ecef5f14cfa1 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 14 Jul 2026 15:40:58 +0200 Subject: [PATCH 1/2] feat: drag and drop elements on android --- .../swmansion/enriched/EnrichedDragHandler.kt | 190 ++++++++++++++++++ .../enriched/EnrichedTextInputView.kt | 15 +- .../com/swmansion/enriched/utils/Numbers.kt | 4 +- apps/example/ios/Podfile.lock | 4 +- 4 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt b/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt new file mode 100644 index 000000000..ca04eea09 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt @@ -0,0 +1,190 @@ +package com.swmansion.enriched + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.text.SpannableStringBuilder +import android.util.TypedValue +import android.view.DragEvent +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.swmansion.enriched.utils.dp + +class EnrichedDragHandler( + private val view: EnrichedTextInputView, + private val clipboardManager: EnrichedClipboardManager, +) { + private data class EnrichedDragLocalState( + val sourceView: EnrichedTextInputView, + val start: Int, + val end: Int, + ) + + private var lastTouchDownX = 0f + private var lastTouchDownY = 0f + private var blockNextActionUpAfterDrag = false + + fun onDragEvent(event: DragEvent): Boolean? { + if (event.action != DragEvent.ACTION_DROP) { + return null + } + + return handleDrop(event) + } + + fun performLongClick(): Boolean { + if (!isLastTouchWithinSelection()) { + return false + } + + if (!startEnrichedSelectionDrag()) { + return false + } + + blockNextActionUpAfterDrag = true + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + return true + } + + fun onTouchEvent(event: MotionEvent): Boolean { + if ( + blockNextActionUpAfterDrag && + ( + event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) + ) { + blockNextActionUpAfterDrag = false + return true + } + + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + lastTouchDownX = event.x + lastTouchDownY = event.y + } + + return false + } + + private fun handleDrop(event: DragEvent): Boolean? { + val localState = event.localState + val enrichedLocalState = localState as? EnrichedDragLocalState ?: return null + + val clip = event.clipData ?: return null + val dropOffset = view.getOffsetForPosition(event.x, event.y) + val sameViewLocalState = enrichedLocalState.takeIf { it.sourceView == view } + if ( + sameViewLocalState != null && + dropOffset >= sameViewLocalState.start && + dropOffset < sameViewLocalState.end + ) { + return true + } + + val lengthBeforeDrop = view.text?.length ?: 0 + val didInsert = clipboardManager.insertClipData(clip, dropOffset, dropOffset) + if (!didInsert) { + return null + } + + if (sameViewLocalState != null) { + deleteSourceAfterLocalDrop(sameViewLocalState, dropOffset, lengthBeforeDrop) + } + + return true + } + + private fun isLastTouchWithinSelection(): Boolean { + val start = minOf(view.selectionStart, view.selectionEnd) + val end = maxOf(view.selectionStart, view.selectionEnd) + if (start >= end) return false + + val touchOffset = view.getOffsetForPosition(lastTouchDownX, lastTouchDownY) + return touchOffset >= start && touchOffset < end + } + + private fun startEnrichedSelectionDrag(): Boolean { + val start = minOf(view.selectionStart, view.selectionEnd) + val end = maxOf(view.selectionStart, view.selectionEnd) + if (start >= end) return false + + val clip = clipboardManager.createSelectedTextClipData(start, end) ?: return false + val started = + view.startDragAndDrop( + clip, + createTextDragShadow(start, end), + EnrichedDragLocalState(view, start, end), + View.DRAG_FLAG_GLOBAL, + ) + + if (started) { + view.setSelection(end) + } + + return started + } + + private fun createTextDragBackgroundDrawable(): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 10.dp.toFloat() + setColor(Color.LTGRAY) + } + + private fun createTextDragShadow( + start: Int, + end: Int, + ): View.DragShadowBuilder { + val shadowView = TextView(view.context) + + val padding = 10.dp + + shadowView.apply { + text = view.editableText?.subSequence(start, end) ?: "" + setTextColor(view.textColors) + setTextSize(TypedValue.COMPLEX_UNIT_PX, view.textSize) + gravity = Gravity.CENTER + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + maxWidth = view.width + background = createTextDragBackgroundDrawable() + setPadding(padding, padding / 2, padding, padding / 2) + } + + val size = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + shadowView.measure(size, size) + shadowView.layout(0, 0, shadowView.measuredWidth, shadowView.measuredHeight) + + return View.DragShadowBuilder(shadowView) + } + + private fun deleteSourceAfterLocalDrop( + localState: EnrichedDragLocalState, + dropOffset: Int, + lengthBeforeDrop: Int, + ) { + val editable = view.text as? SpannableStringBuilder ?: return + var dragSourceStart = localState.start + var dragSourceEnd = localState.end + + if (dropOffset <= dragSourceStart) { + val shift = editable.length - lengthBeforeDrop + dragSourceStart += shift + dragSourceEnd += shift + } + + val safeStart = dragSourceStart.coerceIn(0, editable.length) + val safeEnd = dragSourceEnd.coerceIn(safeStart, editable.length) + if (safeStart >= safeEnd) return + + view.transactionManager.runSilently { + editable.replace(safeStart, safeEnd, "") + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt index 6a2d35750..e4d3555ec 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt @@ -16,6 +16,7 @@ import android.util.AttributeSet import android.util.Log import android.util.TypedValue import android.view.ActionMode +import android.view.DragEvent import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -155,6 +156,14 @@ class EnrichedTextInputView : AppCompatEditText { CheckListClickHandler(this) } + private val dragHandler by lazy { + EnrichedDragHandler(this, clipboardManager) + } + + override fun onDragEvent(event: DragEvent): Boolean = dragHandler.onDragEvent(event) ?: super.onDragEvent(event) + + override fun performLongClick(): Boolean = dragHandler.performLongClick() || super.performLongClick() + var spanWatcher: EnrichedSpanWatcher? = null constructor(context: Context) : super(context) { @@ -272,7 +281,9 @@ class EnrichedTextInputView : AppCompatEditText { // https://github.com/facebook/react-native/blob/36df97f500aa0aa8031098caf7526db358b6ddc1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L295C1-L296C1 override fun onTouchEvent(event: MotionEvent): Boolean { if (checkboxClickHandler.handleTouch(event)) return true - when (event.action) { + + if (dragHandler.onTouchEvent(event)) return true + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { detectScrollMovement = true // Disallow parent views to intercept touch events, until we can detect if we should be @@ -380,7 +391,7 @@ class EnrichedTextInputView : AppCompatEditText { end: Int? = null, ) { transactionManager.runWithIgnoredSpanWatcher { - val currentText = (text as? SpannableStringBuilder) ?: return@runWithIgnoredSpanWatcher + val currentText = editableText val length = currentText.length val insertionStart = selection.start diff --git a/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt b/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt index 2f8f905d1..394e8cb2a 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt @@ -9,5 +9,5 @@ object Dimens { val Float.dp: Float get() = this * Dimens.density -val Int.dp: Float - get() = this * Dimens.density +val Int.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 1ad9bd3c0..bc3cf244d 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2621,7 +2621,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeEnriched (0.0.76): + - ReactNativeEnriched (0.0.77): - boost - DoubleConversion - fast_float @@ -3240,7 +3240,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 23e2bca1661f8781e55fcc05a151fc1df97bc1fb ReactCodegen: 10a61330b137caaad6f7fbe7f5d0e7a40d621700 ReactCommon: c6cd81778336e767e27fe63c6707dd6b735fff5c - ReactNativeEnriched: 42dbc84d8a782db27e0645a354e51bdfd80cf68d + ReactNativeEnriched: a3ec2fb837120152fda350c02a1a530c9c93741f RNReanimated: 361d5b8e20a77cd1f7907ad301f6d51ef79c1545 RNScreens: 7179cc1ba31b4e18ed29f10abf20c24a7961cf4c RNWorklets: 6c4441bf4c01fd1598099789264481d199d18968 From e1960bc872ec1c9536b0d3d8072b0d77eaa1ec12 Mon Sep 17 00:00:00 2001 From: IvanIhnatsiuk Date: Tue, 14 Jul 2026 16:06:47 +0200 Subject: [PATCH 2/2] fix: copilot comments --- .../main/java/com/swmansion/enriched/EnrichedDragHandler.kt | 3 +-- android/src/main/java/com/swmansion/enriched/utils/Numbers.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt b/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt index ca04eea09..98876123f 100644 --- a/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt +++ b/android/src/main/java/com/swmansion/enriched/EnrichedDragHandler.kt @@ -2,7 +2,6 @@ package com.swmansion.enriched import android.graphics.Color import android.graphics.drawable.GradientDrawable -import android.text.SpannableStringBuilder import android.util.TypedValue import android.view.DragEvent import android.view.Gravity @@ -169,7 +168,7 @@ class EnrichedDragHandler( dropOffset: Int, lengthBeforeDrop: Int, ) { - val editable = view.text as? SpannableStringBuilder ?: return + val editable = view.editableText var dragSourceStart = localState.start var dragSourceEnd = localState.end diff --git a/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt b/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt index 394e8cb2a..7c718fd3d 100644 --- a/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt +++ b/android/src/main/java/com/swmansion/enriched/utils/Numbers.kt @@ -10,4 +10,4 @@ val Float.dp: Float get() = this * Dimens.density val Int.dp: Int - get() = (this * Resources.getSystem().displayMetrics.density).toInt() + get() = (this * Dimens.density).toInt()