Merge pull request #441 from PhilKes/feat/list-drag-scroll

Add auto-scroll when dragging ListItem to top/bottom
This commit is contained in:
Phil 2025-03-05 18:26:07 +01:00 committed by GitHub
commit e48c7b5dec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 164 additions and 28 deletions

View file

@ -0,0 +1,118 @@
package androidx.recyclerview.widget
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.core.widget.NestedScrollView
import kotlin.math.abs
class NestedScrollViewItemTouchHelper(
callback: Callback,
private val scrollView: NestedScrollView,
) : ItemTouchHelper(callback) {
private var selectedStartY: Int = -1
private var selectedStartScrollY: Float = -1f
private var selectedView: View? = null
private var dragScrollStartTimeInMs: Long = 0
private var lastmDy = 0f
private var lastScrollY = 0
private var tmpRect: Rect? = null
override fun select(selected: RecyclerView.ViewHolder?, actionState: Int) {
super.select(selected, actionState)
if (selected != null) {
selectedView = selected.itemView
selectedStartY = selected.itemView.top
selectedStartScrollY = scrollView!!.scrollY.toFloat()
}
}
/**
* Scrolls [scrollView] when an item in [mRecyclerView] is dragged to the top or bottom of the
* [scrollView].
*
* Inspired by
* [https://stackoverflow.com/a/70699988/9748566](https://stackoverflow.com/a/70699988/9748566)
*/
override fun scrollIfNecessary(): Boolean {
if (mSelected == null) {
dragScrollStartTimeInMs = Long.MIN_VALUE
return false
}
val now = System.currentTimeMillis()
val scrollDuration =
if (dragScrollStartTimeInMs == Long.MIN_VALUE) 0 else now - dragScrollStartTimeInMs
val lm = mRecyclerView.layoutManager
if (tmpRect == null) {
tmpRect = Rect()
}
var scrollY = 0
val currentScrollY = scrollView.scrollY
// We need to use the height of NestedScrollView, not RecyclerView's!
val actualShowingHeight =
scrollView.height - mRecyclerView.top - mRecyclerView.paddingBottom
lm!!.calculateItemDecorationsForChild(mSelected.itemView, tmpRect!!)
if (lm.canScrollVertically()) {
// Keep scrolling if the user didnt change the drag direction
if (lastScrollY != 0 && abs(lastmDy) >= abs(mDy)) {
scrollY = lastScrollY
} else {
// The true current Y of the item in NestedScrollView, not in RecyclerView!
val curY = (selectedStartY + mDy - currentScrollY).toInt()
// The true mDy should plus the initial scrollY and minus current scrollY of
// NestedScrollView
val checkDy = (mDy + selectedStartScrollY - currentScrollY).toInt()
val topDiff = curY - tmpRect!!.top - mRecyclerView.paddingTop
if (checkDy < 0 && topDiff < 0) { // User is draging the item out of the top edge.
scrollY = topDiff
} else if (checkDy > 0) { // User is draging the item out of the bottom edge.
val bottomDiff = (curY + mSelected.itemView.height - actualShowingHeight) + 10
if (bottomDiff >= 0) {
scrollY = bottomDiff
}
} else {
scrollY = 0
}
}
}
lastScrollY = scrollY
lastmDy = mDy
if (scrollY != 0) {
scrollY =
mCallback.interpolateOutOfBoundsScroll(
mRecyclerView,
mSelected.itemView.height,
scrollY,
actualShowingHeight,
scrollDuration,
)
}
if (scrollY != 0) {
val maxScrollY = scrollView.childrenHeightsSum - scrollView.height
// Check if we can scroll further before applying the scroll
if (
(scrollY < 0 && scrollView.scrollY > 0) ||
(scrollY > 0 && scrollView.scrollY < maxScrollY)
) {
if (dragScrollStartTimeInMs == Long.MIN_VALUE) {
dragScrollStartTimeInMs = now
}
scrollView.scrollBy(0, scrollY)
// Update the dragged item position as well
selectedView?.translationY = selectedView!!.translationY + scrollY
return true
}
}
dragScrollStartTimeInMs = Long.MIN_VALUE
lastScrollY = 0
lastmDy = 0f
return false
}
private val ViewGroup.childrenHeightsSum
get() = children.map { it.measuredHeight }.sum()
}

View file

