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),
listManager,
false,
binding.ScrollView,
)
val initializedItems = notallyModel.items.init(true)
if (preferences.autoSortByCheckedEnabled) {
@ -243,6 +244,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
NotallyXPreferences.getInstance(application),
listManager,
true,
binding.ScrollView,
)
itemsChecked =
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 {
if (positionFrom == null) {
positionFrom = from
stateBefore = listManager.getState()
stateBefore = listManager.getState(selectedPos = from)
val item = listManager.getItem(from)
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 {
private const val TAG = "DragCallback"
private const val TAG = "ListItemDragCallback"
}
}

View file

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

View file

@ -2,6 +2,7 @@ package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem
@ -17,6 +18,7 @@ class CheckedListItemAdapter(
private val preferences: NotallyXPreferences,
private val listManager: ListManager,
private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : RecyclerView.Adapter<ListItemVH>(), HighlightText {
private lateinit var list: SortedList<ListItem>
@ -31,6 +33,7 @@ class CheckedListItemAdapter(
preferences,
listManager,
isCheckedListAdapter,
scrollView,
) {
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 androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
@ -18,6 +19,7 @@ class ListItemAdapter(
private val preferences: NotallyXPreferences,
private val listManager: ListManager,
private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : ListAdapter<ListItem, ListItemVH>(DIFF_CALLBACK), HighlightText {
private val itemAdapterBase =
@ -30,6 +32,7 @@ class ListItemAdapter(
preferences,
listManager,
isCheckedListAdapter,
scrollView,
) {
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.ViewGroup
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 com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.databinding.RecyclerListItemBinding
@ -28,10 +29,11 @@ abstract class ListItemAdapterBase(
private val preferences: NotallyXPreferences,
private val listManager: ListManager,
private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) {
private val callback = ListItemDragCallback(elevation, listManager)
private val touchHelper = ItemTouchHelper(callback)
private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView)
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
fun onAttachedToRecyclerView(recyclerView: RecyclerView) {

View file

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