Skip to content

Commit 829624d

Browse files
david-allisonmikehardy
authored andcommitted
fix(card-browser): end multiselect position
If ending multiselect using navigation it felt best to 'anchor' the list on the first visible selected row (so it stays in the same position) If there is no selected item in view, the item at the top of the list is maintained
1 parent b5f588b commit 829624d

File tree

3 files changed

+68
-3
lines changed

3 files changed

+68
-3
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import com.ichi2.anki.launchCatchingTask
5252
import com.ichi2.anki.observability.ChangeManager
5353
import com.ichi2.anki.snackbar.showSnackbar
5454
import com.ichi2.anki.ui.attachFastScroller
55+
import com.ichi2.anki.utils.ext.visibleItemPositions
5556
import com.ichi2.utils.HandlerUtils
5657
import kotlinx.coroutines.flow.Flow
5758
import kotlinx.coroutines.launch
@@ -72,6 +73,10 @@ class CardBrowserFragment :
7273
@VisibleForTesting
7374
lateinit var cardsListView: RecyclerView
7475

76+
/** LayoutManager for [cardsListView] */
77+
val layoutManager: LinearLayoutManager
78+
get() = cardsListView.layoutManager as LinearLayoutManager
79+
7580
@VisibleForTesting
7681
lateinit var browserColumnHeadings: ViewGroup
7782

@@ -144,9 +149,26 @@ class CardBrowserFragment :
144149
// update adapter to remove check boxes
145150
cardsAdapter.notifyDataSetChanged()
146151
if (modeChange is SingleSelectCause.DeselectRow) {
152+
cardsAdapter.notifyDataSetChanged()
147153
autoScrollTo(modeChange.selection)
148154
} else if (modeChange is MultiSelectCause.RowSelected) {
155+
cardsAdapter.notifyDataSetChanged()
149156
autoScrollTo(modeChange.selection)
157+
} else if (modeChange is SingleSelectCause && !modeChange.previouslySelectedRowIds.isNullOrEmpty()) {
158+
// if any visible rows are selected, anchor on the first row
159+
160+
// obtain the offset of the row before we call notifyDataSetChanged
161+
val rowPositionAndOffset =
162+
try {
163+
val visibleRowIds = layoutManager.visibleItemPositions.map { viewModel.getRowAtPosition(it) }
164+
val firstVisibleRowId = visibleRowIds.firstOrNull { modeChange.previouslySelectedRowIds!!.contains(it) }
165+
firstVisibleRowId?.let { firstVisibleRowId.toRowSelection() }
166+
} catch (e: Exception) {
167+
Timber.w(e)
168+
null
169+
}
170+
cardsAdapter.notifyDataSetChanged()
171+
rowPositionAndOffset?.let { autoScrollTo(it) }
150172
}
151173
}
152174

@@ -276,15 +298,14 @@ class CardBrowserFragment :
276298
}
277299

278300
private fun calculateTopOffset(cardPosition: Int): Int {
279-
val layoutManager = cardsListView.layoutManager as LinearLayoutManager
280301
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
281302
val view = cardsListView.getChildAt(cardPosition - firstVisiblePosition)
282303
return view?.top ?: 0
283304
}
284305

285306
private fun autoScrollTo(rowSelection: RowSelection) {
286307
val newPosition = viewModel.getPositionOfId(rowSelection.rowId) ?: return
287-
(cardsListView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(newPosition, rowSelection.topOffset)
308+
layoutManager.scrollToPositionWithOffset(newPosition, rowSelection.topOffset)
288309
}
289310

290311
private fun CardOrNoteId.toRowSelection() =

AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,12 @@ class CardBrowserViewModel(
598598
fun selectNone(): Job? {
599599
if (_selectedRows.isEmpty()) return null
600600
Timber.d("selecting none")
601+
val removalReason =
602+
SingleSelectCause.Other.apply {
603+
this.previouslySelectedRowIds = _selectedRows.toSet()
604+
}
601605
_selectedRows.clear()
602-
return onRemoveSelectedRows(disableMultiSelectIfEmpty = false, reason = SingleSelectCause.Other)
606+
return onRemoveSelectedRows(disableMultiSelectIfEmpty = false, reason = removalReason)
603607
}
604608

605609
/**
@@ -1075,6 +1079,7 @@ class CardBrowserViewModel(
10751079
* Turn off [Multi-Select Mode][isInMultiSelectMode] and return to normal state
10761080
*/
10771081
fun endMultiSelectMode(reason: SingleSelectCause) {
1082+
reason.previouslySelectedRowIds = _selectedRows.toSet()
10781083
_selectedRows.clear()
10791084
flowOfMultiSelectModeChanged.value = reason
10801085
}
@@ -1286,6 +1291,8 @@ class CardBrowserViewModel(
12861291
data object NavigateBack : SingleSelectCause()
12871292

12881293
data object Other : SingleSelectCause()
1294+
1295+
var previouslySelectedRowIds: Set<CardOrNoteId>? = null
12891296
}
12901297

12911298
sealed class MultiSelectCause : ChangeMultiSelectMode() {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) 2025 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.utils.ext
18+
19+
import androidx.recyclerview.widget.LinearLayoutManager
20+
import androidx.recyclerview.widget.RecyclerView
21+
22+
/**
23+
* Returns range of adapter positions of the visible items.
24+
*
25+
* This position does not include adapter changes that were dispatched after the last layout pass.
26+
*
27+
* Returns [IntRange.EMPTY] if the [LinearLayoutManager] contains no items.
28+
*/
29+
val LinearLayoutManager.visibleItemPositions: IntRange
30+
get() {
31+
val first = findFirstVisibleItemPosition()
32+
val last = findLastVisibleItemPosition()
33+
if (first == RecyclerView.NO_POSITION || last == RecyclerView.NO_POSITION) {
34+
return IntRange.EMPTY
35+
}
36+
return first..last
37+
}

0 commit comments

Comments
 (0)