Skip to content
Open
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 @@ -243,6 +243,15 @@ open class ScreenContainer(
transaction.commitNowAllowingStateLoss()
}

fun notifyScreenDetached(screen: Screen) {
if (context is ReactContext) {
val surfaceId = UIManagerHelper.getSurfaceId(context)
UIManagerHelper
.getEventDispatcherForReactTag(context as ReactContext, screen.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
}
}

fun notifyTopDetached() {
val top = topScreen as Screen
if (context is ReactContext) {
Expand Down
16 changes: 16 additions & 0 deletions android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ class ScreenStack(
super.removeScreenAt(index)
}

// When there is more then one active screen on stack,
// pops the screen, so that only one remains
// Returns true when any screen was popped
// When there was only one screen on stack returns false
fun popToRoot(): Boolean {
val rootIndex = screenWrappers.indexOfFirst { it.screen.activityState != Screen.ActivityState.INACTIVE }
val lastActiveIndex = screenWrappers.indexOfLast { it.screen.activityState != Screen.ActivityState.INACTIVE }
if (rootIndex >= 0 && lastActiveIndex > rootIndex) {
for (screenIndex in (rootIndex + 1)..lastActiveIndex) {
notifyScreenDetached(screenWrappers[screenIndex].screen)
}
return true
}
return false
}

override fun removeAllScreens() {
dismissedWrappers.clear()
super.removeAllScreens()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.swmansion.rnscreens.gamma.helpers

import android.view.View
import android.view.ViewGroup
import android.widget.ScrollView
import androidx.core.view.isNotEmpty
import com.swmansion.rnscreens.ScreenStack

class ViewFinder {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can just be object to make it a singleton.

Suggested change
class ViewFinder {
object ViewFinder {

You might also make these two standalone functions.

companion object {
fun findScrollViewInFirstDescendantChain(view: View): ScrollView? {
var currentView: View? = view

while (currentView != null) {
if (currentView is ScrollView) {
return currentView
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
currentView = currentView.getChildAt(0)
} else {
break
}
}

return null
}

fun findScreenStackInFirstDescendantChain(view: View): ScreenStack? {
var currentView: View? = view

while (currentView != null) {
if (currentView is ScreenStack) {
return currentView
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
currentView = currentView.getChildAt(0)
} else {
break
}
}

return null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ class TabScreen(
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}

var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true
var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true

private fun <T> updateMenuItemAttributesIfNeeded(
oldValue: T,
newValue: T,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,28 @@ class TabScreenViewManager :
view.tabTitle = value
}

@ReactProp(name = "specialEffects")
override fun setSpecialEffects(
view: TabScreen,
value: ReadableMap?,
) = Unit
) {
var scrollToTop = true
var popToRoot = true
if (value?.hasKey("repeatedTabSelection") ?: false) {
value.getMap("repeatedTabSelection")?.let { repeatedTabSelectionConfig ->
if (repeatedTabSelectionConfig.hasKey("scrollToTop")) {
scrollToTop =
repeatedTabSelectionConfig.getBoolean("scrollToTop")
}
if (repeatedTabSelectionConfig.hasKey("popToRoot")) {
popToRoot =
repeatedTabSelectionConfig.getBoolean("popToRoot")
}
}
}
view.shouldUseRepeatedTabSelectionPopToRootSpecialEffect = popToRoot
view.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = scrollToTop
}

override fun setOverrideScrollViewContentInsetAdjustmentBehavior(
view: TabScreen,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.swmansion.rnscreens.BuildConfig
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
import com.swmansion.rnscreens.gamma.helpers.ViewFinder
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
import com.swmansion.rnscreens.safearea.EdgeInsets
import com.swmansion.rnscreens.safearea.SafeAreaProvider
Expand Down Expand Up @@ -92,7 +93,31 @@ class TabsHost(
}
}

private inner class SpecialEffectsHandler {
// Handles the repeated tab selection special effects such as popToRoot and scrollToTop
// Returns true if any special effect was handled
fun handleRepeatedTabSelection(): Boolean {
val contentView = [email protected]
val selectedTabFragment = [email protected]
if (selectedTabFragment.tabScreen.shouldUseRepeatedTabSelectionPopToRootSpecialEffect) {
val screenStack = ViewFinder.findScreenStackInFirstDescendantChain(contentView)
if (screenStack != null && screenStack.popToRoot()) {
return true
}
}
if (selectedTabFragment.tabScreen.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect) {
val scrollView = ViewFinder.findScrollViewInFirstDescendantChain(contentView)
if (scrollView != null && scrollView.scrollY > 0) {
scrollView.smoothScrollTo(scrollView.scrollX, 0)
return true
}
}
return false
}
}

private val containerUpdateCoordinator = ContainerUpdateCoordinator()
private val specialEffectsHandler = SpecialEffectsHandler()

private val wrappedContext =
ContextThemeWrapper(
Expand Down Expand Up @@ -128,6 +153,9 @@ class TabsHost(

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

private 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
Expand Down Expand Up @@ -219,8 +247,10 @@ class TabsHost(
bottomNavigationView.setOnItemSelectedListener { item ->
RNSLog.d(TAG, "Item selected $item")
val fragment = getFragmentForMenuItemId(item.itemId)
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
eventEmitter.emitOnNativeFocusChange(tabKey)
if (fragment != currentFocusedTab || !specialEffectsHandler.handleRepeatedTabSelection()) {
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
eventEmitter.emitOnNativeFocusChange(tabKey)
}
true
}
}
Expand Down Expand Up @@ -327,8 +357,7 @@ class TabsHost(
}

private fun updateSelectedTab() {
val newFocusedTab =
checkNotNull(tabScreenFragments.find { it.tabScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }
val newFocusedTab = currentFocusedTab

check(requireFragmentManager.fragments.size <= 1) { "[RNScreens] There can be only a single focused tab" }
val oldFocusedTab = requireFragmentManager.fragments.firstOrNull()
Expand Down
19 changes: 12 additions & 7 deletions apps/src/tests/TestBottomTabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
ios: {
type: 'sfSymbol',
name: 'house.fill',
},
},
android: {
type: 'imageSource',
imageSource: require('../../../assets/variableIcons/icon_fill.png'),
}
},
},
selectedIcon: {
type: 'sfSymbol',
Expand Down Expand Up @@ -105,11 +105,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
ios: {
type: 'templateSource',
templateSource: require('../../../assets/variableIcons/icon.png'),
},
},
android: {
type: 'drawableResource',
name: 'sym_call_missed',
}
},
},
selectedIcon: {
type: 'templateSource',
Expand Down Expand Up @@ -148,7 +148,7 @@ const TAB_CONFIGS: TabConfiguration[] = [
shared: {
type: 'imageSource',
imageSource: require('../../../assets/variableIcons/icon.png'),
}
},
},
selectedIcon: {
type: 'imageSource',
Expand All @@ -171,8 +171,8 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
android: {
type: 'drawableResource',
name: 'custom_home_icon'
}
name: 'custom_home_icon',
},
},
selectedIcon: {
type: 'sfSymbol',
Expand All @@ -181,6 +181,11 @@ const TAB_CONFIGS: TabConfiguration[] = [
title: 'Tab4',
systemItem: 'search', // iOS specific
badgeValue: '123',
specialEffects: {
repeatedTabSelection: {
popToRoot: false,
},
},
},
component: Tab4,
},
Expand Down
4 changes: 2 additions & 2 deletions apps/src/tests/TestBottomTabs/tabs/Tab4.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const Stack = createNativeStackNavigator<RouteParamList>();

export function LongText() {
return (
<Text>
<Text style={{fontSize: 16}}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sed egestas
felis. Proin laoreet eros a tellus elementum, quis euismod enim gravida.
Morbi at arcu commodo, condimentum purus a, congue sapien. Nunc luctus
Expand Down Expand Up @@ -113,7 +113,7 @@ export function Tab4() {
<Stack.Screen
name="Screen1"
component={Screen1}
options={{ headerTransparent: true }}
options={{ headerTransparent: false }}
/>
<Stack.Screen
name="Screen2"
Expand Down
9 changes: 7 additions & 2 deletions src/components/bottom-tabs/BottomTabsScreen.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,12 +463,17 @@ export interface BottomTabsScreenProps {
* `popToRoot` has priority over `scrollToTop`.
*
* @default All special effects are enabled by default.
*
* @platform ios
*/
specialEffects?: {
repeatedTabSelection?: {
/**
* @default true
* @platform ios
*/
popToRoot?: boolean;
/**
* @default true
*/
scrollToTop?: boolean;
};
};
Expand Down
6 changes: 3 additions & 3 deletions src/gesture-handler/fabricUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

import { View } from "react-native";
import { View } from 'react-native';

/* eslint-disable */

Expand All @@ -24,11 +24,11 @@ export function getShadowNodeWrapperAndTagFromRef(ref: View | null): {
return {
shadowNodeWrapper: null,
tag: -1,
}
};
}
const internalRef = ref as unknown as HostInstance;
return {
shadowNodeWrapper: internalRef.__internalInstanceHandle.stateNode.node,
tag: internalRef.__nativeTag,
}
};
}
Loading