diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index eeed28509e..c0c3749ac7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +++ b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt @@ -7,6 +7,8 @@ import com.facebook.react.module.annotations.ReactModuleList import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager +import com.swmansion.rnscreens.utils.ScreenDummyLayoutHelper + @ReactModuleList( nativeModules = [ @@ -14,8 +16,20 @@ import com.facebook.react.uimanager.ViewManager ] ) class RNScreensPackage : TurboReactPackage() { - override fun createViewManagers(reactContext: ReactApplicationContext) = - listOf>( + // We just retain it here. This object helps us tackle jumping content when using native header. + // See: https://github.com/software-mansion/react-native-screens/pull/2169 + private var screenDummyLayoutHelper: ScreenDummyLayoutHelper? = null + + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + // This is the earliest we lay our hands on react context. + // Moreover this is called before FabricUIManger has finished initializing, not to mention + // installing its C++ bindings - so we are safe in terms of creating this helper + // before RN starts creating shadow nodes. + // See https://github.com/software-mansion/react-native-screens/pull/2169 + screenDummyLayoutHelper = ScreenDummyLayoutHelper(reactContext) + + return listOf>( ScreenContainerViewManager(), ScreenViewManager(), ModalScreenViewManager(), @@ -24,6 +38,7 @@ class RNScreensPackage : TurboReactPackage() { ScreenStackHeaderSubviewManager(), SearchBarManager() ) + } override fun getModule( s: String, @@ -51,4 +66,8 @@ class RNScreensPackage : TurboReactPackage() { moduleInfos } } + + companion object { + const val TAG = "RNScreensPackage" + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/Screen.kt b/android/src/main/java/com/swmansion/rnscreens/Screen.kt index 4eacaf0e4c..9a3b2c8394 100644 --- a/android/src/main/java/com/swmansion/rnscreens/Screen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/Screen.kt @@ -66,7 +66,8 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) { val height = b - t val headerHeight = calculateHeaderHeight() - val totalHeight = headerHeight.first + headerHeight.second // action bar height + status bar height + val totalHeight = + headerHeight.first + headerHeight.second // action bar height + status bar height if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { updateScreenSizeFabric(width, height, totalHeight) } else { @@ -171,7 +172,13 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) { ScreenWindowTraits.applyDidSetStatusBarAppearance() } field = statusBarStyle - fragmentWrapper?.let { ScreenWindowTraits.setStyle(this, it.tryGetActivity(), it.tryGetContext()) } + fragmentWrapper?.let { + ScreenWindowTraits.setStyle( + this, + it.tryGetActivity(), + it.tryGetContext() + ) + } } var isStatusBarHidden: Boolean? = null @@ -204,7 +211,13 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) { ScreenWindowTraits.applyDidSetStatusBarAppearance() } field = statusBarColor - fragmentWrapper?.let { ScreenWindowTraits.setColor(this, it.tryGetActivity(), it.tryGetContext()) } + fragmentWrapper?.let { + ScreenWindowTraits.setColor( + this, + it.tryGetActivity(), + it.tryGetContext() + ) + } } var navigationBarColor: Int? = null @@ -213,7 +226,12 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) { ScreenWindowTraits.applyDidSetNavigationBarAppearance() } field = navigationBarColor - fragmentWrapper?.let { ScreenWindowTraits.setNavigationBarColor(this, it.tryGetActivity()) } + fragmentWrapper?.let { + ScreenWindowTraits.setNavigationBarColor( + this, + it.tryGetActivity() + ) + } } var isNavigationBarTranslucent: Boolean? = null @@ -248,20 +266,23 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) { private fun calculateHeaderHeight(): Pair { val actionBarTv = TypedValue() - val resolvedActionBarSize = context.theme.resolveAttribute(android.R.attr.actionBarSize, actionBarTv, true) + val resolvedActionBarSize = + context.theme.resolveAttribute(android.R.attr.actionBarSize, actionBarTv, true) // Check if it's possible to get an attribute from theme context and assign a value from it. // Otherwise, the default value will be returned. - val actionBarHeight = TypedValue.complexToDimensionPixelSize(actionBarTv.data, resources.displayMetrics) - .takeIf { resolvedActionBarSize && headerConfig?.isHeaderHidden != true && headerConfig?.isHeaderTranslucent != true } - ?.let { PixelUtil.toDIPFromPixel(it.toFloat()).toDouble() } ?: 0.0 - - val statusBarHeight = context.resources.getIdentifier("status_bar_height", "dimen", "android") - // Count only status bar when action bar is visible and status bar is not hidden - .takeIf { it > 0 && isStatusBarHidden != true && actionBarHeight > 0 } - ?.let { (context.resources::getDimensionPixelSize)(it) } - ?.let { PixelUtil.toDIPFromPixel(it.toFloat()).toDouble() } - ?: 0.0 + val actionBarHeight = + TypedValue.complexToDimensionPixelSize(actionBarTv.data, resources.displayMetrics) + .takeIf { resolvedActionBarSize && headerConfig?.isHeaderHidden != true && headerConfig?.isHeaderTranslucent != true } + ?.let { PixelUtil.toDIPFromPixel(it.toFloat()).toDouble() } ?: 0.0 + + val statusBarHeight = + context.resources.getIdentifier("status_bar_height", "dimen", "android") + // Count only status bar when action bar is visible and status bar is not hidden + .takeIf { it > 0 && isStatusBarHidden != true && actionBarHeight > 0 } + ?.let { (context.resources::getDimensionPixelSize)(it) } + ?.let { PixelUtil.toDIPFromPixel(it.toFloat()).toDouble() } + ?: 0.0 return actionBarHeight to statusBarHeight } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt index 004cf98386..a806a01736 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt @@ -26,7 +26,7 @@ import com.swmansion.rnscreens.events.HeaderDetachedEvent class ScreenStackHeaderConfig(context: Context) : ViewGroup(context) { private val configSubviews = ArrayList(3) val toolbar: CustomToolbar - var isHeaderHidden = false // named this way to avoid conflict with platform's isHidden + var isHeaderHidden = false // named this way to avoid conflict with platform's isHidden var isHeaderTranslucent = false // named this way to avoid conflict with platform's isTranslucent private var headerTopInset: Int? = null private var title: String? = null @@ -210,7 +210,7 @@ class ScreenStackHeaderConfig(context: Context) : ViewGroup(context) { toolbar.contentInsetStartWithNavigation = 0 } - val titleTextView = titleTextView + val titleTextView = findTitleTextViewInToolbar(toolbar) if (titleColor != 0) { toolbar.setTitleTextColor(titleColor) } @@ -309,19 +309,6 @@ class ScreenStackHeaderConfig(context: Context) : ViewGroup(context) { maybeUpdate() } - private val titleTextView: TextView? - get() { - for (i in 0 until toolbar.childCount) { - val view = toolbar.getChildAt(i) - if (view is TextView) { - if (view.text == toolbar.title) { - return view - } - } - } - return null - } - fun setTitle(title: String?) { this.title = title } @@ -401,4 +388,18 @@ class ScreenStackHeaderConfig(context: Context) : ViewGroup(context) { } toolbar.clipChildren = false } + + companion object { + fun findTitleTextViewInToolbar(toolbar: Toolbar): TextView? { + for (i in 0 until toolbar.childCount) { + val view = toolbar.getChildAt(i) + if (view is TextView) { + if (view.text == toolbar.title) { + return view + } + } + } + return null + } + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt new file mode 100644 index 0000000000..908cae9854 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt @@ -0,0 +1,189 @@ +package com.swmansion.rnscreens.utils + +import android.app.Activity +import android.util.Log +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.PixelUtil +import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.ScreenStackHeaderConfig +import java.lang.ref.WeakReference + +/** + * This class provides methods to create dummy layout (that mimics Screen setup), and to compute + * expected header height. It is meant to be accessed from C++ layer via JNI. + * See https://github.com/software-mansion/react-native-screens/pull/2169 + * for more detailed description of the issue this code solves. + */ +internal class ScreenDummyLayoutHelper(reactContext: ReactApplicationContext) { + // The state required to compute header dimensions. We want this on instance rather than on class + // for context access & being tied to instance lifetime. + private lateinit var coordinatorLayout: CoordinatorLayout + private lateinit var appBarLayout: AppBarLayout + private lateinit var dummyContentView: View + private lateinit var toolbar: Toolbar + private var defaultFontSize: Float = 0f + private var defaultContentInsetStartWithNavigation: Int = 0 + + // LRU with size 1 + private var cache: CacheEntry = CacheEntry.EMPTY + + // We do not want to be responsible for the context lifecycle. If it's null, we're fine. + // This same context is being passed down to our view components so it is destroyed + // only if our views also are. + private var reactContextRef: WeakReference = WeakReference(reactContext) + + init { + + // We load the library so that we are able to communicate with our C++ code (descriptor & shadow nodes). + // Basically we leak this object to C++, as its lifecycle should span throughout whole application + // lifecycle anyway. + try { + System.loadLibrary(LIBRARY_NAME) + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "Failed to load $LIBRARY_NAME") + } + + WEAK_INSTANCE = WeakReference(this) + ensureDummyLayoutWithHeader(reactContext) + } + + /** + * Initializes dummy view hierarchy with CoordinatorLayout, AppBarLayout and dummy View. + * We utilize this to compute header height (app bar layout height) from C++ layer when its needed. + */ + private fun ensureDummyLayoutWithHeader(reactContext: ReactApplicationContext) { + if (::coordinatorLayout.isInitialized) { + return + } + + // We need to use activity here, as react context does not have theme attributes required by + // AppBarLayout attached leading to crash. + val contextWithTheme = + requireNotNull(reactContext.currentActivity) { "[RNScreens] Attempt to use context detached from activity" } + + coordinatorLayout = CoordinatorLayout(contextWithTheme) + + appBarLayout = AppBarLayout(contextWithTheme).apply { + layoutParams = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.WRAP_CONTENT, + ) + } + + toolbar = Toolbar(contextWithTheme).apply { + title = DEFAULT_HEADER_TITLE + layoutParams = AppBarLayout.LayoutParams( + AppBarLayout.LayoutParams.MATCH_PARENT, + AppBarLayout.LayoutParams.WRAP_CONTENT + ).apply { scrollFlags = 0 } + } + + // We know the title text view will be there, cause we've just set title. + defaultFontSize = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar)!!.textSize + defaultContentInsetStartWithNavigation = toolbar.contentInsetStartWithNavigation + + appBarLayout.addView(toolbar) + + dummyContentView = View(contextWithTheme).apply { + layoutParams = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ) + } + + coordinatorLayout.apply { + addView(appBarLayout) + addView(dummyContentView) + } + } + + /** + * Triggers layout pass on dummy view hierarchy, taking into consideration selected + * ScreenStackHeaderConfig props that might have impact on final header height. + * + * @param fontSize font size value as passed from JS + * @return header height in dp as consumed by Yoga + */ + private fun computeDummyLayout(fontSize: Int, isTitleEmpty: Boolean): Float { + if (!::coordinatorLayout.isInitialized) { + Log.e(TAG, "[RNScreens] Attempt to access dummy view hierarchy before it is initialized") + return 0.0f + } + + if (cache.hasKey(CacheKey(fontSize, isTitleEmpty))) { + return cache.headerHeight + } + + val topLevelDecorView = requireActivity().window.decorView + + // These dimensions are not accurate, as they do include status bar & navigation bar, however + // it is ok for our purposes. + val decorViewWidth = topLevelDecorView.width + val decorViewHeight = topLevelDecorView.height + + val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewWidth, View.MeasureSpec.EXACTLY) + val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewHeight, View.MeasureSpec.EXACTLY) + + if (isTitleEmpty) { + toolbar.title = "" + toolbar.contentInsetStartWithNavigation = 0 + } else { + toolbar.title = DEFAULT_HEADER_TITLE + toolbar.contentInsetStartWithNavigation = defaultContentInsetStartWithNavigation + } + + val textView = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar) + textView?.textSize = if (fontSize != FONT_SIZE_UNSET) fontSize.toFloat() else defaultFontSize + + coordinatorLayout.measure(widthMeasureSpec, heightMeasureSpec) + + // It seems that measure pass would be enough, however I'm not certain whether there are no + // scenarios when layout violates measured dimensions. + coordinatorLayout.layout(0, 0, decorViewWidth, decorViewHeight) + + val headerHeight = PixelUtil.toDIPFromPixel(appBarLayout.height.toFloat()) + cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight) + return headerHeight + } + + private fun requireReactContext(): ReactApplicationContext { + return requireNotNull(reactContextRef.get()) { "[RNScreens] Attempt to require missing react context" } + } + + private fun requireActivity(): Activity { + return requireNotNull(requireReactContext().currentActivity) { "[RNScreens] Attempt to use context detached from activity" } + } + + companion object { + const val TAG = "ScreenDummyLayoutHelper" + + const val LIBRARY_NAME = "react_codegen_rnscreens" + + const val FONT_SIZE_UNSET = -1 + + private const val DEFAULT_HEADER_TITLE: String = "FontSize123!#$" + + // We access this field from C++ layer, through `getInstance` method. + // We don't care what instance we get access to as long as it has initialized + // dummy view hierarchy. + private var WEAK_INSTANCE = WeakReference(null) + + @JvmStatic + fun getInstance(): ScreenDummyLayoutHelper? { + return WEAK_INSTANCE.get() + } + } +} + +private data class CacheKey(val fontSize: Int, val isTitleEmpty: Boolean) + +private class CacheEntry(val cacheKey: CacheKey, val headerHeight: Float) { + fun hasKey(key: CacheKey) = cacheKey.fontSize != Int.MIN_VALUE && cacheKey == key + + companion object { + val EMPTY = CacheEntry(CacheKey(Int.MIN_VALUE, false), 0f) + } +} diff --git a/android/src/main/jni/CMakeLists.txt b/android/src/main/jni/CMakeLists.txt index ec34a20be4..eaab6697af 100644 --- a/android/src/main/jni/CMakeLists.txt +++ b/android/src/main/jni/CMakeLists.txt @@ -6,6 +6,7 @@ set(LIB_TARGET_NAME react_codegen_${LIB_LITERAL}) set(LIB_ANDROID_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../..) set(LIB_COMMON_DIR ${LIB_ANDROID_DIR}/../common/cpp) +set(LIB_COMMON_COMPONENTS_DIR ${LIB_COMMON_DIR}/react/renderer/components/${LIB_LITERAL}) set(LIB_ANDROID_GENERATED_JNI_DIR ${LIB_ANDROID_DIR}/build/generated/source/codegen/jni) set(LIB_ANDROID_GENERATED_COMPONENTS_DIR ${LIB_ANDROID_GENERATED_JNI_DIR}/react/renderer/components/${LIB_LITERAL}) @@ -18,20 +19,20 @@ add_compile_options( -Wno-gnu-zero-variadic-macro-arguments ) -file(GLOB LIB_CUSTOM_SRCS CONFIGURE_DEPENDS *.cpp ${LIB_COMMON_DIR}/react/renderer/components/${LIB_LITERAL}/*.cpp) +file(GLOB LIB_CUSTOM_SRCS CONFIGURE_DEPENDS *.cpp ${LIB_COMMON_COMPONENTS_DIR}/*.cpp ${LIB_COMMON_COMPONENTS_DIR}/utils/*.cpp) file(GLOB LIB_CODEGEN_SRCS CONFIGURE_DEPENDS ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}/*.cpp) add_library( ${LIB_TARGET_NAME} - SHARED + SHARED ${LIB_CUSTOM_SRCS} ${LIB_CODEGEN_SRCS} ) target_include_directories( ${LIB_TARGET_NAME} - PUBLIC - . + PUBLIC + . ${LIB_COMMON_DIR} ${LIB_ANDROID_GENERATED_JNI_DIR} ${LIB_ANDROID_GENERATED_COMPONENTS_DIR} diff --git a/apps/test-examples/App.js b/apps/test-examples/App.js index 7a7646455a..ebac1c8435 100644 --- a/apps/test-examples/App.js +++ b/apps/test-examples/App.js @@ -103,6 +103,7 @@ import Test2069 from './src/Test2069'; import Test2118 from './src/Test2118'; import Test2184 from './src/Test2184'; import TestScreenAnimation from './src/TestScreenAnimation'; +import TestHeader from './src/TestHeader'; enableFreeze(true); diff --git a/apps/test-examples/src/TestHeader.tsx b/apps/test-examples/src/TestHeader.tsx new file mode 100644 index 0000000000..764be9cfc3 --- /dev/null +++ b/apps/test-examples/src/TestHeader.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { Button, View } from 'react-native'; +import { NavigationContainer, ParamListBase } from '@react-navigation/native'; +import { + createNativeStackNavigator, + NativeStackNavigationProp, +} from '@react-navigation/native-stack'; + +type Props = { + navigation: NativeStackNavigationProp; +}; + +const Stack = createNativeStackNavigator(); + +export default function App(): JSX.Element { + return ( + + + + + + + + ); +} + +const First = ({ navigation }: Props) => ( + + +