diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt index 73ac57da11..ca1dfe7a54 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt @@ -1,11 +1,15 @@ package com.swmansion.rnscreens.gamma.tabs import android.content.res.Configuration +import android.os.Build import android.view.Choreographer +import android.view.Gravity import android.view.MenuItem +import android.view.View +import android.view.WindowInsets import android.widget.FrameLayout -import android.widget.LinearLayout import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.view.children import androidx.fragment.app.FragmentManager import com.facebook.react.modules.core.ReactChoreographer import com.facebook.react.uimanager.ThemedReactContext @@ -13,13 +17,18 @@ 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.ViewIdGenerator +import com.swmansion.rnscreens.safearea.EdgeInsets +import com.swmansion.rnscreens.safearea.SafeAreaProvider +import com.swmansion.rnscreens.safearea.SafeAreaView import com.swmansion.rnscreens.utils.RNSLog import kotlin.properties.Delegates class TabsHost( val reactContext: ThemedReactContext, -) : LinearLayout(reactContext), - TabScreenDelegate { +) : FrameLayout(reactContext), + TabScreenDelegate, + SafeAreaProvider, + View.OnLayoutChangeListener { /** * All container updates should go through instance of this class. * The semantics are as follows: @@ -93,19 +102,21 @@ class TabsHost( private val bottomNavigationView: BottomNavigationView = BottomNavigationView(wrappedContext).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + layoutParams = + LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ) } private val contentView: FrameLayout = FrameLayout(reactContext).apply { layoutParams = - LinearLayout - .LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT, - ).apply { - weight = 1f - } + LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT, + ) id = ViewIdGenerator.generateViewId() } @@ -121,6 +132,8 @@ class TabsHost( private var isLayoutEnqueued: Boolean = false + private var interfaceInsetsChangeListener: SafeAreaView? = null + private val appearanceCoordinator = TabsHostAppearanceCoordinator(wrappedContext, bottomNavigationView, tabScreenFragments) @@ -193,7 +206,6 @@ class TabsHost( } init { - orientation = VERTICAL addView(contentView) addView(bottomNavigationView) @@ -418,12 +430,73 @@ class TabsHost( bottomNavigationView.menu.findItem(index) } + override fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView) { + if (interfaceInsetsChangeListener == null) { + bottomNavigationView.addOnLayoutChangeListener(this) + } + interfaceInsetsChangeListener = listener + } + + override fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView) { + if (interfaceInsetsChangeListener == listener) { + interfaceInsetsChangeListener = null + bottomNavigationView.removeOnLayoutChangeListener(this) + } + } + + override fun getInterfaceInsets(): EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, bottomNavigationView.height.toFloat()) + + override fun dispatchApplyWindowInsets(insets: WindowInsets?): WindowInsets? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return super.dispatchApplyWindowInsets(insets) + } + + // On Android versions prior to R, insets dispatch is broken. + // In order to mitigate this, we override dispatchApplyWindowInsets with + // correct implementation. To simplify it, we skip the call to TabsHost's + // onApplyWindowInsets. + if (insets?.isConsumed ?: true) { + return insets + } + + for (child in children) { + child.dispatchApplyWindowInsets(insets) + } + + return insets + } + internal fun onViewManagerAddEventEmitters() { // When this is called from View Manager the view tag is already set check(id != NO_ID) { "[RNScreens] TabsHost must have its tag set when registering event emitters" } eventEmitter = TabsHostEventEmitter(reactContext, id) } + override fun onLayoutChange( + view: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + require(view is BottomNavigationView) { + "[RNScreens] TabsHost's onLayoutChange expects BottomNavigationView, received $view instead" + } + + val oldHeight = oldBottom - oldTop + val newHeight = bottom - top + + if (newHeight != oldHeight) { + interfaceInsetsChangeListener?.apply { + this.onInterfaceInsetsChange(EdgeInsets(0.0f, 0.0f, 0.0f, newHeight.toFloat())) + } + } + } + companion object { const val TAG = "TabsHost" } diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/EdgeInsets.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/EdgeInsets.kt new file mode 100644 index 0000000000..aec565fd16 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/EdgeInsets.kt @@ -0,0 +1,35 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 +package com.swmansion.rnscreens.safearea + +import androidx.core.graphics.Insets +import kotlin.math.max + +data class EdgeInsets( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) { + companion object { + val ZERO: EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, 0.0f) + + fun fromInsets(insets: Insets) = + EdgeInsets( + insets.left.toFloat(), + insets.top.toFloat(), + insets.right.toFloat(), + insets.bottom.toFloat(), + ) + + fun max( + i1: EdgeInsets, + i2: EdgeInsets, + ) = EdgeInsets( + max(i1.left, i2.left), + max(i1.top, i2.top), + max(i1.right, i2.right), + max(i1.bottom, i2.bottom), + ) + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/InsetType.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/InsetType.kt new file mode 100644 index 0000000000..ed761df3ee --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/InsetType.kt @@ -0,0 +1,12 @@ +package com.swmansion.rnscreens.safearea + +enum class InsetType { + ALL, + SYSTEM, + INTERFACE, + ; + + fun containsSystem(): Boolean = this == ALL || this == SYSTEM + + fun containsInterface(): Boolean = this == ALL || this == INTERFACE +} diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaProvider.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaProvider.kt new file mode 100644 index 0000000000..b3c9e682b2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaProvider.kt @@ -0,0 +1,36 @@ +package com.swmansion.rnscreens.safearea + +/** + * Allows containers that obscure some part of its children views to provide safe area. + * + * This protocol only handles **interface insets** (e.g. toolbar, bottomNavigationView). + * System insets (e.g. `systemBars`, `displayCutout`) are handled through Android inset + * dispatch mechanism (via `onApplyWindowInsets`). + * + * Classes implementing this protocol are responsible for notifying `SafeAreaView`, which + * registers as a listener, about changes to **interface** safe area insets. + */ +interface SafeAreaProvider { + /** + * Responsible for registering **interface** safe area insets listener. + * + * @param listener `SafeAreaView` instance that wants to receive notifications + * about changes to **interface** safe area insets via `onInterfaceInsetsChange`. + */ + fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView) + + /** + * Responsible for unregistering **interface** safe area insets listener. + * + * @param listener `SafeAreaView` instance that wants to stop receiving notifications + * about changes to **interface** safe area insets. + */ + fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView) + + /** + * Responsible for providing current **interface** safe area insets. + * + * @returns `EdgeInsets` describing the current **interface** safe area insets. + */ + fun getInterfaceInsets(): EdgeInsets +} diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaView.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaView.kt index 17ec35a5fb..cb31893f68 100644 --- a/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaView.kt +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaView.kt @@ -1,13 +1,264 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 package com.swmansion.rnscreens.safearea import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.view.ViewTreeObserver +import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper.getReactContext +import com.facebook.react.uimanager.UIManagerModule import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.BuildConfig +import com.swmansion.rnscreens.safearea.paper.SafeAreaViewEdges +import com.swmansion.rnscreens.safearea.paper.SafeAreaViewLocalData +import java.lang.ref.WeakReference +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +private const val MAX_WAIT_TIME_NANO = 500000000L // 500ms @SuppressLint("ViewConstructor") // Should never be recreated class SafeAreaView( private val reactContext: ThemedReactContext, -) : ReactViewGroup(reactContext) { +) : ReactViewGroup(reactContext), + OnApplyWindowInsetsListener, + ViewTreeObserver.OnPreDrawListener { + private var provider = WeakReference(null) + private var currentInterfaceInsets: EdgeInsets = EdgeInsets.ZERO + private var currentSystemInsets: EdgeInsets = EdgeInsets.ZERO + private var needsInsetsUpdate = false + private var stateWrapper: StateWrapper? = null + private var edges: SafeAreaViewEdges? = null + private var insetType: InsetType = InsetType.ALL + + fun getStateWrapper(): StateWrapper? = stateWrapper + + fun setStateWrapper(stateWrapper: StateWrapper?) { + this.stateWrapper = stateWrapper + } + + init { + ViewCompat.setOnApplyWindowInsetsListener(this, this) + } + + override fun onAttachedToWindow() { + viewTreeObserver.addOnPreDrawListener(this) + + val newProvider = findAncestorProvider() + if (newProvider == null) { + super.onAttachedToWindow() + return + } + + newProvider.setOnInterfaceInsetsChangeListener(this) + provider = WeakReference(newProvider) + + currentInterfaceInsets = newProvider.getInterfaceInsets() + updateInsets() + + super.onAttachedToWindow() + } + + override fun onDetachedFromWindow() { + provider.get()?.removeOnInterfaceInsetsChangeListener(this) + + viewTreeObserver.removeOnPreDrawListener(this) + super.onDetachedFromWindow() + } + + private fun findAncestorProvider(): SafeAreaProvider? { + var providerCandidate = this.parent + + while (providerCandidate != null) { + if (providerCandidate is SafeAreaProvider) { + break + } + + providerCandidate = providerCandidate.parent + } + + return providerCandidate as? SafeAreaProvider + } + + fun onInterfaceInsetsChange(newInterfaceInsets: EdgeInsets) { + if (newInterfaceInsets != currentInterfaceInsets) { + currentInterfaceInsets = newInterfaceInsets + + if (insetType.containsInterface()) { + needsInsetsUpdate = true + } + } + } + + override fun onApplyWindowInsets( + view: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + val newSystemInsets = + insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + + if (newSystemInsets != currentSystemInsets) { + currentSystemInsets = EdgeInsets.fromInsets(newSystemInsets) + + if (insetType.containsSystem()) { + needsInsetsUpdate = true + } + } + + return WindowInsetsCompat + .Builder(insets) + .apply { + if (insetType.containsSystem()) { + setInsets( + WindowInsetsCompat.Type.systemBars(), + getConsumedInsetsFromSelectedEdges( + insets.getInsets( + WindowInsetsCompat.Type.systemBars(), + ), + ), + ) + setInsets( + WindowInsetsCompat.Type.displayCutout(), + getConsumedInsetsFromSelectedEdges( + insets.getInsets( + WindowInsetsCompat.Type.displayCutout(), + ), + ), + ) + } + }.build() + } + + private fun updateInsetsIfNeeded(): Boolean { + if (needsInsetsUpdate) { + needsInsetsUpdate = false + updateInsets() + return true + } + return false + } + + private fun updateInsets() { + val safeAreaInsets = + EdgeInsets.max( + if (insetType.containsInterface()) currentInterfaceInsets else EdgeInsets.ZERO, + if (insetType.containsSystem()) currentSystemInsets else EdgeInsets.ZERO, + ) + + val stateWrapper = getStateWrapper() + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED && stateWrapper != null) { + val insets = Arguments.createMap() + insets.putDouble("left", PixelUtil.toDIPFromPixel(safeAreaInsets.left).toDouble()) + insets.putDouble("top", PixelUtil.toDIPFromPixel(safeAreaInsets.top).toDouble()) + insets.putDouble("right", PixelUtil.toDIPFromPixel(safeAreaInsets.right).toDouble()) + insets.putDouble("bottom", PixelUtil.toDIPFromPixel(safeAreaInsets.bottom).toDouble()) + + val newState = Arguments.createMap() + newState.putMap("insets", insets) + + stateWrapper.updateState(newState) + } else if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + val localData = + SafeAreaViewLocalData( + insets = safeAreaInsets, + edges = edges ?: SafeAreaViewEdges.ZERO, + ) + val reactContext = getReactContext(this) + val uiManager = reactContext.getNativeModule(UIManagerModule::class.java) + if (uiManager != null) { + uiManager.setViewLocalData(id, localData) + // Sadly there doesn't seem to be a way to properly dirty a yoga node from java, so + // if we are in the middle of a layout, we need to recompute it. There is also no + // way to know whether we are in the middle of a layout so always do it. + reactContext.runOnNativeModulesQueueThread { + uiManager.uiImplementation.dispatchViewUpdates(-1) + } + waitForReactLayout() + } + } + } + + private fun waitForReactLayout() { + // Block the main thread until the native module thread is finished with + // its current tasks. To do this we use the done boolean as a lock and enqueue + // a task on the native modules thread. When the task runs we can unblock the + // main thread. This should be safe as long as the native modules thread + // does not block waiting on the main thread. + var done = false + val lock = ReentrantLock() + val condition = lock.newCondition() + val startTime = System.nanoTime() + var waitTime = 0L + getReactContext(this).runOnNativeModulesQueueThread { + lock.withLock { + if (!done) { + done = true + condition.signal() + } + } + } + lock.withLock { + while (!done && waitTime < MAX_WAIT_TIME_NANO) { + try { + condition.awaitNanos(MAX_WAIT_TIME_NANO) + } catch (ex: InterruptedException) { + // In case of an interrupt just give up waiting. + done = true + } + waitTime += System.nanoTime() - startTime + } + } + // Timed out waiting. + if (waitTime >= MAX_WAIT_TIME_NANO) { + Log.w(TAG, "Timed out waiting for layout.") + } + } + + private fun getConsumedInsetsFromSelectedEdges(insets: Insets): Insets = + Insets.of( + if (edges?.left ?: false) 0 else insets.left, + if (edges?.top ?: false) 0 else insets.top, + if (edges?.right ?: false) 0 else insets.right, + if (edges?.bottom ?: false) 0 else insets.bottom, + ) + + fun setEdges(edges: SafeAreaViewEdges) { + this.edges = edges + requestApplyInsets() + + // We don't want to call updateInsetsIfNeeded here because system insets don't arrive + // immediately after requestApplyInsets. We just set the flag to true to make sure the + // update is eventually executed. + needsInsetsUpdate = true + } + + fun setInsetType(insetType: InsetType) { + this.insetType = insetType + requestApplyInsets() + + // We don't want to call updateInsetsIfNeeded here because system insets don't arrive + // immediately after requestApplyInsets. We just set the flag to true to make sure the + // update is eventually executed, even if we set insetType to `INTERFACE`. + needsInsetsUpdate = true + } + + override fun onPreDraw(): Boolean { + val didUpdate = updateInsetsIfNeeded() + if (didUpdate) { + requestLayout() + } + return !didUpdate + } + companion object { const val TAG = "SafeAreaView" } diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaViewManager.kt index f00082a9da..751c1a9ea0 100644 --- a/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/SafeAreaViewManager.kt @@ -1,12 +1,20 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 package com.swmansion.rnscreens.safearea +import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.viewmanagers.RNSSafeAreaViewManagerDelegate import com.facebook.react.viewmanagers.RNSSafeAreaViewManagerInterface +import com.swmansion.rnscreens.safearea.paper.SafeAreaViewEdges +import com.swmansion.rnscreens.safearea.paper.SafeAreaViewShadowNode @ReactModule(name = SafeAreaViewManager.REACT_CLASS) class SafeAreaViewManager : @@ -18,10 +26,46 @@ class SafeAreaViewManager : override fun createViewInstance(reactContext: ThemedReactContext): SafeAreaView = SafeAreaView(reactContext) + override fun getDelegate() = delegate + + override fun createShadowNodeInstance() = SafeAreaViewShadowNode() + + override fun getShadowNodeClass() = SafeAreaViewShadowNode::class.java + + @ReactProp(name = "edges") override fun setEdges( view: SafeAreaView, value: ReadableMap?, - ): Unit = Unit + ) { + SafeAreaViewEdges.fromProp(value)?.let { + view.setEdges(it) + } + } + + @ReactProp(name = "insetType") + override fun setInsetType( + view: SafeAreaView, + value: String?, + ) { + val insetType = + when (value) { + null, "all" -> InsetType.ALL + "system" -> InsetType.SYSTEM + "interface" -> InsetType.INTERFACE + else -> throw JSApplicationIllegalArgumentException("Unknown inset type $value") + } + + view.setInsetType(insetType) + } + + override fun updateState( + view: SafeAreaView, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.setStateWrapper(stateWrapper) + return super.updateState(view, props, stateWrapper) + } companion object { const val REACT_CLASS = "RNSSafeAreaView" diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewEdges.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewEdges.kt new file mode 100644 index 0000000000..193110d948 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewEdges.kt @@ -0,0 +1,32 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 +package com.swmansion.rnscreens.safearea.paper + +import com.facebook.react.bridge.ReadableMap + +data class SafeAreaViewEdges( + val left: Boolean, + val top: Boolean, + val right: Boolean, + val bottom: Boolean, +) { + companion object { + val ZERO: SafeAreaViewEdges = + SafeAreaViewEdges( + left = false, + top = false, + right = false, + bottom = false, + ) + + fun fromProp(map: ReadableMap?): SafeAreaViewEdges? = + map?.let { + SafeAreaViewEdges( + left = map.getBoolean("left"), + top = map.getBoolean("top"), + right = map.getBoolean("right"), + bottom = map.getBoolean("bottom"), + ) + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewLocalData.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewLocalData.kt new file mode 100644 index 0000000000..ff052a7064 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewLocalData.kt @@ -0,0 +1,10 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 +package com.swmansion.rnscreens.safearea.paper + +import com.swmansion.rnscreens.safearea.EdgeInsets + +data class SafeAreaViewLocalData( + val insets: EdgeInsets, + val edges: SafeAreaViewEdges, +) diff --git a/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewShadowNode.kt b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewShadowNode.kt new file mode 100644 index 0000000000..06698605f4 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/safearea/paper/SafeAreaViewShadowNode.kt @@ -0,0 +1,124 @@ +// Implementation adapted from `react-native-safe-area-context`: +// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1 +package com.swmansion.rnscreens.safearea.paper + +import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.LayoutShadowNode +import com.facebook.react.uimanager.NativeViewHierarchyOptimizer +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.Spacing +import com.facebook.react.uimanager.ViewProps +import com.facebook.react.uimanager.annotations.ReactPropGroup + +class SafeAreaViewShadowNode : LayoutShadowNode() { + private var localData: SafeAreaViewLocalData? = null + private val margins: FloatArray = FloatArray(ViewProps.PADDING_MARGIN_SPACING_TYPES.size) + private var needsUpdate = false + + init { + for (i in ViewProps.PADDING_MARGIN_SPACING_TYPES.indices) { + margins[i] = Float.NaN + } + } + + private fun updateInsets() { + val localData = localData ?: return + var left = 0f + var top = 0f + var right = 0f + var bottom = 0f + val allEdges = margins[Spacing.ALL] + if (!java.lang.Float.isNaN(allEdges)) { + left = allEdges + top = allEdges + right = allEdges + bottom = allEdges + } + val verticalEdges = margins[Spacing.VERTICAL] + if (!java.lang.Float.isNaN(verticalEdges)) { + top = verticalEdges + bottom = verticalEdges + } + val horizontalEdges = margins[Spacing.HORIZONTAL] + if (!java.lang.Float.isNaN(horizontalEdges)) { + left = horizontalEdges + right = horizontalEdges + } + val leftEdge = margins[Spacing.LEFT] + if (!java.lang.Float.isNaN(leftEdge)) { + left = leftEdge + } + val topEdge = margins[Spacing.TOP] + if (!java.lang.Float.isNaN(topEdge)) { + top = topEdge + } + val rightEdge = margins[Spacing.RIGHT] + if (!java.lang.Float.isNaN(rightEdge)) { + right = rightEdge + } + val bottomEdge = margins[Spacing.BOTTOM] + if (!java.lang.Float.isNaN(bottomEdge)) { + bottom = bottomEdge + } + left = PixelUtil.toPixelFromDIP(left) + top = PixelUtil.toPixelFromDIP(top) + right = PixelUtil.toPixelFromDIP(right) + bottom = PixelUtil.toPixelFromDIP(bottom) + val edges = localData.edges + val insets = localData.insets + + super.setMargin(Spacing.LEFT, getEdgeValue(edges.left, insets.left, left)) + super.setMargin(Spacing.TOP, getEdgeValue(edges.top, insets.top, top)) + super.setMargin(Spacing.RIGHT, getEdgeValue(edges.right, insets.right, right)) + super.setMargin(Spacing.BOTTOM, getEdgeValue(edges.bottom, insets.bottom, bottom)) + } + + private fun getEdgeValue( + edgeMode: Boolean, + insetValue: Float, + edgeValue: Float, + ): Float = if (edgeMode) insetValue + edgeValue else edgeValue + + override fun onBeforeLayout(nativeViewHierarchyOptimizer: NativeViewHierarchyOptimizer) { + if (needsUpdate) { + needsUpdate = false + updateInsets() + } + } + + override fun setLocalData(data: Any) { + if (data !is SafeAreaViewLocalData) { + return + } + localData = data + needsUpdate = false + updateInsets() + } + + // Names needs to reflect exact order in LayoutShadowNode.java + @ReactPropGroup( + names = + [ + ViewProps.MARGIN, + ViewProps.MARGIN_VERTICAL, + ViewProps.MARGIN_HORIZONTAL, + ViewProps.MARGIN_START, + ViewProps.MARGIN_END, + ViewProps.MARGIN_TOP, + ViewProps.MARGIN_BOTTOM, + ViewProps.MARGIN_LEFT, + ViewProps.MARGIN_RIGHT, + ], + ) + override fun setMargins( + index: Int, + margin: Dynamic, + ) { + val spacingType = ViewProps.PADDING_MARGIN_SPACING_TYPES[index] + margins[spacingType] = + if (margin.type == ReadableType.Number) margin.asDouble().toFloat() else Float.NaN + super.setMargins(index, margin) + needsUpdate = true + } +} diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index 8e5766e965..5347241315 100644 --- a/android/src/main/jni/rnscreens.h +++ b/android/src/main/jni/rnscreens.h @@ -22,6 +22,7 @@ #include #include #include +#include namespace facebook { namespace react { diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerDelegate.java index 83cf397993..264cb8b6c3 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerDelegate.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerDelegate.java @@ -26,6 +26,9 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "edges": mViewManager.setEdges(view, (ReadableMap) value); break; + case "insetType": + mViewManager.setInsetType(view, (String) value); + break; default: super.setProperty(view, propName, value); } diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerInterface.java index 79a5773a41..55ca6cf06c 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerInterface.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSSafeAreaViewManagerInterface.java @@ -16,4 +16,5 @@ public interface RNSSafeAreaViewManagerInterface { void setEdges(T view, @Nullable ReadableMap value); + void setInsetType(T view, @Nullable String value); } diff --git a/common/cpp/react/renderer/components/rnscreens/RNSSafeAreaViewState.h b/common/cpp/react/renderer/components/rnscreens/RNSSafeAreaViewState.h index f80504c20b..9498acc23a 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSSafeAreaViewState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSSafeAreaViewState.h @@ -15,18 +15,6 @@ namespace facebook { namespace react { -#ifdef ANDROID -inline EdgeInsets edgeInsetsFromDynamic(const folly::dynamic &value) { - return EdgeInsets{ - .left = (float)value["left"].getDouble(), - .top = (float)value["top"].getDouble(), - .right = (float)value["right"].getDouble(), - .bottom = (float)value["bottom"].getDouble(), - }; -} - -#endif - /* * State for component. */ @@ -46,6 +34,14 @@ class JSI_EXPORT RNSSafeAreaViewState final { EdgeInsets insets{}; #ifdef ANDROID + inline EdgeInsets edgeInsetsFromDynamic(const folly::dynamic &value) { + return EdgeInsets{ + .left = (float)value["left"].getDouble(), + .top = (float)value["top"].getDouble(), + .right = (float)value["right"].getDouble(), + .bottom = (float)value["bottom"].getDouble(), + }; + } folly::dynamic getDynamic() const; MapBuffer getMapBuffer() const { return MapBufferBuilder::EMPTY(); diff --git a/react-native.config.js b/react-native.config.js index 33cb7f2908..1085029a14 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -14,7 +14,8 @@ module.exports = { "RNSScreenFooterComponentDescriptor", "RNSScreenContentWrapperComponentDescriptor", 'RNSModalScreenComponentDescriptor', - 'RNSBottomTabsComponentDescriptor' + 'RNSBottomTabsComponentDescriptor', + 'RNSSafeAreaViewComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" }, diff --git a/src/components/safe-area/SafeAreaView.types.ts b/src/components/safe-area/SafeAreaView.types.ts index 73e0b98e5e..1132dcab67 100644 --- a/src/components/safe-area/SafeAreaView.types.ts +++ b/src/components/safe-area/SafeAreaView.types.ts @@ -4,6 +4,11 @@ import { ViewProps } from 'react-native'; export type Edge = 'top' | 'right' | 'bottom' | 'left'; +// Android-only +export type InsetType = 'all' | 'system' | 'interface'; + export interface SafeAreaViewProps extends ViewProps { edges?: Readonly>>; + // Android-only + insetType?: InsetType; } diff --git a/src/fabric/safe-area/SafeAreaViewNativeComponent.ts b/src/fabric/safe-area/SafeAreaViewNativeComponent.ts index 2992f67276..6248102393 100644 --- a/src/fabric/safe-area/SafeAreaViewNativeComponent.ts +++ b/src/fabric/safe-area/SafeAreaViewNativeComponent.ts @@ -4,6 +4,9 @@ // eslint-disable-next-line @react-native/no-deep-imports import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; import { ViewProps } from 'react-native'; +import { WithDefault } from 'react-native/Libraries/Types/CodegenTypesNamespace'; + +type InsetType = 'all' | 'system' | 'interface'; export interface NativeProps extends ViewProps { edges?: Readonly<{ @@ -12,6 +15,8 @@ export interface NativeProps extends ViewProps { bottom: boolean; left: boolean; }>; + // Android-only + insetType?: WithDefault; } export default codegenNativeComponent('RNSSafeAreaView', {