diff --git a/app/src/main/java/androidx/recyclerview/widget/NestedScrollViewItemTouchHelper.kt b/app/src/main/java/androidx/recyclerview/widget/NestedScrollViewItemTouchHelper.kt new file mode 100644 index 00000000..bd87e90d --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/widget/NestedScrollViewItemTouchHelper.kt @@ -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() +} diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditListActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditListActivity.kt index efa77131..011f26ba 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditListActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditListActivity.kt @@ -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 { diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListItemDragCallback.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListItemDragCallback.kt index 66aa2695..b2a0ae25 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListItemDragCallback.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListItemDragCallback.kt @@ -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" } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt index 48ce6f9a..a0ae40be 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt @@ -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 { - 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) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/CheckedListItemAdapter.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/CheckedListItemAdapter.kt index 885448f7..393400e6 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/CheckedListItemAdapter.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/CheckedListItemAdapter.kt @@ -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(), HighlightText { private lateinit var list: SortedList @@ -31,6 +33,7 @@ class CheckedListItemAdapter( preferences, listManager, isCheckedListAdapter, + scrollView, ) { override fun getItem(position: Int): ListItem = list[position] } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapter.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapter.kt index 6e3967c4..795bcbda 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapter.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapter.kt @@ -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(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) } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapterBase.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapterBase.kt index e8d9b97f..a955f6b9 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapterBase.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemAdapterBase.kt @@ -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>() fun onAttachedToRecyclerView(recyclerView: RecyclerView) { diff --git a/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerTestBase.kt b/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerTestBase.kt index cfec9def..9e56209e 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerTestBase.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerTestBase.kt @@ -88,6 +88,12 @@ open class ListManagerTestBase { } .`when`(adapter) .submitList(any()) + doAnswer { invocation -> + val listArgument = invocation.getArgument>(0) + itemsInternal = listArgument + } + .`when`(adapter) + .submitList(any(), any()) listManager.init(adapter, itemsChecked, adapterChecked) adapter.submitList(items)