Skip to content

Commit b5e70a0

Browse files
feat(deck-picker): draw tree hierarchy branch lines
- Adds a custom `RecyclerView.ItemDecoration` that dynamically draws the visual branch hierarchy for subdecks. - Evaluates tree paths natively from the adapter's `currentList` data. - Handles drawing rounded "L" branches connecting parents to nested children as well as continued vertical lines descending past inner children to later siblings.
1 parent e6e9411 commit b5e70a0

3 files changed

Lines changed: 183 additions & 5 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ import com.ichi2.anki.utils.ext.setFragmentResultListener
178178
import com.ichi2.anki.utils.ext.setImageDrawableSafe
179179
import com.ichi2.anki.utils.ext.showDialogFragment
180180
import com.ichi2.anki.widgets.DeckAdapter
181+
import com.ichi2.anki.widgets.DeckHierarchyLinesDecoration
181182
import com.ichi2.anki.worker.SyncMediaWorker
182183
import com.ichi2.anki.worker.SyncWorker
183184
import com.ichi2.anki.worker.UniqueWorkNames
@@ -535,8 +536,11 @@ open class DeckPicker :
535536
viewModel.requestRightClickContextMenu(deckId, x, y)
536537
Timber.d("Right Click on deck recorded!! %d, %f %f", deckId, x, y)
537538
},
538-
)
539+
).apply {
540+
highlightSelected = fragmented
541+
}
539542
deckPickerBinding.decks.adapter = deckListAdapter
543+
deckPickerBinding.decks.addItemDecoration(DeckHierarchyLinesDecoration(this))
540544

541545
lifecycleScope.launch { applyDeckPickerBackground() }
542546

AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.ichi2.anki.R
3131
import com.ichi2.anki.databinding.ItemDeckBinding
3232
import com.ichi2.anki.deckpicker.DisplayDeckNode
3333
import com.ichi2.anki.libanki.DeckId
34+
import com.ichi2.utils.dp
3435
import kotlinx.coroutines.runBlocking
3536
import net.ankiweb.rsdroid.RustCleanup
3637
import com.ichi2.anki.common.android.R as CommonR
@@ -72,6 +73,7 @@ class DeckAdapter(
7273
private val startPadding: Int = context.resources.getDimension(R.dimen.deck_picker_left_padding).toInt()
7374
private val startPaddingSmall: Int = context.resources.getDimension(R.dimen.deck_picker_left_padding_small).toInt()
7475
private val nestedIndent = context.resources.getDimension(R.dimen.keyline_1).toInt()
76+
private val expanderWidth = 48.dp.toPx(context)
7577

7678
// Flags
7779
private var hasSubdecks = false
@@ -88,6 +90,12 @@ class DeckAdapter(
8890
}
8991
}
9092

