Skip to content

Commit dfbc97e

Browse files
committed
feat: allow color customization and badges for Android nav rail
1 parent 29353c9 commit dfbc97e

File tree

4 files changed

+949
-111
lines changed

4 files changed

+949
-111
lines changed
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package com.rcttabview
2+
3+
import android.content.Context
4+
import android.content.res.Configuration
5+
import android.graphics.drawable.Drawable
6+
import android.os.Build
7+
import android.view.HapticFeedbackConstants
8+
import android.view.MenuItem
9+
import android.view.View
10+
import android.widget.TextView
11+
import coil3.ImageLoader
12+
import coil3.asDrawable
13+
import coil3.request.ImageRequest
14+
import coil3.svg.SvgDecoder
15+
import com.facebook.react.bridge.ReadableArray
16+
import com.facebook.react.common.assets.ReactFontManager
17+
import com.facebook.react.views.text.ReactTypefaceUtils
18+
import com.google.android.material.navigationrail.NavigationRailView
19+
20+
class ReactNavigationRailView(context: Context) : NavigationRailView(context) {
21+
override fun getMaxItemCount(): Int {
22+
return 100
23+
}
24+
25+
var onTabSelectedListener: ((key: String) -> Unit)? = null
26+
var onTabLongPressedListener: ((key: String) -> Unit)? = null
27+
var items: MutableList<TabInfo> = mutableListOf()
28+
private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
29+
private val drawableCache: MutableMap<ImageSource, Drawable> = mutableMapOf()
30+
private var pendingRailSelection: String? = null
31+
32+
private var selectedItem: String? = null
33+
private var activeTintColor: Int? = null
34+
private var inactiveTintColor: Int? = null
35+
private val checkedStateSet = intArrayOf(android.R.attr.state_checked)
36+
private val uncheckedStateSet = intArrayOf(-android.R.attr.state_checked)
37+
private var hapticFeedbackEnabled = false
38+
private var fontSize: Int? = null
39+
private var fontFamily: String? = null
40+
private var fontWeight: Int? = null
41+
private var labeled: Boolean? = null
42+
private var hasCustomAppearance = false
43+
44+
private val imageLoader = ImageLoader.Builder(context)
45+
.components {
46+
add(SvgDecoder.Factory())
47+
}
48+
.build()
49+
50+
init {
51+
// Set up navigation rail listeners using Material3's built-in methods
52+
setOnItemSelectedListener { menuItem ->
53+
try {
54+
val selectedTab = items.getOrNull(menuItem.itemId)
55+
selectedTab?.let {
56+
selectedItem = it.key
57+
onTabSelectedListener?.invoke(it.key)
58+
emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
59+
}
60+
} catch (e: Exception) {
61+
// Silently handle selection errors
62+
}
63+
true
64+
}
65+
66+
setOnItemReselectedListener { menuItem ->
67+
val reselectedTab = items.getOrNull(menuItem.itemId)
68+
reselectedTab?.let {
69+
// Handle reselection if needed
70+
}
71+
}
72+
}
73+
74+
private fun getDrawable(imageSource: ImageSource, onDrawableReady: (Drawable?) -> Unit) {
75+
drawableCache[imageSource]?.let {
76+
onDrawableReady(it)
77+
return
78+
}
79+
val request = ImageRequest.Builder(context)
80+
.data(imageSource.getUri(context))
81+
.target { drawable ->
82+
post {
83+
val stateDrawable = drawable.asDrawable(context.resources)
84+
drawableCache[imageSource] = stateDrawable
85+
onDrawableReady(stateDrawable)
86+
}
87+
}
88+
.listener(
89+
onError = { _, result ->
90+
// Silently handle image loading errors
91+
}
92+
)
93+
.build()
94+
95+
imageLoader.enqueue(request)
96+
}
97+
98+
fun updateItems(items: MutableList<TabInfo>) {
99+
// If an item got removed, let's re-add all items
100+
if (items.size < this.items.size) {
101+
menu.clear()
102+
}
103+
this.items = items
104+
items.forEachIndexed { index, item ->
105+
val menuItem = getOrCreateItem(index, item.title)
106+
if (item.title != menuItem.title) {
107+
menuItem.title = item.title
108+
}
109+
110+
menuItem.isVisible = !item.hidden
111+
if (iconSources.containsKey(index)) {
112+
getDrawable(iconSources[index]!!) { drawable ->
113+
menuItem.icon = drawable
114+
}
115+
}
116+
117+
// Handle badges for NavigationRail
118+
if (item.badge?.isNotEmpty() == true) {
119+
getOrCreateBadge(index).let { badge ->
120+
badge.isVisible = true
121+
badge.text = item.badge
122+
}
123+
} else {
124+
removeBadge(index)
125+
}
126+
127+
// Set up long press listener and testID
128+
post {
129+
val itemView = findViewById<View>(menuItem.itemId)
130+
itemView?.let { view ->
131+
view.setOnLongClickListener {
132+
onTabLongPressedListener?.invoke(item.key)
133+
emitHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
134+
true
135+
}
136+
137+
item.testID?.let { testId ->
138+
view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)
139+
?.apply {
140+
tag = testId
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
// Update tint colors and text appearance after updating all items
148+
post {
149+
updateTextAppearance()
150+
updateTintColors()
151+
}
152+
}
153+
154+
private fun getOrCreateItem(index: Int, title: String): MenuItem {
155+
return menu.findItem(index) ?: menu.add(0, index, 0, title)
156+
}
157+
158+
fun setSelectedItem(value: String) {
159+
selectedItem = value
160+
val index = items.indexOfFirst { it.key == value }
161+
162+
// Only try to set selection if menu is populated and index is valid
163+
if (index >= 0 && menu.size() > 0 && index < menu.size()) {
164+
// Use post to ensure the menu is fully initialized
165+
post {
166+
try {
167+
val menuItem = menu.findItem(index)
168+
if (menuItem != null && menuItem.isVisible) {
169+
selectedItemId = index
170+
}
171+
} catch (e: Exception) {
172+
// Silently handle selection errors
173+
}
174+
}
175+
}
176+
} fun setLabeled(labeled: Boolean?) {
177+
this.labeled = labeled
178+
labelVisibilityMode = when (labeled) {
179+
false -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED
180+
true -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED
181+
else -> com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO
182+
}
183+
}
184+
185+
fun setIcons(icons: ReadableArray?) {
186+
if (icons == null || icons.size() == 0) {
187+
return
188+
}
189+
190+
for (idx in 0 until icons.size()) {
191+
val source = icons.getMap(idx)
192+
val uri = source?.getString("uri")
193+
if (uri.isNullOrEmpty()) {
194+
continue
195+
}
196+
197+
val imageSource = ImageSource(context, uri)
198+
this.iconSources[idx] = imageSource
199+
200+
// Update existing item if exists
201+
menu.findItem(idx)?.let { menuItem ->
202+
getDrawable(imageSource) { drawable ->
203+
menuItem.icon = drawable
204+
}
205+
}
206+
}
207+
}
208+
209+
fun setBarTintColor(color: Int?) {
210+
val backgroundColor = color ?: Utils.getDefaultColorFor(context, android.R.attr.colorPrimary) ?: return
211+
val colorDrawable = android.graphics.drawable.ColorDrawable(backgroundColor)
212+
itemBackground = colorDrawable
213+
backgroundTintList = android.content.res.ColorStateList.valueOf(backgroundColor)
214+
hasCustomAppearance = true
215+
}
216+
217+
fun setActiveTintColor(color: Int?) {
218+
activeTintColor = color
219+
updateTintColors()
220+
}
221+
222+
fun setInactiveTintColor(color: Int?) {
223+
inactiveTintColor = color
224+
updateTintColors()
225+
}
226+
227+
fun setFontSize(fontSize: Int?) {
228+
this.fontSize = fontSize
229+
updateTextAppearance()
230+
}
231+
232+
fun setFontFamily(fontFamily: String?) {
233+
this.fontFamily = fontFamily
234+
updateTextAppearance()
235+
}
236+
237+
fun setFontWeight(fontWeight: Int?) {
238+
this.fontWeight = fontWeight
239+
updateTextAppearance()
240+
}
241+
242+
fun setRippleColor(color: Int?) {
243+
itemRippleColor = color?.let { android.content.res.ColorStateList.valueOf(it) }
244+
}
245+
246+
fun setActiveIndicatorColor(color: Int?) {
247+
activeTintColor = color
248+
}
249+
250+
override fun setHapticFeedbackEnabled(hapticFeedbackEnabled: Boolean) {
251+
this.hapticFeedbackEnabled = hapticFeedbackEnabled
252+
}
253+
254+
fun updateTextAppearance() {
255+
// Early return if there is no custom text appearance
256+
if (fontSize == null && fontFamily == null && fontWeight == null) {
257+
return
258+
}
259+
260+
val typeface = if (fontFamily != null || fontWeight != null) {
261+
ReactFontManager.getInstance().getTypeface(
262+
fontFamily ?: "",
263+
Utils.getTypefaceStyle(fontWeight),
264+
context.assets
265+
)
266+
} else null
267+
val size = fontSize?.toFloat()?.takeIf { it > 0 }
268+
269+
val menuView = getChildAt(0) as? android.view.ViewGroup ?: return
270+
for (i in 0 until menuView.childCount) {
271+
val item = menuView.getChildAt(i)
272+
val largeLabel =
273+
item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_large_label_view)
274+
val smallLabel =
275+
item.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_small_label_view)
276+
277+
listOf(largeLabel, smallLabel).forEach { label ->
278+
label?.apply {
279+
size?.let { setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, it) }
280+
typeface?.let { setTypeface(it) }
281+
}
282+
}
283+
}
284+
}
285+
286+
fun updateTintColors() {
287+
val currentItemTintColor = items.firstOrNull { it.key == selectedItem }?.activeTintColor
288+
val colorPrimary = currentItemTintColor ?: activeTintColor ?: Utils.getDefaultColorFor(
289+
context,
290+
android.R.attr.colorPrimary
291+
) ?: return
292+
val colorSecondary =
293+
inactiveTintColor ?: Utils.getDefaultColorFor(context, android.R.attr.textColorSecondary)
294+
?: return
295+
val states = arrayOf(uncheckedStateSet, checkedStateSet)
296+
val colors = intArrayOf(colorSecondary, colorPrimary)
297+
298+
android.content.res.ColorStateList(states, colors).apply {
299+
itemTextColor = this
300+
itemIconTintList = this
301+
}
302+
}
303+
304+
private fun emitHapticFeedback(feedbackConstants: Int) {
305+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hapticFeedbackEnabled) {
306+
this.performHapticFeedback(feedbackConstants)
307+
}
308+
}
309+
310+
fun handleConfigurationChanged(newConfig: Configuration?) {
311+
if (hasCustomAppearance) {
312+
return
313+
}
314+
315+
// User has hidden the navigation rail, don't re-attach it
316+
if (visibility == View.GONE) {
317+
return
318+
}
319+
320+
// Re-setup after configuration change
321+
updateItems(items)
322+
setLabeled(this.labeled)
323+
this.selectedItem?.let { setSelectedItem(it) }
324+
}
325+
326+
override fun onConfigurationChanged(newConfig: Configuration?) {
327+
super.onConfigurationChanged(newConfig)
328+
handleConfigurationChanged(newConfig)
329+
}
330+
331+
fun onDropViewInstance() {
332+
imageLoader.shutdown()
333+
}
334+
}

0 commit comments

Comments
 (0)