Skip to content

Commit 9694199

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 9694199

3 files changed

Lines changed: 182 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, deckListAdapter))
540544

541545
lifecycleScope.launch { applyDeckPickerBackground() }
542546

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ class DeckAdapter(
7272
private val startPadding: Int = context.resources.getDimension(R.dimen.deck_picker_left_padding).toInt()
7373
private val startPaddingSmall: Int = context.resources.getDimension(R.dimen.deck_picker_left_padding_small).toInt()
7474
private val nestedIndent = context.resources.getDimension(R.dimen.keyline_1).toInt()
75+
private val expanderWidth =
76+
android.util.TypedValue
77+
.applyDimension(
78+
android.util.TypedValue.COMPLEX_UNIT_DIP,
79+
48f,
80+
context.resources.displayMetrics,
81+
).toInt()
7582

7683
// Flags
7784
private var hasSubdecks = false
@@ -88,6 +95,12 @@ class DeckAdapter(
8895
}
8996
}
9097

98+
/**
99+
* Whether to highlight the selected deck. Usually true for fragmented (tablet) layouts
100+
* where the deck contents are shown side-by-side, but false for phones.
101+
*/
102+
var highlightSelected: Boolean = true
103+
91104
class ViewHolder(
92105
val binding: ItemDeckBinding,
93106
) : RecyclerView.ViewHolder(binding.root)
@@ -151,7 +164,7 @@ class DeckAdapter(
151164
}
152165
holder.binding.deckLayout.setBackgroundResource(rowCurrentDrawable)
153166
// set a different background color for the current selected deck
154-
if (node.isSelected) {
167+
if (node.isSelected && highlightSelected) {
155168
holder.binding.deckLayout.setBackgroundResource(rowCurrentDrawable)
156169
if (activityHasBackground) {
157170
val background =
@@ -202,6 +215,7 @@ class DeckAdapter(
202215
) {
203216
// Apply the correct expand/collapse drawable
204217
if (node.canCollapse) {
218+
expander.visibility = View.VISIBLE
205219
expander.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
206220
if (node.collapsed) {
207221
expander.setImageDrawable(expandImage)
@@ -210,12 +224,15 @@ class DeckAdapter(
210224
expander.setImageDrawable(collapseImage)
211225
expander.contentDescription = expander.context.getString(R.string.collapse)
212226
}
227+
228+
indent.minimumWidth = nestedIndent * node.depth
213229
} else {
214-
expander.visibility = View.INVISIBLE
230+
// To keep the deck name text perfectly aligned with parent decks above it, we manually add the missing expander width
231+
// back into the indent calculation.
232+
expander.visibility = View.GONE
215233
expander.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
234+
indent.minimumWidth = nestedIndent * node.depth + (expanderWidth - nestedIndent)
216235
}
217-
// Add some indenting for each nested level
218-
indent.minimumWidth = nestedIndent * node.depth
219236
}
220237

221238
companion object {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
14+
class DeckHierarchyLinesDecoration(
15+
context: Context,
16+
private val adapter: DeckAdapter,
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 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, context.resources.displayMetrics)
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 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, context.resources.displayMetrics)
32+
private val cornerRadius = nestedIndent - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12f, context.resources.displayMetrics)
33+
private val reusablePath = Path()
34+
private val reusableRect = RectF()
35+
private var siblingCache = LongArray(50)
36+
37+
override fun onDrawOver(
38+
c: Canvas,
39+
parent: RecyclerView,
40+
state: RecyclerView.State,
41+
) {
42+
val childCount = parent.childCount
43+
if (childCount == 0) return
44+
45+
val currentList = adapter.currentList
46+
if (currentList.isEmpty()) return
47+
48+
if (siblingCache.size < childCount) {
49+
siblingCache = LongArray(childCount * 2)
50+
}
51+
// We use a 64-bit Long as a bitmask. Each bit index represents a depth level.
52+
// If bit 'd' is 1, it means there is a sibling further down the list at depth 'd'
53+
// so we must draw a vertical line
54+
var activeLines = 0L
55+
56+
// Look ahead past the last visible item to find lines that continue below the screen
57+
val lastView = parent.getChildAt(childCount - 1)
58+
val maxPos = parent.getChildAdapterPosition(lastView)
59+
if (maxPos != RecyclerView.NO_POSITION && maxPos + 1 < currentList.size) {
60+
for (i in maxPos + 1 until currentList.size) {
61+
val d = currentList[i].depth
62+
if (d >= 64) continue
63+
// Mark this depth as having an active sibling by shifting a 1 into the appropriate bit
64+
activeLines = activeLines or (1L shl d)
65+
66+
// Any node resets the active lines for all depths deeper than itself
67+
// For example, if we hit a depth 1 node, depths 2, 3, etc. are cleared
68+
// eg:- If we have a deck Math with Algebra as a subdeck and linear, abstract are below it
69+
// this ends at depth 2. If there's another child of Math say Geometry, this would be at depth 1
70+
// If we can see this, then that means we have no more depth 2 children, using a mask allows us
71+
// to terminate the deeper sub-nodes of a parent that has no more children like in the example
72+
val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L
73+
activeLines = activeLines and mask
74+
if (d == 0) break // Root node resets everything below it
75+
}
76+
}
77+
78+
// Scan backwards over the visible items to record the active lines after each node
79+
for (i in childCount - 1 downTo 0) {
80+
val view = parent.getChildAt(i)
81+
val pos = parent.getChildAdapterPosition(view)
82+
if (pos == RecyclerView.NO_POSITION) {
83+
siblingCache[i] = 0L
84+
continue
85+
}
86+
87+
// We store the bitmask we made earlier in the siblingcache for each row so that we can
88+
// know which lines need to pass through this row to reach the decks after it
89+
siblingCache[i] = activeLines
90+
91+
// We look at the last deck that we can see and store it in d
92+
// eg:- if we have a child at the bottom of depth 2 our bits look like 0100
93+
val d = currentList[pos].depth
94+
if (d < 64) {
95+
// We do an or with the activeLines (activelines stores the number of lines passing through
96+
// the current row) and this will give us the number of lines to draw
97+
activeLines = activeLines or (1L shl d)
98+
// We use a mask to mask off the bits for a deeper depth than what we calculated above
99+
val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L
100+
activeLines = activeLines and mask
101+
}
102+
}
103+
104+
// Loop to draw the lines
105+
for (i in 0 until childCount) {
106+
val view = parent.getChildAt(i)
107+
val position = parent.getChildAdapterPosition(view)
108+
if (position == RecyclerView.NO_POSITION) continue
109+
110+
val node = currentList[position]
111+
val depth = node.depth
112+
113+
val top = view.y
114+
val bottom = view.y + view.height
115+
val centerY = top + view.height / 2f
116+
117+
// Helper to check the precomputed bitmask
118+
val hasSibling = { targetDepth: Int ->
119+
targetDepth < 64 && (siblingCache[i] and (1L shl targetDepth)) != 0L
120+
}
121+
122+
for (level in 0 until depth - 1) {
123+
if (hasSibling(level + 1)) {
124+
val x = getLineX(level)
125+
c.drawLine(x, top, x, bottom, paint)
126+
}
127+
}
128+
129+
if (depth > 0) {
130+
val level = depth - 1
131+
val x = getLineX(level)
132+
val endX = x + cornerRadius
133+
134+
if (hasSibling(depth)) {
135+
c.drawLine(x, top, x, bottom, paint)
136+
c.drawLine(x, centerY, endX, centerY, paint)
137+
} else {
138+
reusablePath.reset()
139+
reusablePath.moveTo(x, top)
140+
reusablePath.lineTo(x, centerY - cornerRadius)
141+
reusableRect.set(x, centerY - 2 * cornerRadius, x + 2 * cornerRadius, centerY)
142+
reusablePath.arcTo(reusableRect, 180f, -90f, false)
143+
c.drawPath(reusablePath, paint)
144+
}
145+
}
146+
147+
if (position + 1 < currentList.size && currentList[position + 1].depth == depth + 1) {
148+
val x = getLineX(depth)
149+
val iconOffset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12f, view.resources.displayMetrics)
150+
c.drawLine(x, centerY + iconOffset, x, bottom, paint)
151+
}
152+
}
153+
}
154+
155+
private fun getLineX(depth: Int): Float = depth * nestedIndent + expanderCenterOffset
156+
}

0 commit comments

Comments
 (0)