93+
/**
94+
* Whether to highlight the selected deck. Usually true for fragmented (tablet) layouts
95+
* where the deck contents are shown side-by-side, but false for phones.
96+
*/
97+
var highlightSelected: Boolean = true
98+
9199
class ViewHolder(
92100
val binding: ItemDeckBinding,
93101
) : RecyclerView.ViewHolder(binding.root)
@@ -151,7 +159,7 @@ class DeckAdapter(
151159
}
152160
holder.binding.deckLayout.setBackgroundResource(rowCurrentDrawable)
153161
// set a different background color for the current selected deck
154-
if (node.isSelected) {
162+
if (node.isSelected && highlightSelected) {
155163
holder.binding.deckLayout.setBackgroundResource(rowCurrentDrawable)
156164
if (activityHasBackground) {
157165
val background =
@@ -202,6 +210,7 @@ class DeckAdapter(
202210
) {
203211
// Apply the correct expand/collapse drawable
204212
if (node.canCollapse) {
213+
expander.visibility = View.VISIBLE
205214
expander.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
206215
if (node.collapsed) {
207216
expander.setImageDrawable(expandImage)
@@ -210,12 +219,15 @@ class DeckAdapter(
210219
expander.setImageDrawable(collapseImage)
211220
expander.contentDescription = expander.context.getString(R.string.collapse)
212221
}
222+
223+
indent.minimumWidth = nestedIndent * node.depth
213224
} else {
214-
expander.visibility = View.INVISIBLE
225+
// To keep the deck name text perfectly aligned with parent decks above it, we manually add the missing expander width
226+
// back into the indent calculation.
227+
expander.visibility = View.GONE
215228
expander.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
229+
indent.minimumWidth = nestedIndent * node.depth + (expanderWidth - nestedIndent)
216230
}
217-
// Add some indenting for each nested level
218-
indent.minimumWidth = nestedIndent * node.depth
219231
}
220232

221233
companion object {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: Copyright (c) 2026 Shaan Narendran <shaannaren06@gmail.com>
3+
package com.ichi2.anki.widgets
4+
5+
import android.content.Context
6+
import android.graphics.Canvas
7+
import android.graphics.Paint
8+
import android.graphics.Path
9+
import android.graphics.RectF
10+
import android.util.TypedValue
11+
import androidx.recyclerview.widget.RecyclerView
12+
import com.ichi2.anki.R
13+
import com.ichi2.utils.dp
14+
15+
class DeckHierarchyLinesDecoration(
16+
context: Context,
17+
) : RecyclerView.ItemDecoration() {
18+
private val paint =
19+
Paint(Paint.ANTI_ALIAS_FLAG).apply {
20+
style = Paint.Style.STROKE
21+
strokeCap = Paint.Cap.BUTT
22+
strokeWidth = 2.dp.toPx(context).toFloat()
23+
24+
val typedValue = TypedValue()
25+
context.theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true)
26+
color = typedValue.data
27+
alpha = 30
28+
}
29+
30+
private val nestedIndent = context.resources.getDimension(R.dimen.keyline_1)
31+
private val expanderCenterOffset = 24.dp.toPx(context).toFloat()
32+
private val cornerRadius = nestedIndent - 12.dp.toPx(context)
33+
private val reusablePath = Path()
34+
private val reusableRect = RectF()
35+
36+
/**
37+
* Cache used to store the bitmask of active vertical lines passing through each visible row.
38+
* The index maps to the child position in the RecyclerView, and the 64-bit Long stores the active depths.
39+
* It dynamically resizes if the RecyclerView holds more visible items.
40+
*/
41+
private var siblingCache = LongArray(50)
42+
43+
override fun onDrawOver(
44+
c: Canvas,
45+
parent: RecyclerView,
46+
state: RecyclerView.State,
47+
) {
48+
val childCount = parent.childCount
49+
if (childCount == 0) return
50+
51+
val currentList = (parent.adapter as? DeckAdapter)?.currentList ?: return
52+
if (currentList.isEmpty()) return
53+
54+
if (siblingCache.size < childCount) {
55+
siblingCache = LongArray(childCount * 2)
56+
}
57+
// We use a 64-bit Long as a bitmask. Each bit index represents a depth level.
58+
// If bit 'd' is 1, it means there is a sibling further down the list at depth 'd'
59+
// so we must draw a vertical line
60+
var activeLines = 0L
61+
62+
// Look ahead past the last visible item to find lines that continue below the screen
63+
val lastView = parent.getChildAt(childCount - 1)
64+
val maxPos = parent.getChildAdapterPosition(lastView)
65+
if (maxPos != RecyclerView.NO_POSITION && maxPos + 1 < currentList.size) {
66+
for (i in maxPos + 1 until currentList.size) {
67+
val d = currentList[i].depth
68+
if (d >= 64) continue
69+
// Mark this depth as having an active sibling by shifting a 1 into the appropriate bit
70+
activeLines = activeLines or (1L shl d)
71+
72+
// Any node resets the active lines for all depths deeper than itself
73+
// For example, if we hit a depth 1 node, depths 2, 3, etc. are cleared
74+
// eg:- If we have a deck Math with Algebra as a subdeck and linear, abstract are below it
75+
// this ends at depth 2. If there's another child of Math say Geometry, this would be at depth 1
76+
// If we can see this, then that means we have no more depth 2 children, using a mask allows us
77+
// to terminate the deeper sub-nodes of a parent that has no more children like in the example
78+
val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L
79+
activeLines = activeLines and mask
80+
if (d == 0) break // Root node resets everything below it
81+
}
82+
}
83+
84+
// Scan backwards over the visible items to record the active lines after each node
85+
for (i in childCount - 1 downTo 0) {
86+
val view = parent.getChildAt(i)
87+
val pos = parent.getChildAdapterPosition(view)
88+
if (pos == RecyclerView.NO_POSITION) {
89+
siblingCache[i] = 0L
90+
continue
91+
}
92+
93+
// We store the bitmask we made earlier in the siblingcache for each row so that we can
94+
// know which lines need to pass through this row to reach the decks after it
95+
siblingCache[i] = activeLines
96+
97+
// We look at the last deck that we can see and store it in d
98+
// eg:- if we have a child at the bottom of depth 2 our bits look like 0100
99+
val d = currentList[pos].depth
100+
if (d < 64) {
101+
// We do an or with the activeLines (activelines stores the number of lines passing through
102+
// the current row) and this will give us the number of lines to draw
103+
activeLines = activeLines or (1L shl d)
104+
// We use a mask to mask off the bits for a deeper depth than what we calculated above
105+
val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L
106+
activeLines = activeLines and mask
107+
}
108+
}
109+
110+
// Loop to draw the lines
111+
for (i in 0 until childCount) {
112+
val view = parent.getChildAt(i)
113+
val position = parent.getChildAdapterPosition(view)
114+
if (position == RecyclerView.NO_POSITION) continue
115+
116+
val node = currentList[position]
117+
val depth = node.depth
118+
119+
val top = view.y
120+
val bottom = view.y + view.height
121+
val centerY = top + view.height / 2f
122+
123+
// Helper to check the precomputed bitmask
124+
val hasSibling = { targetDepth: Int ->
125+
targetDepth < 64 && (siblingCache[i] and (1L shl targetDepth)) != 0L
126+
}
127+
128+
for (level in 0 until depth - 1) {
129+
if (hasSibling(level + 1)) {
130+
val x = getLineX(level)
131+
c.drawLine(x, top, x, bottom, paint)
132+
}
133+
}
134+
135+
if (depth > 0) {
136+
val level = depth - 1
137+
val x = getLineX(level)
138+
val endX = x + cornerRadius
139+
140+
if (hasSibling(depth)) {
141+
c.drawLine(x, top, x, bottom, paint)
142+
c.drawLine(x, centerY, endX, centerY, paint)
143+
} else {
144+
reusablePath.reset()
145+
reusablePath.moveTo(x, top)
146+
reusablePath.lineTo(x, centerY - cornerRadius)
147+
reusableRect.set(x, centerY - 2 * cornerRadius, x + 2 * cornerRadius, centerY)
148+
reusablePath.arcTo(reusableRect, 180f, -90f, false)
149+
c.drawPath(reusablePath, paint)
150+
}
151+
}
152+
153+
if (position + 1 < currentList.size && currentList[position + 1].depth == depth + 1) {
154+
val x = getLineX(depth)
155+
val iconOffset = 12.dp.toPx(view.context).toFloat()
156+
c.drawLine(x, centerY + iconOffset, x, bottom, paint)
157+
}
158+
}
159+
}
160+
161+
private fun getLineX(depth: Int): Float = depth * nestedIndent + expanderCenterOffset
162+
}

0 commit comments

Comments
 (0)