Skip to content

Commit 253b826

Browse files
Ubaxkkafar
andauthored
feat(Android, Tabs): Add special effects to bottom tabs on Android (#3337)
## Description Adds special effects to bottom tabs implementation on Android https://github.com/user-attachments/assets/bb103090-876c-4058-91d3-f7874c09c4c0 ## Changes 1. Added `ViewFinder` helper, which has utils for finding ScrollView and Stack. The implementation is based on iOS one. 2. Added `popToRoot` method to `ScreenStack`. It dispatches dismiss event for every non-root active screen on Stack. It is JS responsibility to remove this screen from stack. This way the dismiss can be prevented. 3. Added handler for repeated tab press event. ## Test code and steps to reproduce Bottom tabs test app ## Checklist - [ ] Included code example that can be used to test this change - [ ] Updated TS types - [ ] Updated documentation: <!-- For adding new props to native-stack --> - [ ] https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx - [ ] Ensured that CI passes --------- Co-authored-by: Kacper Kafara <[email protected]>
1 parent a667a34 commit 253b826

File tree

10 files changed

+139
-16
lines changed

10 files changed

+139
-16
lines changed

android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ open class ScreenContainer(
243243
transaction.commitNowAllowingStateLoss()
244244
}
245245

246+
fun notifyScreenDetached(screen: Screen) {
247+
if (context is ReactContext) {
248+
val surfaceId = UIManagerHelper.getSurfaceId(context)
249+
UIManagerHelper
250+
.getEventDispatcherForReactTag(context as ReactContext, screen.id)
251+
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
252+
}
253+
}
254+
246255
fun notifyTopDetached() {
247256
val top = topScreen as Screen
248257
if (context is ReactContext) {

android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ class ScreenStack(
109109
super.removeScreenAt(index)
110110
}
111111

112+
// When there is more then one active screen on stack,
113+
// pops the screen, so that only one remains
114+
// Returns true when any screen was popped
115+
// When there was only one screen on stack returns false
116+
fun popToRoot(): Boolean {
117+
val rootIndex = screenWrappers.indexOfFirst { it.screen.activityState != Screen.ActivityState.INACTIVE }
118+
val lastActiveIndex = screenWrappers.indexOfLast { it.screen.activityState != Screen.ActivityState.INACTIVE }
119+
if (rootIndex >= 0 && lastActiveIndex > rootIndex) {
120+
for (screenIndex in (rootIndex + 1)..lastActiveIndex) {
121+
notifyScreenDetached(screenWrappers[screenIndex].screen)
122+
}
123+
return true
124+
}
125+
return false
126+
}
127+
112128
override fun removeAllScreens() {
113129
dismissedWrappers.clear()
114130
super.removeAllScreens()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.swmansion.rnscreens.gamma.helpers
2+
3+
import android.view.View
4+
import android.view.ViewGroup
5+
import android.widget.ScrollView
6+
import androidx.core.view.isNotEmpty
7+
import com.swmansion.rnscreens.ScreenStack
8+
9+
object ViewFinder {
10+
fun findScrollViewInFirstDescendantChain(view: View): ScrollView? {
11+
var currentView: View? = view
12+
13+
while (currentView != null) {
14+
if (currentView is ScrollView) {
15+
return currentView
16+
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
17+
currentView = currentView.getChildAt(0)
18+
} else {
19+
break
20+
}
21+
}
22+
23+
return null
24+
}
25+
26+
fun findScreenStackInFirstDescendantChain(view: View): ScreenStack? {
27+
var currentView: View? = view
28+
29+
while (currentView != null) {
30+
if (currentView is ScreenStack) {
31+
return currentView
32+
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
33+
currentView = currentView.getChildAt(0)
34+
} else {
35+
break
36+
}
37+
}
38+
39+
return null
40+
}
41+
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ class TabScreen(
6868
updateMenuItemAttributesIfNeeded(oldValue, newValue)
6969
}
7070

71+
var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true
72+
var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true
73+
7174
private fun <T> updateMenuItemAttributesIfNeeded(
7275
oldValue: T,
7376
newValue: T,

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabScreenViewManager.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,28 @@ class TabScreenViewManager :
133133
value: Boolean,
134134
) = Unit
135135

136+
@ReactProp(name = "specialEffects")
136137
override fun setSpecialEffects(
137138
view: TabScreen,
138139
value: ReadableMap?,
139-
) = Unit
140+
) {
141+
var scrollToTop = true
142+
var popToRoot = true
143+
if (value?.hasKey("repeatedTabSelection") ?: false) {
144+
value.getMap("repeatedTabSelection")?.let { repeatedTabSelectionConfig ->
145+
if (repeatedTabSelectionConfig.hasKey("scrollToTop")) {
146+
scrollToTop =
147+
repeatedTabSelectionConfig.getBoolean("scrollToTop")
148+
}
149+
if (repeatedTabSelectionConfig.hasKey("popToRoot")) {
150+
popToRoot =
151+
repeatedTabSelectionConfig.getBoolean("popToRoot")
152+
}
153+
}
154+
}
155+
view.shouldUseRepeatedTabSelectionPopToRootSpecialEffect = popToRoot
156+
view.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = scrollToTop
157+
}
140158

141159
override fun setOverrideScrollViewContentInsetAdjustmentBehavior(
142160
view: TabScreen,

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.facebook.react.uimanager.ThemedReactContext
1616
import com.google.android.material.bottomnavigation.BottomNavigationView
1717
import com.swmansion.rnscreens.BuildConfig
1818
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
19+
import com.swmansion.rnscreens.gamma.helpers.ViewFinder
1920
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
2021
import com.swmansion.rnscreens.safearea.EdgeInsets
2122
import com.swmansion.rnscreens.safearea.SafeAreaProvider
@@ -92,7 +93,29 @@ class TabsHost(
9293
}
9394
}
9495

96+
private inner class SpecialEffectsHandler {
97+
fun handleRepeatedTabSelection(): Boolean {
98+
val contentView = this@TabsHost.contentView
99+
val selectedTabFragment = this@TabsHost.currentFocusedTab
100+
if (selectedTabFragment.tabScreen.shouldUseRepeatedTabSelectionPopToRootSpecialEffect) {
101+
val screenStack = ViewFinder.findScreenStackInFirstDescendantChain(contentView)
102+
if (screenStack != null && screenStack.popToRoot()) {
103+
return true
104+
}
105+
}
106+
if (selectedTabFragment.tabScreen.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect) {
107+
val scrollView = ViewFinder.findScrollViewInFirstDescendantChain(contentView)
108+
if (scrollView != null && scrollView.scrollY > 0) {
109+
scrollView.smoothScrollTo(scrollView.scrollX, 0)
110+
return true
111+
}
112+
}
113+
return false
114+
}
115+
}
116+
95117
private val containerUpdateCoordinator = ContainerUpdateCoordinator()
118+
private val specialEffectsHandler = SpecialEffectsHandler()
96119

97120
private val wrappedContext =
98121
ContextThemeWrapper(
@@ -128,6 +151,9 @@ class TabsHost(
128151

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

154+
private val currentFocusedTab: TabScreenFragment
155+
get() = checkNotNull(tabScreenFragments.find { it.tabScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }
156+
131157
private var lastAppliedUiMode: Int? = null
132158

133159
private var isLayoutEnqueued: Boolean = false
@@ -226,8 +252,10 @@ class TabsHost(
226252
bottomNavigationView.setOnItemSelectedListener { item ->
227253
RNSLog.d(TAG, "Item selected $item")
228254
val fragment = getFragmentForMenuItemId(item.itemId)
229-
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
230-
eventEmitter.emitOnNativeFocusChange(tabKey)
255+
if (fragment != currentFocusedTab || !specialEffectsHandler.handleRepeatedTabSelection()) {
256+
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
257+
eventEmitter.emitOnNativeFocusChange(tabKey)
258+
}
231259
true
232260
}
233261
}
@@ -334,8 +362,7 @@ class TabsHost(
334362
}
335363

336364
private fun updateSelectedTab() {
337-
val newFocusedTab =
338-
checkNotNull(tabScreenFragments.find { it.tabScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }
365+
val newFocusedTab = currentFocusedTab
339366

340367
check(requireFragmentManager.fragments.size <= 1) { "[RNScreens] There can be only a single focused tab" }
341368
val oldFocusedTab = requireFragmentManager.fragments.firstOrNull()

apps/src/tests/TestBottomTabs/index.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
3838
ios: {
3939
type: 'sfSymbol',
4040
name: 'house.fill',
41-
},
41+
},
4242
android: {
4343
type: 'imageSource',
4444
imageSource: require('../../../assets/variableIcons/icon_fill.png'),
45-
}
45+
},
4646
},
4747
selectedIcon: {
4848
type: 'sfSymbol',
@@ -105,11 +105,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
105105
ios: {
106106
type: 'templateSource',
107107
templateSource: require('../../../assets/variableIcons/icon.png'),
108-
},
108+
},
109109
android: {
110110
type: 'drawableResource',
111111
name: 'sym_call_missed',
112-
}
112+
},
113113
},
114114
selectedIcon: {
115115
type: 'templateSource',
@@ -148,7 +148,7 @@ const TAB_CONFIGS: TabConfiguration[] = [
148148
shared: {
149149
type: 'imageSource',
150150
imageSource: require('../../../assets/variableIcons/icon.png'),
151-
}
151+
},
152152
},
153153
selectedIcon: {
154154
type: 'imageSource',
@@ -171,8 +171,8 @@ const TAB_CONFIGS: TabConfiguration[] = [
171171
},
172172
android: {
173173
type: 'drawableResource',
174-
name: 'custom_home_icon'
175-
}
174+
name: 'custom_home_icon',
175+
},
176176
},
177177
selectedIcon: {
178178
type: 'sfSymbol',
@@ -181,6 +181,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
181181
title: 'Tab4',
182182
systemItem: 'search', // iOS specific
183183
badgeValue: '123',
184+
specialEffects: {
185+
repeatedTabSelection: {
186+
popToRoot: false,
187+
},
188+
},
184189
},
185190
component: Tab4,
186191
},

apps/src/tests/TestBottomTabs/tabs/Tab4.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function Tab4() {
6464
<Stack.Screen
6565
name="Screen1"
6666
component={Screen1}
67-
options={{ headerTransparent: true }}
67+
options={{ headerTransparent: false }}
6868
/>
6969
<Stack.Screen
7070
name="Screen2"

react-navigation

Submodule react-navigation updated 351 files

src/components/bottom-tabs/BottomTabsScreen.types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,12 +467,16 @@ export interface BottomTabsScreenProps {
467467
* `popToRoot` has priority over `scrollToTop`.
468468
*
469469
* @default All special effects are enabled by default.
470-
*
471-
* @platform ios
472470
*/
473471
specialEffects?: {
474472
repeatedTabSelection?: {
473+
/**
474+
* @default true
475+
*/
475476
popToRoot?: boolean;
477+
/**
478+
* @default true
479+
*/
476480
scrollToTop?: boolean;
477481
};
478482
};

0 commit comments

Comments
 (0)