Skip to content

Commit

Permalink
fix(Android, Fabric): jumping content with native header (#2169)
Browse files Browse the repository at this point in the history
## Description

This PR intents to solve the *eternal* issue with "jumping content" on
Android & Fabric. The issue itself is present on every platform / RN
architecture combination, however this PR scope is to solve situation
only on Android + Fabric. Android + Paper, and iOS will be solved in
separate PRs.

> [!note]
> These videos are recorded with `topInsetEnabled: false`, as this prop
implementation causes another series of issues that will be handled
separately.

Here is before & after comparison (best way to see is to go
frame-by-frame)

| Before | After |
|--------|--------|
| <video width="320" height="240" controls
src="https://github.com/software-mansion/react-native-screens/assets/50801299/e1e995b5-885b-4bd4-941a-57cdc6b321b2"></video>
| <video width="320" height="240" controls
src="https://github.com/software-mansion/react-native-screens/assets/50801299/6ca87888-0f05-4dfc-b6db-bfd08e3735b3"></video>
|

This will even work with irregular font sizes!

### Short issue genesis

> [!note]
> The flow described here below is a simplification, but should give you
a good grasp on the issue.

Basically during the very first Yoga layout, that happens on secondary
thread, React layout mechanism has no knowledge of the header size
(there isn't even a node representing the header at appropriate
tree-level present in shadow tree), thus the `Screen` content is
layouted with more available space that it has in reality. These
dimensions are then send to UI thread, and propagated bottom-up
(children before parents) and `Screen` contents do receive too high
frame. Then, when `ScreenContainer` / `ScreenStack` does receive its
frame from RN, it triggers a fragmentary pass of native layout using
`CoordinatorLayout` (the layout stops at `Screen`), offsetting the
`Screen` by just-computed-header-height on Y axis, and in consequence
pushing some of the `Screen`'s contents off the screen (exactly a strip
of header height). The situation is then salvaged by sending state
update from `Screen` to React Native with new frame size.

### Implemented solution

During the first Yoga layout pass (when there is not state from UI
thread received yet) we utilise the fact that RN clones our ShadowNode &
calls `adapt` on it. In the adapt method we call into JVM where we have
set up a dummy view hierarchy with coordinator layout & header, we
layout it & return result to C++ layer where we set bottom padding on
the Screen so that its contents do have right amount of space.

> [!important]
> Downside of this solution is the fact, that the Yoga state / Shadow
Tree still indicates that the `Screen`'s origin is at `(x, y) = (0, 0)`
and it still will have wrong dimensions.
Setting dummy dimension on `HeaderConfig` shadow node will improve
situation only slightly, as the `Screen` will still have wrong origin,
but it will have appropriate size immediately, **hence `Screen`'s state
update might not trigger follow-up transaction**. Thus I'm thinking now
that I will update the solution.

### Yet ~un~tested approaches

* ~I want to try making custom descriptor for `ScreenStack`, and try to
customise shadownode's layout method.~ <- tested this & I believe this
will not work due to the fact, that `ShadowNode.layout` does not use
`layoutContext.viewportOffset` at all (so we can not use this to offset
our descendants). At the same time the `layout` method does not
propagate layout metrics - they are extracted for each shadow node
directly from it's yoga node and this process does not take into
consideration parent's layout metrics. **However, if the `x, y` view
origin coordinates determined by yoga are in parent node coordinate
system** we can use `HeaderConfig` to ensure appropriate `Screens` size
and at the same time set `frame.y` manually!


## Test code and steps to reproduce

I've added `TestHeader` test case. It's best to run it with
`FabricTestExample`, record it, and then see frame-by-frame that the
content no longer jumps.

## Checklist

- [x] Included code example that can be used to test this change
- [ ] Ensured that CI passes
  • Loading branch information
kkafar authored Jun 18, 2024
1 parent 9a24f20 commit 84f8f6d
Show file tree
Hide file tree
Showing 14 changed files with 697 additions and 38 deletions.
23 changes: 21 additions & 2 deletions android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,29 @@ 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 = [
ScreensModule::class
]
)
class RNScreensPackage : TurboReactPackage() {
override fun createViewManagers(reactContext: ReactApplicationContext) =
listOf<ViewManager<*, *>>(
// 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<ViewManager<*, *>> {
// 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<ViewManager<*, *>>(
ScreenContainerViewManager(),
ScreenViewManager(),
ModalScreenViewManager(),
Expand All @@ -24,6 +38,7 @@ class RNScreensPackage : TurboReactPackage() {
ScreenStackHeaderSubviewManager(),
SearchBarManager()
)
}

override fun getModule(
s: String,
Expand Down Expand Up @@ -51,4 +66,8 @@ class RNScreensPackage : TurboReactPackage() {
moduleInfos
}
}

companion object {
const val TAG = "RNScreensPackage"
}
}
51 changes: 36 additions & 15 deletions android/src/main/java/com/swmansion/rnscreens/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -248,20 +266,23 @@ class Screen(context: ReactContext?) : FabricEnabledViewGroup(context) {

private fun calculateHeaderHeight(): Pair<Double, Double> {
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import com.swmansion.rnscreens.events.HeaderDetachedEvent
class ScreenStackHeaderConfig(context: Context) : ViewGroup(context) {
private val configSubviews = ArrayList<ScreenStackHeaderSubview>(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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ReactApplicationContext> = 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<ScreenDummyLayoutHelper>(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)
}
}
Loading

0 comments on commit 84f8f6d

Please sign in to comment.