diff --git a/app/src/main/java/com/anytypeio/anytype/ui/editor/DragAndDropDelegate.kt b/app/src/main/java/com/anytypeio/anytype/ui/editor/DragAndDropDelegate.kt new file mode 100644 index 0000000000..1cad424f4c --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/ui/editor/DragAndDropDelegate.kt @@ -0,0 +1,605 @@ +package com.anytypeio.anytype.ui.editor + +import android.content.ClipData +import android.content.ClipDescription +import android.graphics.Point +import android.view.DragEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewPropertyAnimator +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.anytypeio.anytype.R +import com.anytypeio.anytype.core_models.Position +import com.anytypeio.anytype.core_ui.features.editor.BlockAdapter +import com.anytypeio.anytype.core_ui.features.editor.BlockViewHolder +import com.anytypeio.anytype.core_ui.features.editor.DefaultEditorDragShadow +import com.anytypeio.anytype.core_ui.features.editor.DragAndDropConfig +import com.anytypeio.anytype.core_ui.features.editor.EditorDragAndDropListener +import com.anytypeio.anytype.core_ui.features.editor.SupportNesting +import com.anytypeio.anytype.core_ui.features.editor.TextInputDragShadow +import com.anytypeio.anytype.core_ui.features.editor.holders.media.Video +import com.anytypeio.anytype.core_ui.features.editor.holders.other.Code +import com.anytypeio.anytype.core_ui.features.editor.holders.other.Title +import com.anytypeio.anytype.core_ui.features.editor.holders.relations.FeaturedRelationListViewHolder +import com.anytypeio.anytype.core_ui.features.editor.holders.text.Text +import com.anytypeio.anytype.core_utils.ext.dimen +import com.anytypeio.anytype.core_utils.ext.invisible +import com.anytypeio.anytype.core_utils.ext.screen +import com.anytypeio.anytype.core_utils.ext.toast +import com.anytypeio.anytype.core_utils.ext.visible +import com.anytypeio.anytype.databinding.FragmentEditorBinding +import com.anytypeio.anytype.presentation.editor.Editor +import com.anytypeio.anytype.presentation.editor.EditorViewModel +import com.anytypeio.anytype.presentation.editor.editor.BlockDimensions +import com.anytypeio.anytype.presentation.editor.editor.listener.ListenerType +import com.anytypeio.anytype.presentation.editor.editor.model.BlockView +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber + +class DragAndDropDelegate { + + private lateinit var binding: FragmentEditorBinding + private lateinit var blockAdapter: BlockAdapter + private lateinit var vm: EditorViewModel + private lateinit var fragment: EditorFragment + + private val screen: Point by lazy { fragment.screen() } + private var dndTargetPos = NO_POSITION + private var dndTargetPrevious: Pair? = null + + private var dndTargetLineAnimator: ViewPropertyAnimator? = null + + private var scrollDownJob: Job? = null + private var scrollUpJob: Job? = null + + private var operationProcessed = false + + fun init( + blockAdapter: BlockAdapter, + vm: EditorViewModel, + editorFragment: EditorFragment + ) { + this.binding = editorFragment.binding + this.blockAdapter = blockAdapter + this.vm = vm + this.fragment = editorFragment + + binding.recycler.setOnDragListener(dndListener) + } + + val dndListener: EditorDragAndDropListener by lazy { + EditorDragAndDropListener( + onDragLocation = { target, ratio -> + handleDragging(target, ratio) + }, + onDrop = { target, event -> + binding.recycler.itemAnimator = DefaultItemAnimator() + proceedWithDropping(target, event) + binding.recycler.postDelayed({ + binding.recycler.itemAnimator = null + }, RECYCLER_DND_ANIMATION_RELAXATION_TIME) + }, + onDragExited = { + it.isSelected = false + }, + onDragEnded = { _, isMoved -> + binding.dndTargetLine.invisible() + blockAdapter.unSelectDraggedViewHolder() + blockAdapter.notifyItemChanged(dndTargetPos) + if (!operationProcessed && !isMoved && dndTargetPos != NO_POSITION) { + val block = blockAdapter.views[dndTargetPos] + if (block is BlockView.Selectable) + vm.onClickListener(ListenerType.LongClick(block.id)) + operationProcessed = true + } + stopScrollDownJob() + stopScrollUpJob() + }, + onDragStart = { + operationProcessed = false + } + ) + } + + private fun stopScrollDownJob() { + scrollDownJob?.cancel() + scrollDownJob = null + } + + private fun stopScrollUpJob() { + scrollUpJob?.cancel() + scrollUpJob = null + } + + fun handleDragAndDropTrigger( + vh: RecyclerView.ViewHolder, + event: MotionEvent? + ): Boolean { + if (vm.mode is Editor.Mode.Edit) { + if (vh is BlockViewHolder.DragAndDropHolder && binding.recycler.scrollState == RecyclerView.SCROLL_STATE_IDLE) { + dndTargetPos = vh.bindingAdapterPosition + if (vh is Video) { + vh.pause() + } + + val item = ClipData.Item(EditorFragment.EMPTY_TEXT) + + val dragData = ClipData( + EditorFragment.DRAG_AND_DROP_LABEL, + arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), + item + ) + + val shadow = when (vh) { + is Text -> TextInputDragShadow(vh.content.id, vh.itemView, event) + is Code -> TextInputDragShadow(vh.content.id, vh.itemView, event) + else -> DefaultEditorDragShadow(vh.itemView, event) + } + vh.itemView.startDragAndDrop( + dragData, + shadow, + null, + 0 + ) + blockAdapter.selectDraggedViewHolder(dndTargetPos) + blockAdapter.notifyItemChanged(dndTargetPos) + } + } else { + val pos = vh.bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + vm.onClickListener( + ListenerType.LongClick(vm.views[pos].id, BlockDimensions()) + ) + } + } + return true + } + + private fun handleDragging(target: View, ratio: Float) { + val vh = binding.recycler.findContainingViewHolder(target) + if (vh != null) { + if (vh.bindingAdapterPosition != dndTargetPos) { + if (vh is SupportNesting) { + when (ratio) { + in DragAndDropConfig.topRange -> { + target.isSelected = false + if (handleDragAbove(vh, ratio)) + return + } + in DragAndDropConfig.middleRange -> { + target.isSelected = true + handleDragInside(vh) + } + in DragAndDropConfig.bottomRange -> { + target.isSelected = false + if (handleDragBelow(vh, ratio)) + return + } + } + } else { + when (ratio) { + in DragAndDropConfig.topHalfRange -> { + if (vh is FeaturedRelationListViewHolder) { + binding.dndTargetLine.invisible() + } else if (vh is Title) { + binding.dndTargetLine.invisible() + } else { + if (handleDragAbove(vh, ratio)) + return + } + } + in DragAndDropConfig.bottomHalfRange -> { + if (handleDragBelow(vh, ratio)) + return + } + } + } + } + + handleScrollingWhileDragging(vh, ratio) + dndTargetPrevious = Pair(ratio, vh.bindingAdapterPosition) + } + } + + private fun handleScrollingWhileDragging( + vh: RecyclerView.ViewHolder, + ratio: Float + ) { + + val targetViewPosition = IntArray(2) + vh.itemView.getLocationOnScreen(targetViewPosition) + val targetViewY = targetViewPosition[1] + + val targetY = targetViewY + (vh.itemView.height * ratio) + + // Checking whether the touch is at the bottom of the screen. + + if (screen.y - targetY < 200) { + if (scrollDownJob == null) { + startScrollingDown() + } + } else { + stopScrollDownJob() + } + + // Checking whether the touch is at the top of the screen. + + if (targetY < 200) { + if (scrollUpJob == null) { + startScrollingUp() + } + } else { + stopScrollUpJob() + } + } + + private fun startScrollingDown() { + scrollDownJob = fragment.lifecycleScope.launch { + while (isActive) { + binding.recycler.smoothScrollBy(0, 350) + delay(60) + } + } + } + + private fun startScrollingUp() { + scrollUpJob = fragment.lifecycleScope.launch { + while (isActive) { + binding.recycler.smoothScrollBy(0, -350) + delay(60) + } + } + } + + private fun handleDragBelow( + vh: RecyclerView.ViewHolder, + ratio: Float + ): Boolean { + val currPos = vh.bindingAdapterPosition + val prev = dndTargetPrevious + if (prev != null) { + val (prevRatio, prevPosition) = prev + if (vh.bindingAdapterPosition.inc() == prevPosition && prevRatio in DragAndDropConfig.topRange) { + Timber.d("dnd skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") + val previousTarget = blockAdapter.views[prevPosition] + val currentTarget = blockAdapter.views[currPos] + if (previousTarget is BlockView.Indentable && currentTarget is BlockView.Indentable) { + if (previousTarget.indent == currentTarget.indent) + return true + } else { + return true + } + } else { + Timber.d("dnd not skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") + } + } else { + Timber.d("dnd prev was null") + } + + var indent = 0 + + val block = blockAdapter.views[vh.bindingAdapterPosition] + + if (block is BlockView.Indentable) { + indent = block.indent * fragment.dimen(R.dimen.indent) + } + + if (binding.dndTargetLine.isVisible) { + dndTargetLineAnimator?.cancel() + dndTargetLineAnimator = binding.dndTargetLine + .animate() + .setInterpolator(DecelerateInterpolator()) + .translationY(vh.itemView.bottom.toFloat()) + .translationX(indent.toFloat()) + .setDuration(75) + dndTargetLineAnimator?.start() + } else { + binding.dndTargetLine.translationY = vh.itemView.bottom.toFloat() + binding.dndTargetLine.translationX = indent.toFloat() + binding.dndTargetLine.visible() + } + + return false + } + + private fun handleDragInside(vh: RecyclerView.ViewHolder) { + dndTargetLineAnimator?.cancel() + binding.dndTargetLine.invisible() + } + + private fun handleDragAbove( + vh: RecyclerView.ViewHolder, + ratio: Float + ): Boolean { + val currPos = vh.bindingAdapterPosition + val prev = dndTargetPrevious + if (prev != null) { + val (prevRatio, prevPosition) = prev + if (currPos == prevPosition.inc() && prevRatio in DragAndDropConfig.bottomRange) { + Timber.d("dnd skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") + val previousTarget = blockAdapter.views[prevPosition] + val currentTarget = blockAdapter.views[currPos] + if (previousTarget is BlockView.Indentable && currentTarget is BlockView.Indentable) { + if (previousTarget.indent == currentTarget.indent) + return true + } else { + return true + } + } else { + Timber.d("dnd not skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") + } + } else { + Timber.d("dnd prev was null") + } + + var indent = 0 + + val block = blockAdapter.views[vh.bindingAdapterPosition] + + if (block is BlockView.Indentable) { + indent = block.indent * fragment.dimen(R.dimen.indent) + } + + if (binding.dndTargetLine.isVisible) { + dndTargetLineAnimator?.cancel() + dndTargetLineAnimator = binding.dndTargetLine + .animate() + .setInterpolator(DecelerateInterpolator()) + .translationY(vh.itemView.top.toFloat()) + .translationX(indent.toFloat()) + .setDuration(75) + dndTargetLineAnimator?.start() + } else { + binding.dndTargetLine.translationY = vh.itemView.top.toFloat() + binding.dndTargetLine.translationX = indent.toFloat() + binding.dndTargetLine.visible() + } + + return false + } + + private class DropContainer( + val vh: RecyclerView.ViewHolder?, + val ratio: Float + ) + + private fun checkIfDroppedBeforeFirstVisibleItem( + manager: LinearLayoutManager, + touchY: Float + ): DropContainer? { + manager.findFirstCompletelyVisibleItemPosition().let { first -> + if (first != RecyclerView.NO_POSITION) { + manager.findViewByPosition(first)?.let { view -> + val point = IntArray(2) + view.getLocationOnScreen(point) + if (touchY < point[1]) { + return DropContainer( + binding.recycler.findContainingViewHolder(view), + TOP_RATIO + ) + } + } + } + } + return null + } + + private fun checkIfDroppedAfterLastVisibleItem( + manager: LinearLayoutManager, + touchY: Float + ): DropContainer? { + manager.findLastCompletelyVisibleItemPosition().let { last -> + if (last != RecyclerView.NO_POSITION) { + manager.findViewByPosition(last)?.let { view -> + val point = IntArray(2) + view.getLocationOnScreen(point) + if (touchY > point[1]) { + return DropContainer( + binding.recycler.findContainingViewHolder(view), + BOTTOM_RATIO + ) + } + } + } + } + return null + } + + private fun calculateBottomClosestView( + manager: LinearLayoutManager, + start: Int, + end: Int, + touchY: Float + ): View? { + var closestBottomView: View? = null + var closestBottomViewDistance = Int.MAX_VALUE + + for (i in start..end) { + manager.findViewByPosition(i)?.let { view -> + val point = IntArray(2) + view.getLocationOnScreen(point) + val height = view.height + if (touchY <= point[1] + height) { + val newLastDiff = (point[1] - touchY).toInt() + if (newLastDiff < closestBottomViewDistance) { + closestBottomViewDistance = newLastDiff + closestBottomView = view + } + } + } + } + return closestBottomView + } + + private fun calculateTopClosestView( + manager: LinearLayoutManager, + start: Int, + end: Int, + touchY: Float + ): View? { + var closestTopView: View? = null + var closestTopViewDistance = Int.MAX_VALUE + for (i in start..end) { + manager.findViewByPosition(i)?.let { view -> + val point = IntArray(2) + view.getLocationOnScreen(point) + val height = view.height + if (touchY > point[1] + height) { + val newLastDiff = (touchY - point[1] - height).toInt() + if (newLastDiff < closestTopViewDistance) { + closestTopViewDistance = newLastDiff + closestTopView = view + } + } + } + } + return closestTopView + } + + private fun calculateDropContainer(touchY: Float): DropContainer { + val point = IntArray(2) + binding.recycler.getLocationOnScreen(point) + val touchY = point[1] + touchY + + val manager = (binding.recycler.layoutManager as LinearLayoutManager) + checkIfDroppedBeforeFirstVisibleItem(manager, touchY)?.let { + return it + } + checkIfDroppedAfterLastVisibleItem(manager, touchY)?.let { + return it + } + + val start = manager.findFirstCompletelyVisibleItemPosition() + val end = manager.findLastCompletelyVisibleItemPosition() + + val bottomClosestView = + calculateBottomClosestView(manager, start, end, touchY) ?: return DropContainer( + null, + 0f + ) + val topClosestView = calculateTopClosestView(manager, start, end, touchY) + ?: return DropContainer(null, 0f) + + return getClosestViewToLine(topClosestView, bottomClosestView) + } + + private fun getClosestViewToLine( + topView: View, + bottomView: View + ): DropContainer { + + val dndMiddle = kotlin.run { + val point = IntArray(2) + binding.dndTargetLine.getLocationOnScreen(point) + point[1] + binding.dndTargetLine.height / 2f + } + + val topViewDistance = kotlin.run { + val point = IntArray(2) + topView.getLocationOnScreen(point) + dndMiddle - point[1] - topView.height + } + + val bottomViewDistance = kotlin.run { + val point = IntArray(2) + bottomView.getLocationOnScreen(point) + point[1] - dndMiddle + } + + return if (topViewDistance > bottomViewDistance) { + DropContainer(binding.recycler.findContainingViewHolder(bottomView), TOP_RATIO) + } else { + DropContainer(binding.recycler.findContainingViewHolder(topView), BOTTOM_RATIO) + } + } + + private fun resolveDropContainer(target: View, event: DragEvent): DropContainer { + return if (target == binding.recycler) { + calculateDropContainer(event.y) + } else { + val vh = binding.recycler.findContainingViewHolder(target) + if (vh != null) { + DropContainer(vh, event.y / vh.itemView.height) + } else { + DropContainer(null, 0f) + } + } + } + + private fun proceedWithDropping(target: View, event: DragEvent) { + binding.dndTargetLine.invisible() + + val dropContainer = resolveDropContainer(target, event) + val vh = dropContainer.vh + val ratio = dropContainer.ratio + + if (vh != null) { + if (vh.bindingAdapterPosition != dndTargetPos) { + target.isSelected = false + if (vh is SupportNesting) { + when (ratio) { + in DragAndDropConfig.topRange -> { + vm.onDragAndDrop( + dragged = blockAdapter.views[dndTargetPos].id, + target = blockAdapter.views[vh.bindingAdapterPosition].id, + position = Position.TOP + ) + } + in DragAndDropConfig.middleRange -> { + vm.onDragAndDrop( + dragged = blockAdapter.views[dndTargetPos].id, + target = blockAdapter.views[vh.bindingAdapterPosition].id, + position = Position.INNER + ) + } + in DragAndDropConfig.bottomRange -> { + try { + vm.onDragAndDrop( + dragged = blockAdapter.views[dndTargetPos].id, + target = blockAdapter.views[vh.bindingAdapterPosition].id, + position = Position.BOTTOM + ) + } catch (e: Exception) { + fragment.toast("Failed to drop. Please, try again later.") + } + } + else -> fragment.toast("drop skipped, scenario 1") + } + } else { + when (ratio) { + in DragAndDropConfig.topHalfRange -> { + vm.onDragAndDrop( + dragged = blockAdapter.views[dndTargetPos].id, + target = blockAdapter.views[vh.bindingAdapterPosition].id, + position = Position.TOP + ) + } + in DragAndDropConfig.bottomHalfRange -> { + vm.onDragAndDrop( + dragged = blockAdapter.views[dndTargetPos].id, + target = blockAdapter.views[vh.bindingAdapterPosition].id, + position = Position.BOTTOM + ) + } + else -> fragment.toast("drop skipped, scenario 2") + } + } + } + } else { + fragment.toast("view holder not found") + } + } +} + +private const val NO_POSITION = -1 +private const val RECYCLER_DND_ANIMATION_RELAXATION_TIME = 500L +private const val TOP_RATIO = 0.1f +private const val BOTTOM_RATIO = 0.9f \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/editor/EditorFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/editor/EditorFragment.kt index e6e297037d..af120c1d62 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/editor/EditorFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/editor/EditorFragment.kt @@ -4,18 +4,15 @@ import android.animation.ObjectAnimator import android.app.Activity import android.content.ActivityNotFoundException import android.content.ClipData -import android.content.ClipDescription import android.content.Intent import android.graphics.Point import android.net.Uri import android.os.Build import android.os.Bundle -import android.view.DragEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.view.ViewPropertyAnimator import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator import android.view.animation.OvershootInterpolator @@ -50,7 +47,6 @@ import androidx.viewbinding.ViewBinding import com.anytypeio.anytype.BuildConfig import com.anytypeio.anytype.R import com.anytypeio.anytype.core_models.Id -import com.anytypeio.anytype.core_models.Position import com.anytypeio.anytype.core_models.SyncStatus import com.anytypeio.anytype.core_models.Url import com.anytypeio.anytype.core_models.ext.getFirstLinkOrObjectMarkupParam @@ -59,19 +55,8 @@ import com.anytypeio.anytype.core_ui.extensions.addTextFromSelectedStart import com.anytypeio.anytype.core_ui.extensions.color import com.anytypeio.anytype.core_ui.extensions.cursorYBottomCoordinate import com.anytypeio.anytype.core_ui.features.editor.BlockAdapter -import com.anytypeio.anytype.core_ui.features.editor.BlockViewHolder -import com.anytypeio.anytype.core_ui.features.editor.DefaultEditorDragShadow import com.anytypeio.anytype.core_ui.features.editor.DragAndDropAdapterDelegate -import com.anytypeio.anytype.core_ui.features.editor.DragAndDropConfig -import com.anytypeio.anytype.core_ui.features.editor.EditorDragAndDropListener -import com.anytypeio.anytype.core_ui.features.editor.SupportNesting -import com.anytypeio.anytype.core_ui.features.editor.TextInputDragShadow import com.anytypeio.anytype.core_ui.features.editor.TurnIntoActionReceiver -import com.anytypeio.anytype.core_ui.features.editor.holders.media.Video -import com.anytypeio.anytype.core_ui.features.editor.holders.other.Code -import com.anytypeio.anytype.core_ui.features.editor.holders.other.Title -import com.anytypeio.anytype.core_ui.features.editor.holders.relations.FeaturedRelationListViewHolder -import com.anytypeio.anytype.core_ui.features.editor.holders.text.Text import com.anytypeio.anytype.core_ui.features.editor.scrollandmove.DefaultScrollAndMoveTargetDescriptor import com.anytypeio.anytype.core_ui.features.editor.scrollandmove.ScrollAndMoveStateListener import com.anytypeio.anytype.core_ui.features.editor.scrollandmove.ScrollAndMoveTargetHighlighter @@ -120,7 +105,6 @@ import com.anytypeio.anytype.presentation.editor.editor.Markup import com.anytypeio.anytype.presentation.editor.editor.ThemeColor import com.anytypeio.anytype.presentation.editor.editor.ViewState import com.anytypeio.anytype.presentation.editor.editor.control.ControlPanelState -import com.anytypeio.anytype.presentation.editor.editor.listener.ListenerType import com.anytypeio.anytype.presentation.editor.editor.model.BlockView import com.anytypeio.anytype.presentation.editor.editor.model.UiBlock import com.anytypeio.anytype.presentation.editor.editor.sam.ScrollAndMoveTarget @@ -171,12 +155,10 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import kotlin.math.abs -import kotlin.ranges.contains open class EditorFragment : NavigationFragment(R.layout.fragment_editor), OnFragmentInteractionListener, @@ -326,12 +308,9 @@ open class EditorFragment : NavigationFragment(R.layout.f onBackPressedCallback = { vm.onBackPressedCallback() }, onKeyPressedEvent = vm::onKeyPressedEvent, onDragAndDropTrigger = { vh: RecyclerView.ViewHolder, event: MotionEvent? -> - handleDragAndDropTrigger( - vh, - event - ) + dndDelegate.handleDragAndDropTrigger(vh, event) }, - onDragListener = dndListener, + onDragListener = dndDelegate.dndListener, lifecycle = lifecycle, dragAndDropSelector = DragAndDropAdapterDelegate() ) @@ -420,6 +399,7 @@ open class EditorFragment : NavigationFragment(R.layout.f } private val pickerDelegate = PickerDelegate.Impl(this as BaseFragment) + private val dndDelegate = DragAndDropDelegate() @Inject lateinit var factory: EditorViewModelFactory @@ -507,7 +487,7 @@ open class EditorFragment : NavigationFragment(R.layout.f setupWindowInsetAnimation() - binding.recycler.setOnDragListener(dndListener) + dndDelegate.init(blockAdapter, vm, this) binding.recycler.addOnItemTouchListener( OutsideClickDetector(vm::onOutsideClicked) ) @@ -2017,530 +1997,6 @@ open class EditorFragment : NavigationFragment(R.layout.f } } - //region Drag-and-drop UI logic. - - private var dndTargetPos = -1 - private var dndTargetPrevious: Pair? = null - - var dndTargetLineAnimator: ViewPropertyAnimator? = null - - private var scrollDownJob: Job? = null - private var scrollUpJob: Job? = null - - private val dndListener: EditorDragAndDropListener by lazy { - EditorDragAndDropListener( - onDragLocation = { target, ratio -> - handleDragging(target, ratio) - }, - onDrop = { target, event -> - binding.recycler.itemAnimator = DefaultItemAnimator() - proceedWithDropping(target, event) - binding.recycler.postDelayed({ - binding.recycler.itemAnimator = null - }, RECYCLER_DND_ANIMATION_RELAXATION_TIME) - }, - onDragExited = { - it.isSelected = false - }, - onDragEnded = { - binding.dndTargetLine.invisible() - blockAdapter.unSelectDraggedViewHolder() - blockAdapter.notifyItemChanged(dndTargetPos) - stopScrollDownJob() - stopScrollUpJob() - - } - ) - } - - private fun handleDragAndDropTrigger( - vh: RecyclerView.ViewHolder, - event: MotionEvent? - ): Boolean { - if (vm.mode is Editor.Mode.Edit) { - if (vh is BlockViewHolder.DragAndDropHolder && binding.recycler.scrollState == RecyclerView.SCROLL_STATE_IDLE) { - dndTargetPos = vh.bindingAdapterPosition - if (vh is Video) { - vh.pause() - } - - val item = ClipData.Item(EMPTY_TEXT) - - val dragData = ClipData( - DRAG_AND_DROP_LABEL, - arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), - item - ) - - val shadow = when (vh) { - is Text -> TextInputDragShadow(vh.content.id, vh.itemView, event) - is Code -> TextInputDragShadow(vh.content.id, vh.itemView, event) - else -> DefaultEditorDragShadow(vh.itemView, event) - } - vh.itemView.startDragAndDrop( - dragData, - shadow, - null, - 0 - ) - blockAdapter.selectDraggedViewHolder(dndTargetPos) - blockAdapter.notifyItemChanged(dndTargetPos) - } - } else { - val pos = vh.bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) { - vm.onClickListener( - ListenerType.LongClick(vm.views[pos].id, BlockDimensions()) - ) - } - } - return true - } - - private fun handleDragging(target: View, ratio: Float) { - val vh = binding.recycler.findContainingViewHolder(target) - if (vh != null) { - if (vh.bindingAdapterPosition != dndTargetPos) { - if (vh is SupportNesting) { - when (ratio) { - in DragAndDropConfig.topRange -> { - target.isSelected = false - if (handleDragAbove(vh, ratio)) - return - } - in DragAndDropConfig.middleRange -> { - target.isSelected = true - handleDragInside(vh) - } - in DragAndDropConfig.bottomRange -> { - target.isSelected = false - if (handleDragBelow(vh, ratio)) - return - } - } - } else { - when (ratio) { - in DragAndDropConfig.topHalfRange -> { - if (vh is FeaturedRelationListViewHolder) { - binding.dndTargetLine.invisible() - } else if (vh is Title) { - binding.dndTargetLine.invisible() - } else { - if (handleDragAbove(vh, ratio)) - return - } - } - in DragAndDropConfig.bottomHalfRange -> { - if (handleDragBelow(vh, ratio)) - return - } - } - } - } - - handleScrollingWhileDragging(vh, ratio) - dndTargetPrevious = Pair(ratio, vh.bindingAdapterPosition) - } - } - - private fun handleScrollingWhileDragging( - vh: RecyclerView.ViewHolder, - ratio: Float - ) { - - val targetViewPosition = IntArray(2) - vh.itemView.getLocationOnScreen(targetViewPosition) - val targetViewY = targetViewPosition[1] - - val targetY = targetViewY + (vh.itemView.height * ratio) - - // Checking whether the touch is at the bottom of the screen. - - if (screen.y - targetY < 200) { - if (scrollDownJob == null) { - startScrollingDown() - } - } else { - stopScrollDownJob() - } - - // Checking whether the touch is at the top of the screen. - - if (targetY < 200) { - if (scrollUpJob == null) { - startScrollingUp() - } - } else { - stopScrollUpJob() - } - } - - private fun startScrollingDown() { - scrollDownJob = lifecycleScope.launch { - while (isActive) { - binding.recycler.smoothScrollBy(0, 350) - delay(60) - } - } - } - - private fun startScrollingUp() { - scrollUpJob = lifecycleScope.launch { - while (isActive) { - binding.recycler.smoothScrollBy(0, -350) - delay(60) - } - } - } - - private fun handleDragBelow( - vh: RecyclerView.ViewHolder, - ratio: Float - ): Boolean { - val currPos = vh.bindingAdapterPosition - val prev = dndTargetPrevious - if (prev != null) { - val (prevRatio, prevPosition) = prev - if (vh.bindingAdapterPosition.inc() == prevPosition && prevRatio in DragAndDropConfig.topRange) { - Timber.d("dnd skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") - val previousTarget = blockAdapter.views[prevPosition] - val currentTarget = blockAdapter.views[currPos] - if (previousTarget is BlockView.Indentable && currentTarget is BlockView.Indentable) { - if (previousTarget.indent == currentTarget.indent) - return true - } else { - return true - } - } else { - Timber.d("dnd not skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") - } - } else { - Timber.d("dnd prev was null") - } - - var indent = 0 - - val block = blockAdapter.views[vh.bindingAdapterPosition] - - if (block is BlockView.Indentable) { - indent = block.indent * dimen(R.dimen.indent) - } - - if (binding.dndTargetLine.isVisible) { - dndTargetLineAnimator?.cancel() - dndTargetLineAnimator = binding.dndTargetLine - .animate() - .setInterpolator(DecelerateInterpolator()) - .translationY(vh.itemView.bottom.toFloat()) - .translationX(indent.toFloat()) - .setDuration(75) - dndTargetLineAnimator?.start() - } else { - binding.dndTargetLine.translationY = vh.itemView.bottom.toFloat() - binding.dndTargetLine.translationX = indent.toFloat() - binding.dndTargetLine.visible() - } - - return false - } - - private fun handleDragInside(vh: RecyclerView.ViewHolder) { - dndTargetLineAnimator?.cancel() - binding.dndTargetLine.invisible() - } - - private fun handleDragAbove( - vh: RecyclerView.ViewHolder, - ratio: Float - ): Boolean { - val currPos = vh.bindingAdapterPosition - val prev = dndTargetPrevious - if (prev != null) { - val (prevRatio, prevPosition) = prev - if (currPos == prevPosition.inc() && prevRatio in DragAndDropConfig.bottomRange) { - Timber.d("dnd skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") - val previousTarget = blockAdapter.views[prevPosition] - val currentTarget = blockAdapter.views[currPos] - if (previousTarget is BlockView.Indentable && currentTarget is BlockView.Indentable) { - if (previousTarget.indent == currentTarget.indent) - return true - } else { - return true - } - } else { - Timber.d("dnd not skipped: prev - $prev, curr: pos ${vh.bindingAdapterPosition}, $ratio") - } - } else { - Timber.d("dnd prev was null") - } - - var indent = 0 - - val block = blockAdapter.views[vh.bindingAdapterPosition] - - if (block is BlockView.Indentable) { - indent = block.indent * dimen(R.dimen.indent) - } - - if (binding.dndTargetLine.isVisible) { - dndTargetLineAnimator?.cancel() - dndTargetLineAnimator = binding.dndTargetLine - .animate() - .setInterpolator(DecelerateInterpolator()) - .translationY(vh.itemView.top.toFloat()) - .translationX(indent.toFloat()) - .setDuration(75) - dndTargetLineAnimator?.start() - } else { - binding.dndTargetLine.translationY = vh.itemView.top.toFloat() - binding.dndTargetLine.translationX = indent.toFloat() - binding.dndTargetLine.visible() - } - - return false - } - - private class DropContainer( - val vh: RecyclerView.ViewHolder?, - val ratio: Float - ) - - private fun checkIfDroppedBeforeFirstVisibleItem( - manager: LinearLayoutManager, - touchY: Float - ): DropContainer? { - manager.findFirstCompletelyVisibleItemPosition().let { first -> - if (first != RecyclerView.NO_POSITION) { - manager.findViewByPosition(first)?.let { view -> - val point = IntArray(2) - view.getLocationOnScreen(point) - if (touchY < point[1]) { - return DropContainer( - binding.recycler.findContainingViewHolder(view), - TOP_RATIO - ) - } - } - } - } - return null - } - - private fun checkIfDroppedAfterLastVisibleItem( - manager: LinearLayoutManager, - touchY: Float - ): DropContainer? { - manager.findLastCompletelyVisibleItemPosition().let { last -> - if (last != RecyclerView.NO_POSITION) { - manager.findViewByPosition(last)?.let { view -> - val point = IntArray(2) - view.getLocationOnScreen(point) - if (touchY > point[1]) { - return DropContainer( - binding.recycler.findContainingViewHolder(view), - BOTTOM_RATIO - ) - } - } - } - } - return null - } - - private fun calculateBottomClosestView( - manager: LinearLayoutManager, - start: Int, - end: Int, - touchY: Float - ): View? { - var closestBottomView: View? = null - var closestBottomViewDistance = Int.MAX_VALUE - - for (i in start..end) { - manager.findViewByPosition(i)?.let { view -> - val point = IntArray(2) - view.getLocationOnScreen(point) - val height = view.height - if (touchY <= point[1] + height) { - val newLastDiff = (point[1] - touchY).toInt() - if (newLastDiff < closestBottomViewDistance) { - closestBottomViewDistance = newLastDiff - closestBottomView = view - } - } - } - } - return closestBottomView - } - - private fun calculateTopClosestView( - manager: LinearLayoutManager, - start: Int, - end: Int, - touchY: Float - ): View? { - var closestTopView: View? = null - var closestTopViewDistance = Int.MAX_VALUE - for (i in start..end) { - manager.findViewByPosition(i)?.let { view -> - val point = IntArray(2) - view.getLocationOnScreen(point) - val height = view.height - if (touchY > point[1] + height) { - val newLastDiff = (touchY - point[1] - height).toInt() - if (newLastDiff < closestTopViewDistance) { - closestTopViewDistance = newLastDiff - closestTopView = view - } - } - } - } - return closestTopView - } - - private fun calculateDropContainer(touchY: Float): DropContainer { - val point = IntArray(2) - binding.recycler.getLocationOnScreen(point) - val touchY = point[1] + touchY - - val manager = (binding.recycler.layoutManager as LinearLayoutManager) - checkIfDroppedBeforeFirstVisibleItem(manager, touchY)?.let { - return it - } - checkIfDroppedAfterLastVisibleItem(manager, touchY)?.let { - return it - } - - val start = manager.findFirstCompletelyVisibleItemPosition() - val end = manager.findLastCompletelyVisibleItemPosition() - - val bottomClosestView = - calculateBottomClosestView(manager, start, end, touchY) ?: return DropContainer( - null, - 0f - ) - val topClosestView = calculateTopClosestView(manager, start, end, touchY) - ?: return DropContainer(null, 0f) - - return getClosestViewToLine(topClosestView, bottomClosestView) - } - - private fun getClosestViewToLine( - topView: View, - bottomView: View - ): DropContainer { - - val dndMiddle = kotlin.run { - val point = IntArray(2) - binding.dndTargetLine.getLocationOnScreen(point) - point[1] + binding.dndTargetLine.height / 2f - } - - val topViewDistance = kotlin.run { - val point = IntArray(2) - topView.getLocationOnScreen(point) - dndMiddle - point[1] - topView.height - } - - val bottomViewDistance = kotlin.run { - val point = IntArray(2) - bottomView.getLocationOnScreen(point) - point[1] - dndMiddle - } - - return if (topViewDistance > bottomViewDistance) { - DropContainer(binding.recycler.findContainingViewHolder(bottomView), TOP_RATIO) - } else { - DropContainer(binding.recycler.findContainingViewHolder(topView), BOTTOM_RATIO) - } - } - - private fun resolveDropContainer(target: View, event: DragEvent): DropContainer { - return if (target == binding.recycler) { - calculateDropContainer(event.y) - } else { - val vh = binding.recycler.findContainingViewHolder(target) - if (vh != null) { - DropContainer(vh, event.y / vh.itemView.height) - } else { - DropContainer(null, 0f) - } - } - } - - private fun proceedWithDropping(target: View, event: DragEvent) { - binding.dndTargetLine.invisible() - - val dropContainer = resolveDropContainer(target, event) - val vh = dropContainer.vh - val ratio = dropContainer.ratio - - if (vh != null) { - if (vh.bindingAdapterPosition != dndTargetPos) { - target.isSelected = false - if (vh is SupportNesting) { - when (ratio) { - in DragAndDropConfig.topRange -> { - vm.onDragAndDrop( - dragged = blockAdapter.views[dndTargetPos].id, - target = blockAdapter.views[vh.bindingAdapterPosition].id, - position = Position.TOP - ) - } - in DragAndDropConfig.middleRange -> { - vm.onDragAndDrop( - dragged = blockAdapter.views[dndTargetPos].id, - target = blockAdapter.views[vh.bindingAdapterPosition].id, - position = Position.INNER - ) - } - in DragAndDropConfig.bottomRange -> { - try { - vm.onDragAndDrop( - dragged = blockAdapter.views[dndTargetPos].id, - target = blockAdapter.views[vh.bindingAdapterPosition].id, - position = Position.BOTTOM - ) - } catch (e: Exception) { - toast("Failed to drop. Please, try again later.") - } - } - else -> toast("drop skipped, scenario 1") - } - } else { - when (ratio) { - in DragAndDropConfig.topHalfRange -> { - vm.onDragAndDrop( - dragged = blockAdapter.views[dndTargetPos].id, - target = blockAdapter.views[vh.bindingAdapterPosition].id, - position = Position.TOP - ) - } - in DragAndDropConfig.bottomHalfRange -> { - vm.onDragAndDrop( - dragged = blockAdapter.views[dndTargetPos].id, - target = blockAdapter.views[vh.bindingAdapterPosition].id, - position = Position.BOTTOM - ) - } - else -> toast("drop skipped, scenario 2") - } - } - } - } else { - toast("view holder not found") - } - } - - private fun stopScrollDownJob() { - scrollDownJob?.cancel() - scrollDownJob = null - } - - private fun stopScrollUpJob() { - scrollUpJob?.cancel() - scrollUpJob = null - } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (resultCode == Activity.RESULT_OK) { @@ -2592,8 +2048,4 @@ interface OnFragmentInteractionListener { fun onSetObjectLink(id: Id) fun onSetWebLink(uri: String) fun onCreateObject(name: String) -} - -private const val RECYCLER_DND_ANIMATION_RELAXATION_TIME = 500L -private const val TOP_RATIO = 0.1f -private const val BOTTOM_RATIO = 0.9f \ No newline at end of file +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/EditorDragAndDropListener.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/EditorDragAndDropListener.kt index d020cb05b2..c48467bf6f 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/EditorDragAndDropListener.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/EditorDragAndDropListener.kt @@ -18,16 +18,20 @@ import androidx.annotation.ColorRes import androidx.annotation.DimenRes import androidx.core.content.ContextCompat import com.anytypeio.anytype.core_ui.R +import kotlin.math.abs import kotlin.math.min class EditorDragAndDropListener( val onDragLocation: (v: View, ratio: Float) -> Unit, val onDrop: (v: View, event: DragEvent) -> Unit, - val onDragEnded: (v: View) -> Unit, - val onDragExited: (v: View) -> Unit + val onDragEnded: (v: View, isMoved: Boolean) -> Unit, + val onDragExited: (v: View) -> Unit, + val onDragStart: () -> Unit ) : View.OnDragListener { + private var isMoved = false + override fun onDrag(v: View, event: DragEvent): Boolean { when (event.action) { DragEvent.ACTION_DRAG_ENTERED -> { @@ -35,6 +39,7 @@ class EditorDragAndDropListener( onDragLocation(v, ratio) } DragEvent.ACTION_DRAG_LOCATION -> { + isMoved = true val ratio = event.y / v.height onDragLocation(v, ratio) } @@ -45,7 +50,11 @@ class EditorDragAndDropListener( onDrop(v, event) } DragEvent.ACTION_DRAG_ENDED -> { - onDragEnded(v) + onDragEnded(v, isMoved) + } + DragEvent.ACTION_DRAG_STARTED -> { + onDragStart() + isMoved = false } } return true @@ -123,9 +132,9 @@ open class DragSmoothShadowBuilder( val startX = point[0] val startY = point[1] val shadowTouchX = - if (touchX == 0f) outShadowSize.x / 2f else (touchX - startX) * scale + viewWidthPaddingValue + if (touchX == 0f) outShadowSize.x / 2f else abs((touchX - startX) * scale + viewWidthPaddingValue) val shadowTouchY = - if (touchX == 0f) outShadowSize.y / 2f else (touchY - startY) * scale + viewHeightPaddingValue + if (touchX == 0f) outShadowSize.y / 2f else abs((touchY - startY) * scale + viewHeightPaddingValue) outShadowTouchPoint.set(shadowTouchX.toInt(), shadowTouchY.toInt()) } } diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt index 16cfdacd6a..8aef364bae 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt @@ -3471,10 +3471,11 @@ class BlockAdapterTest { onSlashEvent = {}, onKeyPressedEvent = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, onDragLocation = { _, _ -> }, - onDrop = { _, _ -> } + onDrop = { _, _ -> }, + onDragStart = {} ), onDragAndDropTrigger = { _, _ -> false }, dragAndDropSelector = DragAndDropAdapterDelegate(), diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt index 2f6b0517fd..c234aab4ad 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt @@ -399,10 +399,11 @@ class HeaderBlockTest { onSlashEvent = {}, onKeyPressedEvent = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, - onDragLocation = { _,_ -> }, - onDrop = { _,_ -> } + onDragLocation = { _, _ -> }, + onDrop = { _, _ -> }, + onDragStart = {} ), onDragAndDropTrigger = { _, _ -> false }, dragAndDropSelector = DragAndDropAdapterDelegate(), diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt index 77b1771796..6179e772bb 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt @@ -128,10 +128,11 @@ class HighlightingBlockTest { onSlashEvent = {}, onKeyPressedEvent = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, - onDragLocation = { _,_ -> }, - onDrop = { _,_ -> } + onDragLocation = { _, _ -> }, + onDrop = { _, _ -> }, + onDragStart = {} ), onDragAndDropTrigger = { _, _ -> false }, dragAndDropSelector = DragAndDropAdapterDelegate(), diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt index dbdd7bc15f..0ebcc37ba9 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt @@ -398,10 +398,11 @@ class BlockAdapterCursorBindingTest { onDescriptionChanged = {}, onTitleCheckboxClicked = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, - onDragLocation = { _,_ -> }, - onDrop = { _,_ -> } + onDragLocation = { _, _ -> }, + onDrop = { _, _ -> }, + onDragStart = {} ), dragAndDropSelector = DragAndDropAdapterDelegate(), lifecycle = object : Lifecycle() { diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt index ec313767f2..6d9e636f06 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt @@ -58,10 +58,11 @@ open class BlockAdapterTestSetup { onSlashEvent = {}, onKeyPressedEvent = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, - onDragLocation = { _,_ -> }, - onDrop = { _,_ -> } + onDragLocation = { _, _ -> }, + onDrop = { _, _ -> }, + onDragStart = {} ), onDragAndDropTrigger = { _, _ -> false }, dragAndDropSelector = DragAndDropAdapterDelegate(), diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/uitests/BlockAdapterShared.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/uitests/BlockAdapterShared.kt index 2eddf6a930..61ece1d923 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/uitests/BlockAdapterShared.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/uitests/BlockAdapterShared.kt @@ -54,10 +54,11 @@ fun givenAdapter( onSlashEvent = {}, onKeyPressedEvent = {}, onDragListener = EditorDragAndDropListener( - onDragEnded = {}, + onDragEnded = { _, _ -> }, onDragExited = {}, onDragLocation = { _, _ -> }, - onDrop = { _, _ -> } + onDrop = { _, _ -> }, + onDragStart = {} ), onDragAndDropTrigger = { _, _ -> false }, dragAndDropSelector = DragAndDropAdapterDelegate(),