@ -230,6 +230,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
NotallyXPreferences.getInstance(application), NotallyXPreferences.getInstance(application),
listManager, listManager,
false, false,
binding.ScrollView,
) )
val initializedItems = notallyModel.items.init(true) val initializedItems = notallyModel.items.init(true)
if (preferences.autoSortByCheckedEnabled) { if (preferences.autoSortByCheckedEnabled) {
@ -243,6 +244,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
NotallyXPreferences.getInstance(application), NotallyXPreferences.getInstance(application),
listManager, listManager,
true, true,
binding.ScrollView,
) )
itemsChecked = itemsChecked =
SortedItemsList(ListItemParentSortCallback(adapterChecked!!)).apply { SortedItemsList(ListItemParentSortCallback(adapterChecked!!)).apply {

View file

@ -44,7 +44,7 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
internal fun move(from: Int, to: Int): Boolean { internal fun move(from: Int, to: Int): Boolean {
if (positionFrom == null) { if (positionFrom == null) {
positionFrom = from positionFrom = from
stateBefore = listManager.getState() stateBefore = listManager.getState(selectedPos = from)
val item = listManager.getItem(from) val item = listManager.getItem(from)
parentBefore = if (item.isChild) listManager.findParent(item)?.second else null parentBefore = if (item.isChild) listManager.findParent(item)?.second else null
} }
@ -142,6 +142,6 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
} }
companion object { companion object {
private const val TAG = "DragCallback" private const val TAG = "ListItemDragCallback"
} }
} }

View file

