|
| 1 | +package com.swmansion.rnscreens.utils |
| 2 | + |
| 3 | +import android.app.Activity |
| 4 | +import android.util.Log |
| 5 | +import android.view.View |
| 6 | +import androidx.appcompat.widget.Toolbar |
| 7 | +import androidx.coordinatorlayout.widget.CoordinatorLayout |
| 8 | +import com.facebook.react.bridge.ReactApplicationContext |
| 9 | +import com.facebook.react.uimanager.PixelUtil |
| 10 | +import com.google.android.material.appbar.AppBarLayout |
| 11 | +import com.swmansion.rnscreens.ScreenStackHeaderConfig |
| 12 | +import java.lang.ref.WeakReference |
| 13 | + |
| 14 | +/** |
| 15 | + * This class provides methods to create dummy layout (that mimics Screen setup), and to compute |
| 16 | + * expected header height. It is meant to be accessed from C++ layer via JNI. |
| 17 | + * See https://github.com/software-mansion/react-native-screens/pull/2169 |
| 18 | + * for more detailed description of the issue this code solves. |
| 19 | + */ |
| 20 | +internal class ScreenDummyLayoutHelper(reactContext: ReactApplicationContext) { |
| 21 | + // The state required to compute header dimensions. We want this on instance rather than on class |
| 22 | + // for context access & being tied to instance lifetime. |
| 23 | + private lateinit var coordinatorLayout: CoordinatorLayout |
| 24 | + private lateinit var appBarLayout: AppBarLayout |
| 25 | + private lateinit var dummyContentView: View |
| 26 | + private lateinit var toolbar: Toolbar |
| 27 | + private var defaultFontSize: Float = 0f |
| 28 | + private var defaultContentInsetStartWithNavigation: Int = 0 |
| 29 | + |
| 30 | + // LRU with size 1 |
| 31 | + private var cache: CacheEntry = CacheEntry.EMPTY |
| 32 | + |
| 33 | + // We do not want to be responsible for the context lifecycle. If it's null, we're fine. |
| 34 | + // This same context is being passed down to our view components so it is destroyed |
| 35 | + // only if our views also are. |
| 36 | + private var reactContextRef: WeakReference<ReactApplicationContext> = WeakReference(reactContext) |
| 37 | + |
| 38 | + init { |
| 39 | + |
| 40 | + // We load the library so that we are able to communicate with our C++ code (descriptor & shadow nodes). |
| 41 | + // Basically we leak this object to C++, as its lifecycle should span throughout whole application |
| 42 | + // lifecycle anyway. |
| 43 | + try { |
| 44 | + System.loadLibrary(LIBRARY_NAME) |
| 45 | + } catch (e: UnsatisfiedLinkError) { |
| 46 | + Log.w(TAG, "Failed to load $LIBRARY_NAME") |
| 47 | + } |
| 48 | + |
| 49 | + WEAK_INSTANCE = WeakReference(this) |
| 50 | + ensureDummyLayoutWithHeader(reactContext) |
| 51 | + } |
| 52 | + |
| 53 | + /** |
| 54 | + * Initializes dummy view hierarchy with CoordinatorLayout, AppBarLayout and dummy View. |
| 55 | + * We utilize this to compute header height (app bar layout height) from C++ layer when its needed. |
| 56 | + */ |
| 57 | + private fun ensureDummyLayoutWithHeader(reactContext: ReactApplicationContext) { |
| 58 | + if (::coordinatorLayout.isInitialized) { |
| 59 | + return |
| 60 | + } |
| 61 | + |
| 62 | + // We need to use activity here, as react context does not have theme attributes required by |
| 63 | + // AppBarLayout attached leading to crash. |
| 64 | + val contextWithTheme = |
| 65 | + requireNotNull(reactContext.currentActivity) { "[RNScreens] Attempt to use context detached from activity" } |
| 66 | + |
| 67 | + coordinatorLayout = CoordinatorLayout(contextWithTheme) |
| 68 | + |
| 69 | + appBarLayout = AppBarLayout(contextWithTheme).apply { |
| 70 | + layoutParams = CoordinatorLayout.LayoutParams( |
| 71 | + CoordinatorLayout.LayoutParams.MATCH_PARENT, |
| 72 | + CoordinatorLayout.LayoutParams.WRAP_CONTENT, |
| 73 | + ) |
| 74 | + } |
| 75 | + |
| 76 | + toolbar = Toolbar(contextWithTheme).apply { |
| 77 | + title = DEFAULT_HEADER_TITLE |
| 78 | + layoutParams = AppBarLayout.LayoutParams( |
| 79 | + AppBarLayout.LayoutParams.MATCH_PARENT, |
| 80 | + AppBarLayout.LayoutParams.WRAP_CONTENT |
| 81 | + ).apply { scrollFlags = 0 } |
| 82 | + } |
| 83 | + |
| 84 | + // We know the title text view will be there, cause we've just set title. |
| 85 | + defaultFontSize = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar)!!.textSize |
| 86 | + defaultContentInsetStartWithNavigation = toolbar.contentInsetStartWithNavigation |
| 87 | + |
| 88 | + appBarLayout.addView(toolbar) |
| 89 | + |
| 90 | + dummyContentView = View(contextWithTheme).apply { |
| 91 | + layoutParams = CoordinatorLayout.LayoutParams( |
| 92 | + CoordinatorLayout.LayoutParams.MATCH_PARENT, |
| 93 | + CoordinatorLayout.LayoutParams.MATCH_PARENT |
| 94 | + ) |
| 95 | + } |
| 96 | + |
| 97 | + coordinatorLayout.apply { |
| 98 | + addView(appBarLayout) |
| 99 | + addView(dummyContentView) |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + /** |
| 104 | + * Triggers layout pass on dummy view hierarchy, taking into consideration selected |
| 105 | + * ScreenStackHeaderConfig props that might have impact on final header height. |
| 106 | + * |
| 107 | + * @param fontSize font size value as passed from JS |
| 108 | + * @return header height in dp as consumed by Yoga |
| 109 | + */ |
| 110 | + private fun computeDummyLayout(fontSize: Int, isTitleEmpty: Boolean): Float { |
| 111 | + if (!::coordinatorLayout.isInitialized) { |
| 112 | + Log.e(TAG, "[RNScreens] Attempt to access dummy view hierarchy before it is initialized") |
| 113 | + return 0.0f |
| 114 | + } |
| 115 | + |
| 116 | + if (cache.hasKey(CacheKey(fontSize, isTitleEmpty))) { |
| 117 | + return cache.headerHeight |
| 118 | + } |
| 119 | + |
| 120 | + val topLevelDecorView = requireActivity().window.decorView |
| 121 | + |
| 122 | + // These dimensions are not accurate, as they do include status bar & navigation bar, however |
| 123 | + // it is ok for our purposes. |
| 124 | + val decorViewWidth = topLevelDecorView.width |
| 125 | + val decorViewHeight = topLevelDecorView.height |
| 126 | + |
| 127 | + val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewWidth, View.MeasureSpec.EXACTLY) |
| 128 | + val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewHeight, View.MeasureSpec.EXACTLY) |
| 129 | + |
| 130 | + if (isTitleEmpty) { |
| 131 | + toolbar.title = "" |
| 132 | + toolbar.contentInsetStartWithNavigation = 0 |
| 133 | + } else { |
| 134 | + toolbar.title = DEFAULT_HEADER_TITLE |
| 135 | + toolbar.contentInsetStartWithNavigation = defaultContentInsetStartWithNavigation |
| 136 | + } |
| 137 | + |
| 138 | + val textView = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar) |
| 139 | + textView?.textSize = if (fontSize != FONT_SIZE_UNSET) fontSize.toFloat() else defaultFontSize |
| 140 | + |
| 141 | + coordinatorLayout.measure(widthMeasureSpec, heightMeasureSpec) |
| 142 | + |
| 143 | + // It seems that measure pass would be enough, however I'm not certain whether there are no |
| 144 | + // scenarios when layout violates measured dimensions. |
| 145 | + coordinatorLayout.layout(0, 0, decorViewWidth, decorViewHeight) |
| 146 | + |
| 147 | + val headerHeight = PixelUtil.toDIPFromPixel(appBarLayout.height.toFloat()) |
| 148 | + cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight) |
| 149 | + return headerHeight |
| 150 | + } |
| 151 | + |
| 152 | + private fun requireReactContext(): ReactApplicationContext { |
| 153 | + return requireNotNull(reactContextRef.get()) { "[RNScreens] Attempt to require missing react context" } |
| 154 | + } |
| 155 | + |
| 156 | + private fun requireActivity(): Activity { |
| 157 | + return requireNotNull(requireReactContext().currentActivity) { "[RNScreens] Attempt to use context detached from activity" } |
| 158 | + } |
| 159 | + |
| 160 | + companion object { |
| 161 | + const val TAG = "ScreenDummyLayoutHelper" |
| 162 | + |
| 163 | + const val LIBRARY_NAME = "react_codegen_rnscreens" |
| 164 | + |
| 165 | + const val FONT_SIZE_UNSET = -1 |
| 166 | + |
| 167 | + private const val DEFAULT_HEADER_TITLE: String = "FontSize123!#$" |
| 168 | + |
| 169 | + // We access this field from C++ layer, through `getInstance` method. |
| 170 | + // We don't care what instance we get access to as long as it has initialized |
| 171 | + // dummy view hierarchy. |
| 172 | + private var WEAK_INSTANCE = WeakReference<ScreenDummyLayoutHelper>(null) |
| 173 | + |
| 174 | + @JvmStatic |
| 175 | + fun getInstance(): ScreenDummyLayoutHelper? { |
| 176 | + return WEAK_INSTANCE.get() |
| 177 | + } |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +private data class CacheKey(val fontSize: Int, val isTitleEmpty: Boolean) |
| 182 | + |
| 183 | +private class CacheEntry(val cacheKey: CacheKey, val headerHeight: Float) { |
| 184 | + fun hasKey(key: CacheKey) = cacheKey.fontSize != Int.MIN_VALUE && cacheKey == key |
| 185 | + |
| 186 | + companion object { |
| 187 | + val EMPTY = CacheEntry(CacheKey(Int.MIN_VALUE, false), 0f) |
| 188 | + } |
| 189 | +} |
0 commit comments