Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ class TabScreen(

internal lateinit var eventEmitter: TabScreenEventEmitter

var appearance: AndroidTabsAppearance? by Delegates.observable(null) { _, oldValue, newValue ->
if (oldValue != newValue) {
tabScreenDelegate.get()?.onAppearanceChanged(this)
}
}

var tabKey: String? = null
set(value) {
field =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.content.res.Configuration
import androidx.fragment.app.Fragment

internal interface TabScreenDelegate {
fun onAppearanceChanged(tabScreen: TabScreen)

fun onTabFocusChangedFromJS(
tabScreen: TabScreen,
isFocused: Boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.swmansion.rnscreens.gamma.tabs

import android.util.Log
import androidx.core.graphics.toColorInt
import com.facebook.react.bridge.Dynamic
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
Expand Down Expand Up @@ -61,14 +64,6 @@ class TabScreenViewManager :
value: Dynamic,
) = Unit

@ReactProp(name = "tabBarItemBadgeBackgroundColor", customType = "Color")
override fun setTabBarItemBadgeBackgroundColor(
view: TabScreen,
value: Int?,
) {
view.tabBarItemBadgeBackgroundColor = value
}

override fun setIconType(
view: TabScreen?,
value: String?,
Expand Down Expand Up @@ -198,13 +193,6 @@ class TabScreenViewManager :
}

// Android specific
@ReactProp(name = "tabBarItemBadgeTextColor", customType = "Color")
override fun setTabBarItemBadgeTextColor(
view: TabScreen,
value: Int?,
) {
view.tabBarItemBadgeTextColor = value
}

@ReactProp(name = "drawableIconResourceName")
override fun setDrawableIconResourceName(
Expand Down Expand Up @@ -240,6 +228,86 @@ class TabScreenViewManager :
}
}

@ReactProp(name = "standardAppearanceAndroid")
override fun setStandardAppearanceAndroid(
view: TabScreen,
value: Dynamic?,
) {
if (value == null || value.isNull) {
view.appearance = null
return
}
val map = if (value.type == ReadableType.Map) value.asMap() else null

if (map == null) {
view.appearance = null
return
}

view.appearance = parseAndroidTabsAppearance(map)
}

private fun ReadableMap.getOptionalBoolean(key: String): Boolean? {
if (!hasKey(key) || isNull(key)) return null
return if (getType(key) == ReadableType.Boolean) getBoolean(key) else null
}

private fun ReadableMap.getOptionalString(key: String): String? {
if (!hasKey(key) || isNull(key)) return null
return if (getType(key) == ReadableType.String) getString(key) else null
}

private fun ReadableMap.getOptionalFloat(key: String): Float? {
if (!hasKey(key) || isNull(key)) return null
return if (getType(key) == ReadableType.Number) getDouble(key).toFloat() else null
}

/**
* Safely retrieves a Color.
* Supports both Integer (processed color) and String (hex/name) values.
*/
private fun ReadableMap.getOptionalColor(key: String): Int? {
if (!hasKey(key) || isNull(key)) return null

return try {
when (getType(key)) {
ReadableType.Number -> getInt(key)
ReadableType.String -> getString(key)?.toColorInt()
else -> null
}
} catch (e: Exception) {
Log.w(REACT_CLASS, "[RNScreens] Could not parse color for key '$key': ${e.message}")
null
}
}

private fun parseAndroidTabsAppearance(map: ReadableMap): AndroidTabsAppearance =
AndroidTabsAppearance(
normal = if (map.hasKey("normal")) parseStateAppearance(map.getMap("normal")) else null,
selected = if (map.hasKey("selected")) parseStateAppearance(map.getMap("selected")) else null,
focused = if (map.hasKey("focused")) parseStateAppearance(map.getMap("focused")) else null,
disabled = if (map.hasKey("disabled")) parseStateAppearance(map.getMap("disabled")) else null,
tabBarBackgroundColor = map.getOptionalColor("tabBarBackgroundColor"),
tabBarItemRippleColor = map.getOptionalColor("tabBarItemRippleColor"),
tabBarItemActiveIndicatorColor = map.getOptionalColor("tabBarItemActiveIndicatorColor"),
tabBarItemActiveIndicatorEnabled = map.getOptionalBoolean(key = "tabBarItemActiveIndicatorEnabled"),
tabBarItemLabelVisibilityMode = map.getOptionalString("tabBarItemLabelVisibilityMode"),
)

private fun parseStateAppearance(map: ReadableMap?): AndroidTabsScreenItemStateAppearance? {
if (map == null) return null
return AndroidTabsScreenItemStateAppearance(
tabBarItemTitleFontFamily = map.getOptionalString("tabBarItemTitleFontFamily"),
tabBarItemTitleFontSize = map.getOptionalFloat("tabBarItemTitleFontSize"),
tabBarItemTitleFontWeight = map.getOptionalString("tabBarItemTitleFontWeight"),
tabBarItemTitleFontStyle = map.getOptionalString("tabBarItemTitleFontStyle"),
tabBarItemTitleFontColor = map.getOptionalColor("tabBarItemTitleFontColor"),
tabBarItemIconColor = map.getOptionalColor("tabBarItemIconColor"),
tabBarItemBadgeBackgroundColor = map.getOptionalColor("tabBarItemBadgeBackgroundColor"),
tabBarItemBadgeTextColor = map.getOptionalColor("tabBarItemBadgeTextColor"),
)
}

companion object {
const val REACT_CLASS = "RNSBottomTabsScreen"
}
Expand Down
127 changes: 38 additions & 89 deletions android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.swmansion.rnscreens.gamma.tabs

import android.content.res.Configuration
import android.os.Build
import android.view.Choreographer
import android.view.Gravity
import android.view.MenuItem
import android.view.View
Expand All @@ -12,7 +11,6 @@ import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.children
import androidx.fragment.app.FragmentManager
import com.facebook.react.modules.core.ReactChoreographer
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.swmansion.rnscreens.BuildConfig
Expand Down Expand Up @@ -83,15 +81,15 @@ class TabsHost(

fun runContainerUpdate() {
isUpdatePending = false
if (isSelectedTabInvalidated) {
isSelectedTabInvalidated = false
this@TabsHost.updateSelectedTab()
}
if (isBottomNavigationMenuInvalidated) {
isBottomNavigationMenuInvalidated = false
this@TabsHost.updateBottomNavigationViewAppearance()
a11yCoordinator.setA11yPropertiesToAllTabItems()
}
if (isSelectedTabInvalidated) {
isSelectedTabInvalidated = false
this@TabsHost.updateSelectedTab()
}
}
}

Expand Down Expand Up @@ -153,76 +151,28 @@ class TabsHost(

private val tabScreenFragments: MutableList<TabScreenFragment> = arrayListOf()

private val currentFocusedTab: TabScreenFragment
internal val currentFocusedTab: TabScreenFragment
get() = checkNotNull(tabScreenFragments.find { it.tabScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }

private var lastAppliedUiMode: Int? = null

private var isLayoutEnqueued: Boolean = false

private val measureAndLayoutRunnable =
Runnable {
isLayoutEnqueued = false
if (width > 0 && height > 0) {
forceSubtreeMeasureAndLayoutPass()
}
}

private var interfaceInsetsChangeListener: SafeAreaView? = null

private val appearanceCoordinator =
TabsHostAppearanceCoordinator(wrappedContext, bottomNavigationView, tabScreenFragments)

private val a11yCoordinator = TabsHostA11yCoordinator(bottomNavigationView, tabScreenFragments)

var tabBarBackgroundColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemActiveIndicatorColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var isTabBarItemActiveIndicatorEnabled: Boolean by Delegates.observable(true) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemIconColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontFamily: String? by Delegates.observable<String?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemIconColorActive: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontColorActive: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontSize: Float? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontSizeActive: Float? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontWeight: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemTitleFontStyle: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemRippleColor: Int? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarItemLabelVisibilityMode: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}

var tabBarHidden: Boolean by Delegates.observable(false) { _, oldValue, newValue ->
if (newValue != oldValue) {
updateInterfaceInsets()
Expand Down Expand Up @@ -336,6 +286,15 @@ class TabsHost(
}
}

override fun onAppearanceChanged(tabScreen: TabScreen) {
if (tabScreen.isFocusedTab) {
containerUpdateCoordinator.let {
it.invalidateNavigationMenu()
it.postContainerUpdateIfNeeded()
}
}
}

override fun onTabFocusChangedFromJS(
tabScreen: TabScreen,
isFocused: Boolean,
Expand All @@ -348,7 +307,8 @@ class TabsHost(

override fun onMenuItemAttributesChange(tabScreen: TabScreen) {
getMenuItemForTabScreen(tabScreen)?.let { menuItem ->
appearanceCoordinator.updateMenuItemAppearance(menuItem, tabScreen)
val activeTabScreen = currentFocusedTab.tabScreen
appearanceCoordinator.updateMenuItemAppearance(menuItem, tabScreen, activeTabScreen)
a11yCoordinator.setA11yPropertiesToTabItem(menuItem, tabScreen)
}
}
Expand All @@ -372,11 +332,6 @@ class TabsHost(
if (bottomNavigationView.selectedItemId != selectedTabScreenFragmentId) {
bottomNavigationView.selectedItemId = selectedTabScreenFragmentId
}

post {
refreshLayout()
RNSLog.d(TAG, "BottomNavigationView request layout")
}
}

private fun updateSelectedTab() {
Expand All @@ -401,30 +356,24 @@ class TabsHost(
}.commitNowAllowingStateLoss()
}

private val layoutCallback =
Choreographer.FrameCallback {
isLayoutEnqueued = false
forceSubtreeMeasureAndLayoutPass()
}

private fun refreshLayout() {
@Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
if (!isLayoutEnqueued && layoutCallback != null) {
override fun requestLayout() {
super.requestLayout()
if (!isLayoutEnqueued) {
isLayoutEnqueued = true
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
ReactChoreographer
.getInstance()
.postFrameCallback(
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
layoutCallback,
)
post(measureAndLayoutRunnable)
}
}

override fun requestLayout() {
super.requestLayout()
refreshLayout()
override fun onSizeChanged(
w: Int,
h: Int,
oldw: Int,
oldh: Int,
) {
super.onSizeChanged(w, h, oldw, oldh)
if (w > 0 && h > 0) {
post(measureAndLayoutRunnable)
}
}

override fun onConfigurationChanged(newConfig: Configuration?) {
Expand Down
Loading
Loading