@ -72,38 +72,40 @@ class ListManager(
this.itemsChecked?.let { Log.d(TAG, "itemsChecked:\n${it}") } this.itemsChecked?.let { Log.d(TAG, "itemsChecked:\n${it}") }
} }
internal fun getState(): ListState { internal fun getState(selectedPos: Int? = null): ListState {
val (pos, cursorPos) = recyclerView.getFocusedPositionAndCursor() val (pos, cursorPos) = recyclerView.getFocusedPositionAndCursor()
return ListState( return ListState(
items.cloneList(), items.cloneList(),
itemsChecked?.toMutableList()?.cloneList(), itemsChecked?.toMutableList()?.cloneList(),
pos, selectedPos ?: pos,
cursorPos, cursorPos,
) )
} }
internal fun setState(state: ListState) { internal fun setState(state: ListState) {
adapter.submitList(state.items) adapter.submitList(state.items) {
this.itemsChecked?.setItems(state.checkedItems!!) // Focus item's EditText and set cursor position
// Focus item's EditText and set cursor position state.focusedItemPos?.let { itemPos ->
state.focusedItemPos?.let { itemPos -> recyclerView.post {
recyclerView.postDelayed( if (itemPos in 0..items.size) {
{ recyclerView.smoothScrollToPosition(itemPos)
(recyclerView.findViewHolderForAdapterPosition(itemPos) as? ListItemVH?)?.let { (recyclerView.findViewHolderForAdapterPosition(itemPos) as? ListItemVH?)
viewHolder -> ?.let { viewHolder ->
inputMethodManager?.let { inputManager -> inputMethodManager?.let { inputManager ->
val maxCursorPos = viewHolder.binding.EditText.length() val maxCursorPos = viewHolder.binding.EditText.length()
viewHolder.focusEditText( viewHolder.focusEditText(
selectionStart = selectionStart =
state.cursorPos?.coerceIn(0, maxCursorPos) ?: maxCursorPos, state.cursorPos?.coerceIn(0, maxCursorPos)
inputMethodManager = inputManager, ?: maxCursorPos,
) inputMethodManager = inputManager,
} )
}
}
} }
}, }
20, }
) // Delay is needed, otherwise focus is overwritten by submitList()
} }
this.itemsChecked?.setItems(state.checkedItems!!)
} }
fun add( fun add(
@ -141,7 +143,7 @@ class ListManager(
adapter.notifyItemRangeInserted(insertPos, count) adapter.notifyItemRangeInserted(insertPos, count)
items.notifyPreviousFirstItem(insertPos, count) items.notifyPreviousFirstItem(insertPos, count)
if (pushChange) { if (pushChange) {
changeHistory.push(ListAddChange(stateBefore, getState(), this)) changeHistory.push(ListAddChange(stateBefore, getState(selectedPos = insertPos), this))
} }
recyclerView.post { recyclerView.post {
@ -205,7 +207,7 @@ class ListManager(
/** @return position of the moved item afterwards and the moved item count. */ /** @return position of the moved item afterwards and the moved item count. */
fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> { fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> {
val stateBefore = getState() val itemsCheckedBefore = itemsChecked?.toMutableList()?.cloneList()
val list = items.toMutableList() val list = items.toMutableList()
val movedItem = list[positionFrom] val movedItem = list[positionFrom]
// Do not allow to move parent into its own children // Do not allow to move parent into its own children
@ -229,7 +231,7 @@ class ListManager(
Pair(toOrder until fromOrder, itemCount) Pair(toOrder until fromOrder, itemCount)
} }
shiftItemOrders(orderRange, valueToAdd, items = list) shiftItemOrders(orderRange, valueToAdd, items = list)
stateBefore.checkedItems?.shiftItemOrders(orderRange, valueToAdd) itemsCheckedBefore?.shiftItemOrders(orderRange, valueToAdd)
list.removeFromParent(movedItem) list.removeFromParent(movedItem)
list.removeWithChildren(movedItem) list.removeWithChildren(movedItem)

View file

@ -2,6 +2,7 @@ package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
@ -17,6 +18,7 @@ class CheckedListItemAdapter(
private val preferences: NotallyXPreferences, private val preferences: NotallyXPreferences,
private val listManager: ListManager, private val listManager: ListManager,
private val isCheckedListAdapter: Boolean, private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : RecyclerView.Adapter<ListItemVH>(), HighlightText { ) : RecyclerView.Adapter<ListItemVH>(), HighlightText {
private lateinit var list: SortedList<ListItem> private lateinit var list: SortedList<ListItem>
@ -31,6 +33,7 @@ class CheckedListItemAdapter(
preferences, preferences,
listManager, listManager,
isCheckedListAdapter, isCheckedListAdapter,
scrollView,
) { ) {
override fun getItem(position: Int): ListItem = list[position] override fun getItem(position: Int): ListItem = list[position]
} }

View file

@ -2,6 +2,7 @@ package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -18,6 +19,7 @@ class ListItemAdapter(
private val preferences: NotallyXPreferences, private val preferences: NotallyXPreferences,
private val listManager: ListManager, private val listManager: ListManager,
private val isCheckedListAdapter: Boolean, private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : ListAdapter<ListItem, ListItemVH>(DIFF_CALLBACK), HighlightText { ) : ListAdapter<ListItem, ListItemVH>(DIFF_CALLBACK), HighlightText {
private val itemAdapterBase = private val itemAdapterBase =
@ -30,6 +32,7 @@ class ListItemAdapter(
preferences, preferences,
listManager, listManager,
isCheckedListAdapter, isCheckedListAdapter,
scrollView,
) { ) {
override fun getItem(position: Int): ListItem = this@ListItemAdapter.getItem(position) override fun getItem(position: Int): ListItem = this@ListItemAdapter.getItem(position)
} }

View file

@ -3,7 +3,8 @@ package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.recyclerview.widget.ItemTouchHelper import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.NestedScrollViewItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.databinding.RecyclerListItemBinding import com.philkes.notallyx.databinding.RecyclerListItemBinding
@ -28,10 +29,11 @@ abstract class ListItemAdapterBase(
private val preferences: NotallyXPreferences, private val preferences: NotallyXPreferences,
private val listManager: ListManager, private val listManager: ListManager,
private val isCheckedListAdapter: Boolean, private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) { ) {
private val callback = ListItemDragCallback(elevation, listManager) private val callback = ListItemDragCallback(elevation, listManager)
private val touchHelper = ItemTouchHelper(callback) private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView)
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>() private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
fun onAttachedToRecyclerView(recyclerView: RecyclerView) { fun onAttachedToRecyclerView(recyclerView: RecyclerView) {

View file

@ -88,6 +88,12 @@ open class ListManagerTestBase {
} }
.`when`(adapter) .`when`(adapter)
.submitList(any()) .submitList(any())
doAnswer { invocation ->
val listArgument = invocation.getArgument<MutableList<ListItem>>(0)
itemsInternal = listArgument
}
.`when`(adapter)
.submitList(any(), any())
listManager.init(adapter, itemsChecked, adapterChecked) listManager.init(adapter, itemsChecked, adapterChecked)
adapter.submitList(items) adapter.submitList(items)