1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

Editor | Better DND UX | Enter multiselect (#2261)

* Editor | Improvement | Select when no drag action performed
* Editor | Tech | Move dnd to delegate

Co-authored-by: Mikhail Iudin <mayudin@anytype.io>
This commit is contained in:
Mikhail 2022-05-16 18:52:34 +03:00 committed by GitHub
parent eefe048bd9
commit 8d79878c8d
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 646 additions and 574 deletions

View file

@ -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<Float, Int>? = 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

View file

@ -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<FragmentEditorBinding>(R.layout.fragment_editor),
OnFragmentInteractionListener,
@ -326,12 +308,9 @@ open class EditorFragment : NavigationFragment<FragmentEditorBinding>(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<FragmentEditorBinding>(R.layout.f
}
private val pickerDelegate = PickerDelegate.Impl(this as BaseFragment<ViewBinding>)
private val dndDelegate = DragAndDropDelegate()
@Inject
lateinit var factory: EditorViewModelFactory
@ -507,7 +487,7 @@ open class EditorFragment : NavigationFragment<FragmentEditorBinding>(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<FragmentEditorBinding>(R.layout.f
}
}
//region Drag-and-drop UI logic.
private var dndTargetPos = -1
private var dndTargetPrevious: Pair<Float, Int>? = 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
}

View file

@ -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())
}
}

View file

@ -3471,10 +3471,11 @@ class BlockAdapterTest {
onSlashEvent = {},
onKeyPressedEvent = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _, _ -> },
onDrop = { _, _ -> }
onDrop = { _, _ -> },
onDragStart = {}
),
onDragAndDropTrigger = { _, _ -> false },
dragAndDropSelector = DragAndDropAdapterDelegate(),

View file

@ -399,10 +399,11 @@ class HeaderBlockTest {
onSlashEvent = {},
onKeyPressedEvent = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _,_ -> },
onDrop = { _,_ -> }
onDragLocation = { _, _ -> },
onDrop = { _, _ -> },
onDragStart = {}
),
onDragAndDropTrigger = { _, _ -> false },
dragAndDropSelector = DragAndDropAdapterDelegate(),

View file

@ -128,10 +128,11 @@ class HighlightingBlockTest {
onSlashEvent = {},
onKeyPressedEvent = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _,_ -> },
onDrop = { _,_ -> }
onDragLocation = { _, _ -> },
onDrop = { _, _ -> },
onDragStart = {}
),
onDragAndDropTrigger = { _, _ -> false },
dragAndDropSelector = DragAndDropAdapterDelegate(),

View file

@ -398,10 +398,11 @@ class BlockAdapterCursorBindingTest {
onDescriptionChanged = {},
onTitleCheckboxClicked = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _,_ -> },
onDrop = { _,_ -> }
onDragLocation = { _, _ -> },
onDrop = { _, _ -> },
onDragStart = {}
),
dragAndDropSelector = DragAndDropAdapterDelegate(),
lifecycle = object : Lifecycle() {

View file

@ -58,10 +58,11 @@ open class BlockAdapterTestSetup {
onSlashEvent = {},
onKeyPressedEvent = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _,_ -> },
onDrop = { _,_ -> }
onDragLocation = { _, _ -> },
onDrop = { _, _ -> },
onDragStart = {}
),
onDragAndDropTrigger = { _, _ -> false },
dragAndDropSelector = DragAndDropAdapterDelegate(),

View file

@ -54,10 +54,11 @@ fun givenAdapter(
onSlashEvent = {},
onKeyPressedEvent = {},
onDragListener = EditorDragAndDropListener(
onDragEnded = {},
onDragEnded = { _, _ -> },
onDragExited = {},
onDragLocation = { _, _ -> },
onDrop = { _, _ -> }
onDrop = { _, _ -> },
onDragStart = {}
),
onDragAndDropTrigger = { _, _ -> false },
dragAndDropSelector = DragAndDropAdapterDelegate(),