mirror of
https://github.com/PhilKes/NotallyX.git
synced 2025-06-28 20:29:54 +00:00
Merge pull request #441 from PhilKes/feat/list-drag-scroll
Add auto-scroll when dragging ListItem to top/bottom
This commit is contained in:
commit
e48c7b5dec
8 changed files with 164 additions and 28 deletions
|
@ -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()
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,38 +72,40 @@ 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!!)
|
||||
// Focus item's EditText and set cursor position
|
||||
state.focusedItemPos?.let { itemPos ->
|
||||
recyclerView.postDelayed(
|
||||
{
|
||||
(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,
|
||||
inputMethodManager = inputManager,
|
||||
)
|
||||
}
|
||||
adapter.submitList(state.items) {
|
||||
// Focus item's EditText and set cursor position
|
||||
state.focusedItemPos?.let { itemPos ->
|
||||
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,
|
||||
inputMethodManager = inputManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
20,
|
||||
) // Delay is needed, otherwise focus is overwritten by submitList()
|
||||
}
|
||||
}
|
||||
}
|
||||
this.itemsChecked?.setItems(state.checkedItems!!)
|
||||
}
|
||||
|
||||
fun add(
|
||||
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue