chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,80 @@
cmake_minimum_required(VERSION 3.9.0)
project(rnscreens)
if(${RNS_NEW_ARCH_ENABLED})
add_library(rnscreens
SHARED
../cpp/RNScreensTurboModule.cpp
../cpp/RNSScreenRemovalListener.cpp
./src/main/cpp/jni-adapter.cpp
./src/main/cpp/NativeProxy.cpp
./src/main/cpp/OnLoad.cpp
)
else()
add_library(rnscreens
SHARED
../cpp/RNScreensTurboModule.cpp
./src/main/cpp/jni-adapter.cpp
)
endif()
include_directories(
../cpp
)
set_target_properties(rnscreens PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
CXX_EXTENSIONS OFF
POSITION_INDEPENDENT_CODE ON
)
target_compile_definitions(
rnscreens
PRIVATE
-DFOLLY_NO_CONFIG=1
)
find_package(ReactAndroid REQUIRED CONFIG)
if(${RNS_NEW_ARCH_ENABLED})
find_package(fbjni REQUIRED CONFIG)
if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76)
target_link_libraries(rnscreens
ReactAndroid::reactnative
ReactAndroid::jsi
fbjni::fbjni
android
)
else()
target_link_libraries(
rnscreens
ReactAndroid::jsi
ReactAndroid::react_nativemodule_core
ReactAndroid::react_utils
ReactAndroid::reactnativejni
ReactAndroid::fabricjni
ReactAndroid::react_debug
ReactAndroid::react_render_core
ReactAndroid::runtimeexecutor
ReactAndroid::react_render_graphics
ReactAndroid::rrc_view
ReactAndroid::yoga
ReactAndroid::rrc_text
ReactAndroid::glog
ReactAndroid::react_render_componentregistry
ReactAndroid::react_render_consistency
ReactAndroid::react_performance_timeline
ReactAndroid::react_render_observers_events
fbjni::fbjni
android
)
endif()
else()
target_link_libraries(rnscreens
ReactAndroid::jsi
android
)
endif()
+260
View File
@@ -0,0 +1,260 @@
import com.android.Version
import groovy.json.JsonSlurper
buildscript {
ext {
rnsDefaultTargetSdkVersion = 34
rnsDefaultCompileSdkVersion = 34
rnsDefaultMinSdkVersion = 21
rnsDefaultKotlinVersion = '1.8.0'
}
ext.safeExtGet = {prop, fallback ->
def props = (prop instanceof String) ? [prop] : prop
def result = props.find { key ->
return rootProject.ext.has(key)
}
return result ? rootProject.ext.get(result) : fallback
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle:8.2.1')
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', rnsDefaultKotlinVersion)}"
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.25.0"
}
}
def isRunningInContextOfScreensRepo() {
return project == rootProject
}
def isNewArchitectureEnabled() {
// To opt-in for the New Architecture, you can either:
// - Set `newArchEnabled` to true inside the `gradle.properties` file
// - Invoke gradle with `-newArchEnabled=true`
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
def areDebugLogsEnabled() {
return project.hasProperty("rnsDebugLogsEnabled") && project.rnsDebugLogsEnabled == "true"
}
def resolveReactNativeDirectory() {
def userDefinedRnDirPath = safeAppExtGet("REACT_NATIVE_NODE_MODULES_DIR", null)
if (userDefinedRnDirPath != null) {
return file(userDefinedRnDirPath)
}
File standardRnDirFile = file("$rootDir/../node_modules/react-native/")
if (standardRnDirFile.exists()) {
return standardRnDirFile
}
// This is legacy code, I'm not sure why it works in certain scenarios but it was reported that one of our
// projects needs this.
File legacyRnDirFile = file("$projectDir/../node_modules/react-native/")
if (legacyRnDirFile.exists()) {
return legacyRnDirFile
}
// We're in non standard setup, e.g. monorepo - try to use node resolver to locate the react-native package.
String maybeRnPackagePath = providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('react-native/package.json')")
}.standardOutput.asText.get().trim()
File nodeResolverRnDirFile = null
// file() constructor fails in case string is null or blank
if (maybeRnPackagePath != null && !maybeRnPackagePath.isBlank()) {
File maybeRnPackageFile = file(maybeRnPackagePath)
if (maybeRnPackageFile.exists()) {
nodeResolverRnDirFile = maybeRnPackageFile.parentFile
return nodeResolverRnDirFile
}
}
throw new Exception("[RNScreens] Failed to resolve react-native directory. " +
"Attempted locations: ${standardRnDirFile}, ${legacyRnDirFile} and ${nodeResolverRnDirFile}. " +
"You should set project extension property (in `app/build.gradle`) `REACT_NATIVE_NODE_MODULES_DIR` with path to react-native.")
}
// spotless is only accessible within react-native-screens repo
if (isRunningInContextOfScreensRepo()) {
apply from: 'spotless.gradle'
}
if (isNewArchitectureEnabled()) {
apply plugin: "com.facebook.react"
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
def safeAppExtGet(prop, fallback) {
def appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') }
appProject?.ext?.has(prop) ? appProject.ext.get(prop) : fallback
}
def reactNativeRootDir = resolveReactNativeDirectory()
def reactProperties = new Properties()
file("$reactNativeRootDir/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) }
def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")
def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger()
def IS_NEW_ARCHITECTURE_ENABLED = isNewArchitectureEnabled()
android {
compileSdkVersion safeExtGet('compileSdkVersion', rnsDefaultCompileSdkVersion)
namespace "com.swmansion.rnscreens"
// Used to override the NDK path/version on internal CI or by allowing
// users to customize the NDK path/version from their root project (e.g. for M1 support)
if (rootProject.hasProperty("ndkPath")) {
ndkPath rootProject.ext.ndkPath
}
if (rootProject.hasProperty("ndkVersion")) {
ndkVersion rootProject.ext.ndkVersion
}
defaultConfig {
minSdkVersion safeExtGet(['minSdkVersion', 'minSdk'], rnsDefaultMinSdkVersion)
targetSdkVersion safeExtGet(['targetSdkVersion', 'targetSdk'], rnsDefaultTargetSdkVersion)
versionCode 1
versionName "1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", IS_NEW_ARCHITECTURE_ENABLED.toString()
buildConfigField "boolean", "RNS_DEBUG_LOGGING", areDebugLogsEnabled().toString()
ndk {
abiFilters (*reactNativeArchitectures())
}
externalNativeBuild {
cmake {
arguments "-DANDROID_STL=c++_shared",
"-DRNS_NEW_ARCH_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}",
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}
buildFeatures {
prefab true
buildConfig true
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
packagingOptions {
// For some reason gradle only complains about the duplicated version of libreact_render libraries
// while there are more libraries copied in intermediates folder of the lib build directory, we exclude
// only the ones that make the build fail (ideally we should only include librnscreens_modules but we
// are only allowed to specify exclude patterns)
excludes = [
"META-INF",
"META-INF/**",
"**/libjsi.so",
"**/libc++_shared.so",
"**/libreact_render*.so",
"**/libreactnativejni.so",
"**/libreact_performance_timeline.so",
// In 0.76 multiple react-native's libraries were merged and these are the main new artifacts we're using.
// Some of above lib* names could be removed after we remove support for 0.76.
// https://github.com/facebook/react-native/pull/43909
// https://github.com/facebook/react-native/pull/46059
"**/libfbjni.so",
"**/libreactnative.so"
]
}
sourceSets.main {
ext.androidResDir = "src/main/res"
java {
// Architecture specific
if (IS_NEW_ARCHITECTURE_ENABLED) {
srcDirs += [
"src/fabric/java",
]
} else {
srcDirs += [
"src/paper/java",
]
}
// Background color resolving
if (REACT_NATIVE_MINOR_VERSION <= 74) {
srcDirs += "src/versioned/backgroundcolor/74"
} else if (REACT_NATIVE_MINOR_VERSION <= 76) {
srcDirs += "src/versioned/backgroundcolor/76"
} else {
srcDirs += "src/versioned/backgroundcolor/latest"
}
// Native only classes that use PointerEvents
if (REACT_NATIVE_MINOR_VERSION <= 77) {
srcDirs += "src/versioned/pointerevents/77"
} else {
srcDirs += "src/versioned/pointerevents/latest"
}
// Edge-to-edge detection
if (REACT_NATIVE_MINOR_VERSION <= 80) {
srcDirs += "src/versioned/edge-to-edge/80"
} else {
srcDirs += "src/versioned/edge-to-edge/latest"
}
}
res {
if (safeExtGet(['compileSdkVersion', 'compileSdk'], rnsDefaultCompileSdkVersion) >= 33) {
srcDirs = ["${androidResDir}/base", "${androidResDir}/v33"]
} else {
srcDirs = ["${androidResDir}/base"]
}
}
}
}
repositories {
maven {
url "${reactNativeRootDir}/android"
}
mavenCentral()
mavenLocal()
google()
}
dependencies {
implementation 'com.facebook.react:react-native:+'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.google.android.material:material:1.12.0'
implementation "androidx.core:core-ktx:1.8.0"
def COIL_VERSION = "3.0.4"
implementation("io.coil-kt.coil3:coil:${COIL_VERSION}")
implementation("io.coil-kt.coil3:coil-network-okhttp:${COIL_VERSION}")
implementation("io.coil-kt.coil3:coil-svg:${COIL_VERSION}")
constraints {
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") {
because("on older React Native versions this dependency conflicts with react-native-screens")
}
}
}
@@ -0,0 +1,77 @@
package com.swmansion.rnscreens
import android.content.Context
import android.view.ViewGroup
import androidx.annotation.UiThread
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.StateWrapper
import kotlin.math.abs
abstract class FabricEnabledHeaderConfigViewGroup(
context: Context?,
) : ViewGroup(context) {
private var mStateWrapper: StateWrapper? = null
private var lastWidth = 0f
private var lastHeight = 0f
private var lastPaddingStart = 0f
private var lastPaddingEnd = 0f
fun setStateWrapper(wrapper: StateWrapper?) {
mStateWrapper = wrapper
}
fun updateHeaderConfigState(
width: Int,
height: Int,
paddingStart: Int,
paddingEnd: Int,
) {
// Implementation of this method differs between Fabric & Paper!
updateState(width, height, paddingStart, paddingEnd)
}
// Implementation of this method differs between Fabric & Paper!
@UiThread
private fun updateState(
width: Int,
height: Int,
paddingStart: Int,
paddingEnd: Int,
) {
val realWidth: Float = PixelUtil.toDIPFromPixel(width.toFloat())
val realHeight: Float = PixelUtil.toDIPFromPixel(height.toFloat())
val realPaddingStart: Float = PixelUtil.toDIPFromPixel(paddingStart.toFloat())
val realPaddingEnd: Float = PixelUtil.toDIPFromPixel(paddingEnd.toFloat())
// Check incoming state values. If they're already the correct value, return early to prevent
// infinite UpdateState/SetState loop.
if (abs(lastWidth - realWidth) < DELTA &&
abs(lastHeight - realHeight) < DELTA &&
abs(lastPaddingStart - realPaddingStart) < DELTA &&
abs(lastPaddingEnd - realPaddingEnd) < DELTA
) {
return
}
lastWidth = realWidth
lastHeight = realHeight
lastPaddingStart = realPaddingStart
lastPaddingEnd = realPaddingEnd
val map: WritableMap =
WritableNativeMap().apply {
putDouble("frameWidth", realWidth.toDouble())
putDouble("frameHeight", realHeight.toDouble())
putDouble("paddingStart", realPaddingStart.toDouble())
putDouble("paddingEnd", realPaddingEnd.toDouble())
}
mStateWrapper?.updateState(map)
}
companion object {
private const val DELTA = 0.9f
}
}
@@ -0,0 +1,76 @@
package com.swmansion.rnscreens
import android.content.Context
import android.view.ViewGroup
import androidx.annotation.UiThread
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.StateWrapper
import kotlin.math.abs
abstract class FabricEnabledHeaderSubviewViewGroup(
context: Context?,
) : ViewGroup(context) {
private var mStateWrapper: StateWrapper? = null
private var lastWidth = 0f
private var lastHeight = 0f
private var lastOffsetX = 0f
private var lastOffsetY = 0f
fun setStateWrapper(wrapper: StateWrapper?) {
mStateWrapper = wrapper
}
protected fun updateSubviewFrameState(
width: Int,
height: Int,
offsetX: Int,
offsetY: Int,
) {
updateState(width, height, offsetX, offsetY)
}
@UiThread
fun updateState(
width: Int,
height: Int,
offsetX: Int,
offsetY: Int,
) {
val realWidth: Float = PixelUtil.toDIPFromPixel(width.toFloat())
val realHeight: Float = PixelUtil.toDIPFromPixel(height.toFloat())
val offsetXDip: Float = PixelUtil.toDIPFromPixel(offsetX.toFloat())
val offsetYDip: Float = PixelUtil.toDIPFromPixel(offsetY.toFloat())
// Check incoming state values. If they're already the correct value, return early to prevent
// infinite UpdateState/SetState loop.
if (abs(lastWidth - realWidth) < DELTA &&
abs(lastHeight - realHeight) < DELTA &&
abs(lastOffsetX - offsetXDip) < DELTA &&
abs(lastOffsetY - offsetYDip) < DELTA
) {
return
}
lastWidth = realWidth
lastHeight = realHeight
lastOffsetX = offsetXDip
lastOffsetY = offsetYDip
val map: WritableMap =
WritableNativeMap().apply {
putDouble("frameWidth", realWidth.toDouble())
putDouble("frameHeight", realHeight.toDouble())
putDouble("contentOffsetX", offsetXDip.toDouble())
putDouble("contentOffsetY", offsetYDip.toDouble())
}
mStateWrapper?.updateState(map)
}
companion object {
private const val DELTA = 0.9f
}
}
@@ -0,0 +1,65 @@
package com.swmansion.rnscreens
import android.view.ViewGroup
import androidx.annotation.UiThread
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.StateWrapper
import kotlin.math.abs
abstract class FabricEnabledViewGroup(
context: ReactContext?,
) : ViewGroup(context) {
private var mStateWrapper: StateWrapper? = null
private var lastWidth = 0f
private var lastHeight = 0f
private var lastHeaderHeight = 0f
fun setStateWrapper(wrapper: StateWrapper?) {
mStateWrapper = wrapper
}
protected fun updateScreenSizeFabric(
width: Int,
height: Int,
headerHeight: Int,
) {
updateState(width, height, headerHeight)
}
@UiThread
fun updateState(
width: Int,
height: Int,
headerHeight: Int,
) {
val realWidth: Float = PixelUtil.toDIPFromPixel(width.toFloat())
val realHeight: Float = PixelUtil.toDIPFromPixel(height.toFloat())
val realHeaderHeight: Float = PixelUtil.toDIPFromPixel(headerHeight.toFloat())
// Check incoming state values. If they're already the correct value, return early to prevent
// infinite UpdateState/SetState loop.
val delta = 0.9f
if (abs(lastWidth - realWidth) < delta &&
abs(lastHeight - realHeight) < delta &&
abs(lastHeaderHeight - realHeaderHeight) < delta
) {
return
}
lastWidth = realWidth
lastHeight = realHeight
lastHeaderHeight = realHeaderHeight
val map: WritableMap =
WritableNativeMap().apply {
putDouble("frameWidth", realWidth.toDouble())
putDouble("frameHeight", realHeight.toDouble())
putDouble("contentOffsetX", 0.0)
putDouble("contentOffsetY", realHeaderHeight.toDouble())
}
mStateWrapper?.updateState(map)
}
}
@@ -0,0 +1,69 @@
package com.swmansion.rnscreens
import android.util.Log
import com.facebook.jni.HybridData
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.fabric.FabricUIManager
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
class NativeProxy {
@DoNotStrip
@Suppress("unused")
private val mHybridData: HybridData
init {
mHybridData = initHybrid()
}
private external fun initHybrid(): HybridData
external fun nativeAddMutationsListener(fabricUIManager: FabricUIManager)
external fun invalidateNative()
companion object {
// we use ConcurrentHashMap here since it will be read on the JS thread,
// and written to on the UI thread.
private val viewsMap = ConcurrentHashMap<Int, WeakReference<Screen>>()
fun addScreenToMap(
tag: Int,
view: Screen,
) {
viewsMap[tag] = WeakReference(view)
}
fun removeScreenFromMap(tag: Int) {
viewsMap.remove(tag)
}
fun clearMapOnInvalidate() {
viewsMap.clear()
}
}
// Called from native. Currently this method is called from MountingCoordinator thread,
// which usually is not UI thread.
@DoNotStrip
public fun notifyScreenRemoved(screenTag: Int) {
// Since RN 0.78 the screenTag we receive as argument here might not belong to a screen
// owned by native stack, but e.g. to one parented by plain ScreenContainer, for which we
// currently do not want to start exiting transitions. Therefore is it left to caller to
// ensure that NativeProxy.viewsMap is filled only with screens belonging to screen stacks.
val weakScreeRef = viewsMap[screenTag]
// `screenTag` belongs to not observed screen or screen with such tag no longer exists.
if (weakScreeRef == null) {
return
}
val screen = weakScreeRef.get()
if (screen is Screen) {
screen.startRemovalTransition()
} else {
Log.w("[RNScreens]", "Reference stored in NativeProxy for tag $screenTag no longer points to valid object.")
}
}
}
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,54 @@
#include <fbjni/fbjni.h>
#include <react/fabric/Binding.h>
#include <react/renderer/scheduler/Scheduler.h>
#include <string>
#include "NativeProxy.h"
using namespace facebook;
using namespace react;
namespace rnscreens {
NativeProxy::NativeProxy(jni::alias_ref<NativeProxy::javaobject> jThis)
: javaPart_(jni::make_global(jThis)) {}
void NativeProxy::registerNatives() {
registerHybrid(
{makeNativeMethod("initHybrid", NativeProxy::initHybrid),
makeNativeMethod(
"nativeAddMutationsListener",
NativeProxy::nativeAddMutationsListener),
makeNativeMethod("invalidateNative", NativeProxy::invalidateNative)});
}
void NativeProxy::nativeAddMutationsListener(
jni::alias_ref<facebook::react::JFabricUIManager::javaobject>
fabricUIManager) {
auto uiManager =
fabricUIManager->getBinding()->getScheduler()->getUIManager();
screenRemovalListener_ =
std::make_shared<RNSScreenRemovalListener>([this](int tag) {
static const auto method =
javaPart_->getClass()->getMethod<void(jint)>("notifyScreenRemoved");
method(javaPart_, tag);
});
uiManager->getShadowTreeRegistry().enumerate(
[this](const facebook::react::ShadowTree &shadowTree, bool &stop) {
shadowTree.getMountingCoordinator()->setMountingOverrideDelegate(
screenRemovalListener_);
});
}
jni::local_ref<NativeProxy::jhybriddata> NativeProxy::initHybrid(
jni::alias_ref<jhybridobject> jThis) {
return makeCxxInstance(jThis);
}
void NativeProxy::invalidateNative() {
javaPart_ = nullptr;
}
} // namespace rnscreens
@@ -0,0 +1,35 @@
#pragma once
#include <fbjni/fbjni.h>
#include <react/fabric/JFabricUIManager.h>
#include "RNSScreenRemovalListener.h"
#include <string>
namespace rnscreens {
using namespace facebook;
using namespace facebook::jni;
class NativeProxy : public jni::HybridClass<NativeProxy> {
public:
std::shared_ptr<RNSScreenRemovalListener> screenRemovalListener_;
static auto constexpr kJavaDescriptor =
"Lcom/swmansion/rnscreens/NativeProxy;";
static jni::local_ref<jhybriddata> initHybrid(
jni::alias_ref<jhybridobject> jThis);
static void registerNatives();
private:
friend HybridBase;
jni::global_ref<NativeProxy::javaobject> javaPart_;
explicit NativeProxy(jni::alias_ref<NativeProxy::javaobject> jThis);
void nativeAddMutationsListener(
jni::alias_ref<facebook::react::JFabricUIManager::javaobject>
fabricUIManager);
void invalidateNative();
};
} // namespace rnscreens
@@ -0,0 +1,8 @@
#include <fbjni/fbjni.h>
#include "NativeProxy.h"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
return facebook::jni::initialize(
vm, [] { rnscreens::NativeProxy::registerNatives(); });
}
@@ -0,0 +1,116 @@
#include <jni.h>
#include <jsi/jsi.h>
#include <array>
#include <mutex>
#include "RNScreensTurboModule.h"
using namespace facebook;
jobject globalThis;
extern "C" JNIEXPORT void JNICALL
Java_com_swmansion_rnscreens_ScreensModule_nativeInstall(
JNIEnv *env,
jobject thiz,
jlong jsiPtr) {
auto runtime = reinterpret_cast<jsi::Runtime *>(jsiPtr);
if (!runtime) {
return;
}
jsi::Runtime &rt = *runtime;
if (globalThis) {
env->DeleteGlobalRef(globalThis);
}
globalThis = env->NewGlobalRef(thiz);
JavaVM *jvm;
env->GetJavaVM(&jvm);
const auto &startTransition = [jvm](int stackTag) -> std::array<int, 2> {
JNIEnv *currentEnv;
if (jvm->AttachCurrentThread(&currentEnv, nullptr) != JNI_OK) {
return {0, 0};
}
jclass javaClass = currentEnv->GetObjectClass(globalThis);
jmethodID methodID = currentEnv->GetMethodID(
javaClass, "startTransition", "(Ljava/lang/Integer;)[I");
jclass integerClass = currentEnv->FindClass("java/lang/Integer");
jmethodID integerConstructor =
currentEnv->GetMethodID(integerClass, "<init>", "(I)V");
jobject integerArg =
currentEnv->NewObject(integerClass, integerConstructor, stackTag);
jintArray resultArray = (jintArray)currentEnv->CallObjectMethod(
globalThis, methodID, integerArg);
std::array<int, 2> result = {-1, -1};
jint *elements = currentEnv->GetIntArrayElements(resultArray, nullptr);
if (elements != nullptr) {
result[0] = elements[0];
result[1] = elements[1];
currentEnv->ReleaseIntArrayElements(resultArray, elements, JNI_ABORT);
}
return result;
};
const auto &updateTransition = [jvm](int stackTag, double progress) {
JNIEnv *currentEnv;
if (jvm->AttachCurrentThread(&currentEnv, nullptr) != JNI_OK) {
return;
}
jclass javaClass = currentEnv->GetObjectClass(globalThis);
jmethodID methodID =
currentEnv->GetMethodID(javaClass, "updateTransition", "(D)V");
currentEnv->CallVoidMethod(globalThis, methodID, progress);
};
const auto &finishTransition = [jvm](int stackTag, bool canceled) {
JNIEnv *currentEnv;
if (jvm->AttachCurrentThread(&currentEnv, nullptr) != JNI_OK) {
return;
}
jclass javaClass = currentEnv->GetObjectClass(globalThis);
jmethodID methodID = currentEnv->GetMethodID(
javaClass, "finishTransition", "(Ljava/lang/Integer;Z)V");
jclass integerClass = currentEnv->FindClass("java/lang/Integer");
jmethodID integerConstructor =
currentEnv->GetMethodID(integerClass, "<init>", "(I)V");
jobject integerArg =
currentEnv->NewObject(integerClass, integerConstructor, stackTag);
currentEnv->CallVoidMethod(globalThis, methodID, integerArg, canceled);
};
const auto &disableSwipeBackForTopScreen = [](int _stackTag) {
// no implementation for Android
};
auto rnScreensModule = std::make_shared<RNScreens::RNScreensTurboModule>(
startTransition,
updateTransition,
finishTransition,
disableSwipeBackForTopScreen);
auto rnScreensModuleHostObject =
jsi::Object::createFromHostObject(rt, rnScreensModule);
rt.global().setProperty(
rt,
RNScreens::RNScreensTurboModule::MODULE_NAME,
std::move(rnScreensModuleHostObject));
}
extern "C" JNIEXPORT void JNICALL
Java_com_swmansion_rnscreens_ScreensModule_nativeUninstall(
JNIEnv *env,
jobject thiz) {
if (globalThis != nullptr) {
env->DeleteGlobalRef(globalThis);
globalThis = nullptr;
}
}
void JNICALL JNI_OnUnload(JavaVM *jvm, void *) {
JNIEnv *env;
if (jvm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return;
}
if (globalThis != nullptr) {
env->DeleteGlobalRef(globalThis);
globalThis = nullptr;
}
}
@@ -0,0 +1,87 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.content.Context
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
class CustomSearchView(
context: Context,
fragment: Fragment,
) : SearchView(context) {
/*
CustomSearchView uses some variables from SearchView. They are listed below with links to documentation
isIconified - https://developer.android.com/reference/android/widget/SearchView#setIconified(boolean)
maxWidth - https://developer.android.com/reference/android/widget/SearchView#setMaxWidth(int)
setOnSearchClickListener - https://developer.android.com/reference/android/widget/SearchView#setOnSearchClickListener(android.view.View.OnClickListener)
setOnCloseListener - https://developer.android.com/reference/android/widget/SearchView#setOnCloseListener(android.widget.SearchView.OnCloseListener)
*/
private var onCloseListener: OnCloseListener? = null
private var onSearchClickedListener: OnClickListener? = null
private var onBackPressedCallback: OnBackPressedCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
isIconified = true
}
}
private val backPressOverrider = FragmentBackPressOverrider(fragment, onBackPressedCallback)
var overrideBackAction: Boolean
set(value) {
backPressOverrider.overrideBackAction = value
}
get() = backPressOverrider.overrideBackAction
fun focus() {
isIconified = false
requestFocusFromTouch()
}
fun clearText() = setQuery("", false)
fun setText(text: String) = setQuery(text, false)
fun cancelSearch() {
clearText()
setIconified(true)
}
override fun setOnCloseListener(listener: OnCloseListener?) {
onCloseListener = listener
}
override fun setOnSearchClickListener(listener: OnClickListener?) {
onSearchClickedListener = listener
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isIconified) {
backPressOverrider.maybeAddBackCallback()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
backPressOverrider.removeBackCallbackIfAdded()
}
init {
super.setOnSearchClickListener { v ->
onSearchClickedListener?.onClick(v)
backPressOverrider.maybeAddBackCallback()
}
super.setOnCloseListener {
val result = onCloseListener?.onClose() ?: false
backPressOverrider.removeBackCallbackIfAdded()
result
}
maxWidth = Integer.MAX_VALUE
}
}
@@ -0,0 +1,181 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.view.Choreographer
import android.view.WindowInsets
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.modules.core.ReactChoreographer
import com.facebook.react.uimanager.ThemedReactContext
import com.swmansion.rnscreens.utils.InsetsCompat
import com.swmansion.rnscreens.utils.resolveInsetsOrZero
import kotlin.math.max
/**
* Main toolbar class representing the native header.
*
* This class is used to store config closer to search bar.
* It also handles inset/padding related logic in coordination with header config.
*/
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
open class CustomToolbar(
context: Context,
val config: ScreenStackHeaderConfig,
) : Toolbar(context) {
// Switch this flag to enable/disable display cutout avoidance.
// Currently this is controlled by isTopInsetEnabled prop.
private val shouldAvoidDisplayCutout
get() = config.isTopInsetEnabled
private val shouldApplyTopInset
get() = config.isTopInsetEnabled
private var lastInsets = InsetsCompat.NONE
private var isForceShadowStateUpdateOnLayoutRequested = false
private var isLayoutEnqueued = false
private val layoutCallback: Choreographer.FrameCallback =
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
isLayoutEnqueued = false
// The following measure specs are selected to work only with Android APIs <= 29.
// See https://github.com/software-mansion/react-native-screens/pull/2439
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST),
)
layout(left, top, right, bottom)
}
}
override fun requestLayout() {
super.requestLayout()
val softInputMode =
(context as ThemedReactContext)
.currentActivity
?.window
?.attributes
?.softInputMode
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && softInputMode == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) {
// Below Android API 29, layout is not being requested when subviews are being added to the layout,
// leading to having their subviews in position 0,0 of the toolbar (as Android don't calculate
// the position of each subview, even if Yoga has correctly set their width and height).
// This is mostly the issue, when windowSoftInputMode is set to adjustPan in AndroidManifest.
// Thus, we're manually calling the layout **after** the current layout.
@Suppress("SENSELESS_COMPARISON") // mLayoutCallback can be null here since this method can be called in init
if (!isLayoutEnqueued && layoutCallback != null) {
isLayoutEnqueued = true
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
ReactChoreographer
.getInstance()
.postFrameCallback(
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
layoutCallback,
)
}
}
}
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
val unhandledInsets = super.onApplyWindowInsets(insets)
// There are few UI modes we could be running in
//
// 1. legacy non edge-to-edge mode,
// 2. edge-to-edge with gesture navigation,
// 3. edge-to-edge with translucent navigation buttons bar.
//
// Additionally we need to gracefully handle possible display cutouts.
// We use rootWindowInsets in lieu of insets or unhandledInsets here,
// because cutout sometimes (only in certain scenarios, e.g. with headerLeft view present)
// happen to be Insets.ZERO and is not reliable.
val rootWindowInsets = rootWindowInsets
val cutoutInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.displayCutout(), rootWindowInsets)
val systemBarInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars(), rootWindowInsets)
val statusBarInsetsStable =
resolveInsetsOrZero(
WindowInsetsCompat.Type.systemBars(),
rootWindowInsets,
ignoreVisibility = true,
)
// This seems to work fine in all tested configurations, because cutout & system bars overlap
// only in portrait mode & top inset is controlled separately, therefore we don't count
// any insets twice.
val horizontalInsets =
InsetsCompat.of(
cutoutInsets.left + systemBarInsets.left,
0,
cutoutInsets.right + systemBarInsets.right,
0,
)
// We want to handle display cutout always, no matter the HeaderConfig prop values.
// If there are no cutout displays, we want to apply the additional padding to
// respect the status bar.
val verticalInsets =
InsetsCompat.of(
0,
max(cutoutInsets.top, if (shouldApplyTopInset) statusBarInsetsStable.top else 0),
0,
max(cutoutInsets.bottom, 0),
)
val newInsets = InsetsCompat.add(horizontalInsets, verticalInsets)
if (lastInsets != newInsets) {
lastInsets = newInsets
applyExactPadding(
lastInsets.left,
lastInsets.top,
lastInsets.right,
lastInsets.bottom,
)
}
return unhandledInsets
}
override fun onLayout(
hasSizeChanged: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
super.onLayout(hasSizeChanged, l, t, r, b)
config.onNativeToolbarLayout(
this,
hasSizeChanged || isForceShadowStateUpdateOnLayoutRequested,
)
isForceShadowStateUpdateOnLayoutRequested = false
}
fun updateContentInsets() {
contentInsetStartWithNavigation = config.preferredContentInsetStartWithNavigation
setContentInsetsRelative(config.preferredContentInsetStart, config.preferredContentInsetEnd)
}
private fun applyExactPadding(
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
requestForceShadowStateUpdateOnLayout()
setPadding(left, top, right, bottom)
}
private fun requestForceShadowStateUpdateOnLayout() {
isForceShadowStateUpdateOnLayoutRequested = shouldAvoidDisplayCutout
}
}
@@ -0,0 +1,29 @@
package com.swmansion.rnscreens
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
class FragmentBackPressOverrider(
private val fragment: Fragment,
private val onBackPressedCallback: OnBackPressedCallback,
) {
private var isCallbackAdded: Boolean = false
var overrideBackAction: Boolean = true
fun maybeAddBackCallback() {
if (!isCallbackAdded && overrideBackAction) {
fragment.activity?.onBackPressedDispatcher?.addCallback(
fragment,
onBackPressedCallback,
)
isCallbackAdded = true
}
}
fun removeBackCallbackIfAdded() {
if (isCallbackAdded) {
onBackPressedCallback.remove()
isCallbackAdded = false
}
}
}
@@ -0,0 +1,7 @@
package com.swmansion.rnscreens
import androidx.fragment.app.Fragment
interface FragmentHolder {
val fragment: Fragment
}
@@ -0,0 +1,104 @@
package com.swmansion.rnscreens
import android.util.Log
import android.view.View
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactApplicationContext
import java.lang.ref.WeakReference
object InsetsObserverProxy : OnApplyWindowInsetsListener, LifecycleEventListener {
private val listeners: HashSet<OnApplyWindowInsetsListener> = hashSetOf()
private var eventSourceView: WeakReference<View> = WeakReference(null)
// Please note semantics of this property. This is not `isRegistered`, because somebody, could unregister
// us, without our knowledge, e.g. reanimated or different 3rd party library. This holds only information
// whether this observer has been initially registered.
private var hasBeenRegistered: Boolean = false
// Mainly debug variable to log warnings in case we missed some code path regarding
// context lifetime handling.
private var isObservingContextLifetime: Boolean = false
private var shouldForwardInsetsToView = true
// Allow only when we have not been registered yet or the view we're observing has been
// invalidated due to some lifecycle we have not observed.
private val allowRegistration get() = !hasBeenRegistered || eventSourceView.get() == null
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat,
): WindowInsetsCompat {
var rollingInsets =
if (shouldForwardInsetsToView) {
ViewCompat.onApplyWindowInsets(v, insets)
} else {
insets
}
listeners.forEach {
rollingInsets = it.onApplyWindowInsets(v, insets)
}
return rollingInsets
}
// Call this method to ensure that the observer proxy is
// unregistered when apps is destroyed or we change activity.
fun registerWithContext(context: ReactApplicationContext) {
if (isObservingContextLifetime) {
Log.w(
"[RNScreens]",
"InsetObserverProxy registers on new context while it has not been invalidated on the old one. Please report this as issue at https://github.com/software-mansion/react-native-screens/issues",
)
}
isObservingContextLifetime = true
context.addLifecycleEventListener(this)
}
override fun onHostResume() = Unit
override fun onHostPause() = Unit
override fun onHostDestroy() {
val observedView = getObservedView()
if (hasBeenRegistered && observedView != null) {
ViewCompat.setOnApplyWindowInsetsListener(observedView, null)
hasBeenRegistered = false
eventSourceView.clear()
}
isObservingContextLifetime = false
}
fun addOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) {
listeners.add(listener)
}
fun removeOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) {
listeners.remove(listener)
}
/**
* @return boolean whether the proxy registered as a listener on a view
*/
fun registerOnView(view: View): Boolean {
if (allowRegistration) {
ViewCompat.setOnApplyWindowInsetsListener(view, this)
eventSourceView = WeakReference(view)
hasBeenRegistered = true
return true
}
return false
}
fun unregister() {
getObservedView()?.takeIf { hasBeenRegistered }?.let {
ViewCompat.setOnApplyWindowInsetsListener(it, null)
}
}
private fun getObservedView(): View? = eventSourceView.get()
}
@@ -0,0 +1,12 @@
package com.swmansion.rnscreens
import com.facebook.react.module.annotations.ReactModule
@ReactModule(name = ModalScreenViewManager.REACT_CLASS)
class ModalScreenViewManager : ScreenViewManager() {
override fun getName() = REACT_CLASS
companion object {
const val REACT_CLASS = "RNSModalScreen"
}
}
@@ -0,0 +1,85 @@
package com.swmansion.rnscreens
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
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.gamma.tabs.TabScreenViewManager
import com.swmansion.rnscreens.gamma.tabs.TabsHostViewManager
import com.swmansion.rnscreens.utils.ScreenDummyLayoutHelper
// Fool autolinking for older versions that do not support BaseReactPackage.
// public class RNScreensPackage implements TurboReactPackage {
@ReactModuleList(
nativeModules = [
ScreensModule::class,
],
)
class RNScreensPackage : BaseReactPackage() {
// 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
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
screenDummyLayoutHelper = ScreenDummyLayoutHelper(reactContext)
}
// Proxy needs to register for lifecycle events in order to unregister itself
// on activity restarts.
InsetsObserverProxy.registerWithContext(reactContext)
return listOf<ViewManager<*, *>>(
ScreenContainerViewManager(),
ScreenViewManager(),
ModalScreenViewManager(),
ScreenStackViewManager(),
ScreenStackHeaderConfigViewManager(),
ScreenStackHeaderSubviewManager(),
SearchBarManager(),
ScreenFooterManager(),
ScreenContentWrapperManager(),
TabsHostViewManager(),
TabScreenViewManager(),
)
}
override fun getModule(
s: String,
reactApplicationContext: ReactApplicationContext,
): NativeModule? {
when (s) {
ScreensModule.NAME -> return ScreensModule(reactApplicationContext)
}
return null
}
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
ReactModuleInfoProvider {
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
val isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
moduleInfos[ScreensModule.NAME] =
ReactModuleInfo(
ScreensModule.NAME,
ScreensModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
true, // hasConstants
false, // isCxxModule
isTurboModule,
)
moduleInfos
}
companion object {
const val TAG = "RNScreensPackage"
}
}
@@ -0,0 +1,656 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.graphics.Paint
import android.os.Parcelable
import android.util.SparseArray
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebView
import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.facebook.react.bridge.GuardedRunnable
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.UIManagerModule
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents
import com.swmansion.rnscreens.bottomsheet.useSingleDetent
import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
import com.swmansion.rnscreens.events.HeaderHeightChangeEvent
import com.swmansion.rnscreens.events.SheetDetentChangedEvent
import com.swmansion.rnscreens.ext.asScreenStackFragment
import com.swmansion.rnscreens.ext.parentAsViewGroup
import com.swmansion.rnscreens.gamma.common.FragmentProviding
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
class Screen(
val reactContext: ThemedReactContext,
) : FabricEnabledViewGroup(reactContext),
ScreenContentWrapper.OnLayoutCallback,
FragmentProviding {
val fragment: Fragment?
get() = fragmentWrapper?.fragment
val sheetBehavior: BottomSheetBehavior<Screen>?
get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<Screen>
val reactEventDispatcher: EventDispatcher?
get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
var fragmentWrapper: ScreenFragmentWrapper? = null
var container: ScreenContainer? = null
var activityState: ActivityState? = null
private set
private var isTransitioning = false
var stackPresentation = StackPresentation.PUSH
var replaceAnimation = ReplaceAnimation.POP
var stackAnimation = StackAnimation.DEFAULT
var isGestureEnabled = true
var screenOrientation: Int? = null
private set
var screenId: String? = null
var isStatusBarAnimated: Boolean? = null
var isBeingRemoved = false
// Props for controlling modal presentation
var isSheetGrabberVisible: Boolean = false
// corner radius must be updated after all props prop updates from a single transaction
// have been applied, because it depends on the presentation type.
private var shouldUpdateSheetCornerRadius = false
var sheetCornerRadius: Float = 0F
set(value) {
if (field != value) {
field = value
shouldUpdateSheetCornerRadius = true
}
}
var sheetExpandsWhenScrolledToEdge: Boolean = true
// We want to make sure here that at least one value is present in this array all the time.
// TODO: Model this with custom data structure to guarantee that this invariant is not violated.
var sheetDetents = mutableListOf(1.0)
var sheetLargestUndimmedDetentIndex: Int = -1
var sheetInitialDetentIndex: Int = 0
var sheetClosesOnTouchOutside = true
var sheetElevation: Float = 24F
/**
* When using form sheet presentation we want to delay enter transition **on Paper** in order
* to wait for initial layout from React, otherwise the animator-based animation will look
* glitchy. *This is not needed on Fabric*.
*/
var shouldTriggerPostponedTransitionAfterLayout = false
var footer: ScreenFooter? = null
set(value) {
if (value == null && field != null) {
sheetBehavior?.let { field!!.unregisterWithSheetBehavior(it) }
} else if (value != null) {
sheetBehavior?.let { value.registerWithSheetBehavior(it) }
}
field = value
}
private val isNativeStackScreen: Boolean
get() = container is ScreenStack
init {
// we set layout params as WindowManager.LayoutParams to workaround the issue with TextInputs
// not displaying modal menus (e.g., copy/paste or selection). The missing menus are due to the
// fact that TextView implementation is expected to be attached to window when layout happens.
// Then, at the moment of layout it checks whether window type is in a reasonable range to tell
// whether it should enable selection controls (see Editor.java#prepareCursorControllers).
// With screens, however, the text input component can be laid out before it is attached, in
// that case TextView tries to get window type property from the oldest existing parent, which
// in this case is a Screen class, as it is the root of the screen that is about to be attached.
// Setting params this way is not the most elegant way to solve this problem but workarounds it
// for the time being
layoutParams = WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION)
}
override fun getAssociatedFragment(): Fragment? = fragment
/**
* ScreenContentWrapper notifies us here on it's layout. It is essential for implementing
* `fitToContents` for formSheets, as this is first entry point where we can acquire
* height of our content.
*/
override fun onContentWrapperLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
val height = bottom - top
if (usesFormSheetPresentation()) {
if (isSheetFitToContents()) {
sheetBehavior?.useSingleDetent(height)
}
if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// On old architecture we delay enter transition in order to wait for initial frame.
shouldTriggerPostponedTransitionAfterLayout = true
val parent = parentAsViewGroup()
if (parent != null && !parent.isInLayout) {
// There are reported cases (irreproducible) when Screen is not laid out after
// maxHeight is set on behaviour.
parent.requestLayout()
}
}
}
}
fun registerLayoutCallbackForWrapper(wrapper: ScreenContentWrapper) {
wrapper.delegate = this
}
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) {
// do nothing, react native will keep the view hierarchy so no need to serialize/deserialize
// view's states. The side effect of restoring is that TextInput components would trigger
// set-text events which may confuse text input handling.
}
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) {
// ignore restoring instance state too as we are not saving anything anyways.
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
// In case of form sheet we get layout notification a bit later, in `onBottomSheetBehaviorDidLayout`
// after the attached behaviour laid out this view.
if (changed && isNativeStackScreen && !usesFormSheetPresentation()) {
val width = r - l
val height = b - t
dispatchShadowStateUpdate(width, height, t)
// FormSheet has no header in current model.
notifyHeaderHeightChange(t)
}
}
internal fun onBottomSheetBehaviorDidLayout(coordinatorLayoutDidChange: Boolean) {
if (!usesFormSheetPresentation() || !isNativeStackScreen) {
return
}
if (coordinatorLayoutDidChange) {
dispatchShadowStateUpdate(width, height, top)
}
footer?.onParentLayout(coordinatorLayoutDidChange, left, top, right, bottom, container!!.height)
if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// When using form sheet presentation we want to delay enter transition **on Paper** in order
// to wait for initial layout from React, otherwise the animator-based animation will look
// glitchy. *This seems to not be needed on Fabric*.
triggerPostponedEnterTransitionIfNeeded()
}
}
private fun triggerPostponedEnterTransitionIfNeeded() {
if (shouldTriggerPostponedTransitionAfterLayout) {
shouldTriggerPostponedTransitionAfterLayout = false
// This will trigger enter transition only if one was requested by ScreenStack
fragment?.startPostponedEnterTransition()
}
}
private fun updateScreenSizePaper(
width: Int,
height: Int,
) {
reactContext.runOnNativeModulesQueueThread(
object : GuardedRunnable(reactContext.exceptionHandler) {
override fun runGuarded() {
reactContext
.getNativeModule(UIManagerModule::class.java)
?.updateNodeSize(id, width, height)
}
},
)
}
/**
* @param offsetY ignored on old architecture
*/
private fun dispatchShadowStateUpdate(
width: Int,
height: Int,
offsetY: Int,
) {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
updateScreenSizeFabric(width, height, offsetY)
} else {
updateScreenSizePaper(width, height)
}
}
val headerConfig: ScreenStackHeaderConfig?
get() = children.find { it is ScreenStackHeaderConfig } as? ScreenStackHeaderConfig
val contentWrapper: ScreenContentWrapper?
get() = children.find { it is ScreenContentWrapper } as? ScreenContentWrapper
/**
* While transitioning this property allows to optimize rendering behavior on Android and provide
* a correct blending options for the animated screen. It is turned on automatically by the
* container when transitioning is detected and turned off immediately after
*/
fun setTransitioning(transitioning: Boolean) {
if (isTransitioning == transitioning) {
return
}
isTransitioning = transitioning
val isWebViewInScreen = hasWebView(this)
if (isWebViewInScreen && layerType != LAYER_TYPE_HARDWARE) {
return
}
super.setLayerType(
if (transitioning && !isWebViewInScreen) LAYER_TYPE_HARDWARE else LAYER_TYPE_NONE,
null,
)
}
/**
* Whether this screen allows to see the content underneath it.
*/
fun isTranslucent(): Boolean =
when (stackPresentation) {
StackPresentation.TRANSPARENT_MODAL,
StackPresentation.FORM_SHEET,
-> true
else -> false
}
private fun hasWebView(viewGroup: ViewGroup): Boolean {
for (i in 0 until viewGroup.childCount) {
val child = viewGroup.getChildAt(i)
if (child is WebView) {
return true
} else if (child is ViewGroup) {
if (hasWebView(child)) {
return true
}
}
}
return false
}
override fun setLayerType(
layerType: Int,
paint: Paint?,
) {
// ignore - layer type is controlled by `transitioning` prop
}
fun setActivityState(activityState: ActivityState) {
if (activityState == this.activityState) {
return
}
if (container is ScreenStack && this.activityState != null && activityState < this.activityState!!) {
throw IllegalStateException("[RNScreens] activityState can only progress in NativeStack")
}
this.activityState = activityState
container?.onChildUpdate()
}
fun setScreenOrientation(screenOrientation: String?) {
if (screenOrientation == null) {
this.screenOrientation = null
return
}
ScreenWindowTraits.applyDidSetOrientation()
this.screenOrientation =
when (screenOrientation) {
"all" -> ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
"portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
"portrait_up" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"portrait_down" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
"landscape_left" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
"landscape_right" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
fragmentWrapper?.let { ScreenWindowTraits.setOrientation(this, it.tryGetActivity()) }
}
// Accepts one of 4 accessibility flags
// developer.android.com/reference/android/view/View#attr_android:importantForAccessibility
fun changeAccessibilityMode(mode: Int) {
this.importantForAccessibility = mode
this.headerConfig?.toolbar?.importantForAccessibility = mode
}
var statusBarStyle: String? = null
set(statusBarStyle) {
if (statusBarStyle != null) {
ScreenWindowTraits.applyDidSetStatusBarAppearance()
}
field = statusBarStyle
fragmentWrapper?.let {
ScreenWindowTraits.setStyle(
this,
it.tryGetActivity(),
it.tryGetContext(),
)
}
}
var isStatusBarHidden: Boolean? = null
set(statusBarHidden) {
if (statusBarHidden != null) {
ScreenWindowTraits.applyDidSetStatusBarAppearance()
}
field = statusBarHidden
fragmentWrapper?.let { ScreenWindowTraits.setHidden(this, it.tryGetActivity()) }
}
@Deprecated(
"For apps targeting SDK 35 or above this prop has no effect because " +
"edge-to-edge is enabled by default and the status bar is always translucent.",
)
var isStatusBarTranslucent: Boolean? = null
set(statusBarTranslucent) {
if (statusBarTranslucent != null) {
ScreenWindowTraits.applyDidSetStatusBarAppearance()
}
field = statusBarTranslucent
fragmentWrapper?.let {
ScreenWindowTraits.setTranslucent(
this,
it.tryGetActivity(),
it.tryGetContext(),
)
}
}
@Deprecated(
"For apps targeting SDK 35 or above this prop has no effect because " +
"edge-to-edge is enabled by default and the status bar is always translucent.",
)
var statusBarColor: Int? = null
set(statusBarColor) {
if (statusBarColor != null) {
ScreenWindowTraits.applyDidSetStatusBarAppearance()
}
field = statusBarColor
fragmentWrapper?.let {
ScreenWindowTraits.setColor(
this,
it.tryGetActivity(),
it.tryGetContext(),
)
}
}
@Deprecated(
"For all apps targeting Android SDK 35 or above edge-to-edge is enabled by default. ",
)
var navigationBarColor: Int? = null
set(navigationBarColor) {
if (navigationBarColor != null) {
ScreenWindowTraits.applyDidSetNavigationBarAppearance()
}
field = navigationBarColor
fragmentWrapper?.let {
ScreenWindowTraits.setNavigationBarColor(
this,
it.tryGetActivity(),
)
}
}
@Deprecated(
"For all apps targeting Android SDK 35 or above edge-to-edge is enabled by default. ",
)
var isNavigationBarTranslucent: Boolean? = null
set(navigationBarTranslucent) {
if (navigationBarTranslucent != null) {
ScreenWindowTraits.applyDidSetNavigationBarAppearance()
}
field = navigationBarTranslucent
fragmentWrapper?.let {
ScreenWindowTraits.setNavigationBarTranslucent(
this,
it.tryGetActivity(),
)
}
}
var isNavigationBarHidden: Boolean? = null
set(navigationBarHidden) {
if (navigationBarHidden != null) {
ScreenWindowTraits.applyDidSetNavigationBarAppearance()
}
field = navigationBarHidden
fragmentWrapper?.let {
ScreenWindowTraits.setNavigationBarHidden(
this,
it.tryGetActivity(),
)
}
}
var nativeBackButtonDismissalEnabled: Boolean = true
fun startRemovalTransition() {
if (!isBeingRemoved) {
isBeingRemoved = true
startTransitionRecursive(this)
}
}
fun endRemovalTransition() {
if (!isBeingRemoved) {
return
}
isBeingRemoved = false
endTransitionRecursive(this)
}
private fun endTransitionRecursive(parent: ViewGroup) {
parent.children.forEach { childView ->
parent.endViewTransition(childView)
if (childView is ScreenStackHeaderConfig) {
endTransitionRecursive(childView.toolbar)
}
if (childView is ViewGroup) {
endTransitionRecursive(childView)
}
}
}
private fun startTransitionRecursive(parent: ViewGroup?) {
parent?.let {
for (i in 0 until it.childCount) {
val child = it.getChildAt(i)
if (parent is SwipeRefreshLayout && child is ImageView) {
// SwipeRefreshLayout class which has CircleImageView as a child,
// does not handle `startViewTransition` properly.
// It has a custom `getChildDrawingOrder` method which returns
// wrong index if we called `startViewTransition` on the views on new arch.
// We add a simple View to bump the number of children to make it work.
// TODO: find a better way to handle this scenario
it.addView(View(context), i)
} else {
child?.let { view -> it.startViewTransition(view) }
}
if (child is ScreenStackHeaderConfig) {
// we want to start transition on children of the toolbar too,
// which is not a child of ScreenStackHeaderConfig
startTransitionRecursive(child.toolbar)
}
if (child is ViewGroup) {
startTransitionRecursive(child)
}
}
}
}
// We do not want to perform any action, therefore do not need to override the associated method.
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean =
if (usesFormSheetPresentation()) {
// If we're a form sheet we want to consume the gestures to prevent
// DimmingView's callback from triggering when clicking on the sheet itself.
true
} else {
super.onTouchEvent(event)
}
private fun notifyHeaderHeightChange(headerHeight: Int) {
val screenContext = context as ReactContext
val surfaceId = UIManagerHelper.getSurfaceId(screenContext)
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, id)
?.dispatchEvent(HeaderHeightChangeEvent(surfaceId, id, headerHeight))
}
internal fun onSheetDetentChanged(
detentIndex: Int,
isStable: Boolean,
) {
dispatchSheetDetentChanged(detentIndex, isStable)
// There is no need to update shadow state for transient sheet states -
// we are unsure of the exact sheet position anyway.
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED && isStable) {
updateScreenSizeFabric(width, height, top)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// Insets handler for formSheet is added onResume but it is often too late if we use input
// with autofocus - onResume is called after finishing animator animation.
// onAttachedToWindow is called before onApplyWindowInsets so we use it to set the handler
// earlier. More details: https://github.com/software-mansion/react-native-screens/pull/2911
if (usesFormSheetPresentation()) {
fragment?.asScreenStackFragment()?.sheetDelegate?.let {
InsetsObserverProxy.addOnApplyWindowInsetsListener(
it,
)
}
}
}
private fun dispatchSheetDetentChanged(
detentIndex: Int,
isStable: Boolean,
) {
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
reactEventDispatcher?.dispatchEvent(
SheetDetentChangedEvent(
surfaceId,
id,
detentIndex,
isStable,
),
)
}
internal fun onFinalizePropsUpdate() {
if (shouldUpdateSheetCornerRadius) {
shouldUpdateSheetCornerRadius = false
onSheetCornerRadiusChange()
}
}
internal fun onSheetCornerRadiusChange() {
if (stackPresentation !== StackPresentation.FORM_SHEET || background == null) {
return
}
(background as? MaterialShapeDrawable?)?.let {
val resolvedCornerRadius = PixelUtil.toDIPFromPixel(sheetCornerRadius)
it.shapeAppearanceModel =
ShapeAppearanceModel
.Builder()
.apply {
setTopLeftCorner(CornerFamily.ROUNDED, resolvedCornerRadius)
setTopRightCorner(CornerFamily.ROUNDED, resolvedCornerRadius)
}.build()
}
}
enum class StackPresentation {
PUSH,
MODAL,
TRANSPARENT_MODAL,
FORM_SHEET,
}
enum class StackAnimation {
DEFAULT,
NONE,
FADE,
SLIDE_FROM_BOTTOM,
SLIDE_FROM_RIGHT,
SLIDE_FROM_LEFT,
FADE_FROM_BOTTOM,
IOS_FROM_RIGHT,
IOS_FROM_LEFT,
}
enum class ReplaceAnimation {
PUSH,
POP,
}
enum class ActivityState {
INACTIVE,
TRANSITIONING_OR_BELOW_TOP,
ON_TOP,
}
enum class WindowTraits {
ORIENTATION,
COLOR,
STYLE,
TRANSLUCENT,
HIDDEN,
ANIMATED,
NAVIGATION_BAR_COLOR,
NAVIGATION_BAR_TRANSLUCENT,
NAVIGATION_BAR_HIDDEN,
}
companion object {
const val TAG = "Screen"
/**
* This value describes value in sheet detents array that will be treated as `fitToContents` option.
*/
const val SHEET_FIT_TO_CONTENTS = -1.0
}
}
internal fun View.asScreen() = this as Screen
@@ -0,0 +1,430 @@
package com.swmansion.rnscreens
import android.content.Context
import android.content.ContextWrapper
import android.view.Choreographer
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import com.facebook.react.ReactRootView
import com.facebook.react.bridge.ReactContext
import com.facebook.react.modules.core.ReactChoreographer
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.swmansion.rnscreens.Screen.ActivityState
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.gamma.common.FragmentProviding
open class ScreenContainer(
context: Context?,
) : ViewGroup(context) {
@JvmField
protected val screenWrappers = ArrayList<ScreenFragmentWrapper>()
@JvmField
protected var fragmentManager: FragmentManager? = null
private var isAttached = false
private var needsUpdate = false
private var isLayoutEnqueued = false
private val layoutCallback: Choreographer.FrameCallback =
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
isLayoutEnqueued = false
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
)
layout(left, top, right, bottom)
}
}
private var parentScreenWrapper: ScreenFragmentWrapper? = null
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
var i = 0
val size = childCount
while (i < size) {
getChildAt(i).layout(0, 0, width, height)
i++
}
}
override fun removeView(view: View) {
// The below block is a workaround for an issue with keyboard handling within fragments. Despite
// Android handles input focus on the fragments that leave the screen, the keyboard stays open
// in a number of cases.
// The workaround is to force-hide keyboard when the screen that has focus is dismissed (we
// detect that in removeView as super.removeView causes the input view to un-focus while keeping
// the keyboard open).
if (view === focusedChild) {
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
}
super.removeView(view)
}
override fun requestLayout() {
super.requestLayout()
@Suppress("SENSELESS_COMPARISON") // mLayoutCallback can be null here since this method can be called in init
if (!isLayoutEnqueued && layoutCallback != null) {
isLayoutEnqueued = true
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
ReactChoreographer
.getInstance()
.postFrameCallback(
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
layoutCallback,
)
}
}
val isNested: Boolean
get() = parentScreenWrapper != null
fun onChildUpdate() {
performUpdatesNow()
}
protected open fun adapt(screen: Screen): ScreenFragmentWrapper = ScreenFragment(screen)
fun addScreen(
screen: Screen,
index: Int,
) {
val fragment = adapt(screen)
screen.fragmentWrapper = fragment
screenWrappers.add(index, fragment)
screen.container = this
onScreenChanged()
}
open fun removeScreenAt(index: Int) {
screenWrappers[index].screen.container = null
screenWrappers.removeAt(index)
onScreenChanged()
}
open fun removeAllScreens() {
for (screenFragment in screenWrappers) {
screenFragment.screen.container = null
}
screenWrappers.clear()
onScreenChanged()
}
val screenCount: Int
get() = screenWrappers.size
fun getScreenAt(index: Int): Screen = screenWrappers[index].screen
fun getScreenFragmentWrapperAt(index: Int): ScreenFragmentWrapper = screenWrappers[index]
open val topScreen: Screen?
get() = screenWrappers.firstOrNull { getActivityState(it) === ActivityState.ON_TOP }?.screen
private fun setFragmentManager(fm: FragmentManager) {
fragmentManager = fm
performUpdatesNow()
}
private fun findFragmentManagerForReactRootView(rootView: ReactRootView): FragmentManager {
var context = rootView.context
// ReactRootView is expected to be initialized with the main React Activity as a context but
// in case of Expo the activity is wrapped in ContextWrapper and we need to unwrap it
while (context !is FragmentActivity && context is ContextWrapper) {
context = context.baseContext
}
check(context is FragmentActivity) {
"In order to use RNScreens components your app's activity need to extend ReactActivity"
}
// In case React Native is loaded on a Fragment (not directly in activity) we need to find
// fragment manager whose fragment's view is ReactRootView. As of now, we detect such case by
// checking whether any fragments are attached to activity which hosts ReactRootView.
// See: https://github.com/software-mansion/react-native-screens/issues/1506 on why the cases
// must be treated separately.
return if (context.supportFragmentManager.fragments.isEmpty()) {
// We are in standard React Native application w/o custom native navigation based on fragments.
context.supportFragmentManager
} else {
// We are in some custom setup & we want to use the closest fragment manager in hierarchy.
// `findFragment` method throws IllegalStateException when it fails to resolve appropriate
// fragment. It might happen when e.g. React Native is loaded directly in Activity
// but some custom fragments are still used. Such use case seems highly unlikely
// so, as for now we fallback to activity's FragmentManager in hope for the best.
try {
FragmentManager.findFragment<Fragment>(rootView).childFragmentManager
} catch (ex: IllegalStateException) {
context.supportFragmentManager
}
}
}
private fun setupFragmentManager() {
var parent: ViewParent = this
// We traverse view hierarchy up until we find screen parent or a root view
while (!(parent is ReactRootView || parent is FragmentProviding) &&
parent.parent != null
) {
parent = parent.parent
}
// If parent is of type Screen it means we are inside a nested fragment structure.
// Otherwise we expect to connect directly with root view and get root fragment manager
if (parent is Screen) {
checkNotNull(
parent.fragmentWrapper?.let { fragmentWrapper ->
parentScreenWrapper = fragmentWrapper
fragmentWrapper.addChildScreenContainer(this)
setFragmentManager(fragmentWrapper.fragment.childFragmentManager)
},
) { "Parent Screen does not have its Fragment attached" }
} else if (parent is FragmentProviding) {
// TODO: We're missing parent-child relationship here between old container & new one
val fragmentManager =
checkNotNull(
parent.getAssociatedFragment(),
) { "[RNScreens] Parent $parent returned nullish fragment" }.childFragmentManager
setFragmentManager(fragmentManager)
} else {
// we expect top level view to be of type ReactRootView, this isn't really necessary but in
// order to find root view we test if parent is null. This could potentially happen also when
// the view is detached from the hierarchy and that test would not correctly indicate the root
// view. So in order to make sure we indeed reached the root we test if it is of a correct type.
// This allows us to provide a more descriptive error message for the aforementioned case.
check(parent is ReactRootView) { "ScreenContainer is not attached under ReactRootView" }
setFragmentManager(findFragmentManagerForReactRootView(parent))
}
}
protected fun createTransaction(): FragmentTransaction =
requireNotNull(fragmentManager) { "fragment manager is null when creating transaction" }
.beginTransaction()
.setReorderingAllowed(true)
private fun attachScreen(
transaction: FragmentTransaction,
fragment: Fragment,
) {
transaction.add(id, fragment)
}
fun attachBelowTop() {
if (screenWrappers.size < 2) {
throw RuntimeException("[RNScreens] Unable to run transition for less than 2 screens.")
}
val transaction = createTransaction()
val top = topScreen as Screen
// we have to reattach topScreen so it is on top of the one below
detachScreen(transaction, top.fragment as Fragment)
attachScreen(transaction, screenWrappers[screenWrappers.size - 2].fragment)
attachScreen(transaction, top.fragment as Fragment)
transaction.commitNowAllowingStateLoss()
}
fun detachBelowTop() {
if (screenWrappers.size < 2) {
throw RuntimeException("[RNScreens] Unable to run transition for less than 2 screens.")
}
val transaction = createTransaction()
detachScreen(transaction, screenWrappers[screenWrappers.size - 2].fragment)
transaction.commitNowAllowingStateLoss()
}
fun notifyTopDetached() {
val top = topScreen as Screen
if (context is ReactContext) {
val surfaceId = UIManagerHelper.getSurfaceId(context)
UIManagerHelper
.getEventDispatcherForReactTag(context as ReactContext, top.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, top.id))
}
}
private fun detachScreen(
transaction: FragmentTransaction,
fragment: Fragment,
) {
transaction.remove(fragment)
}
private fun getActivityState(screenFragmentWrapper: ScreenFragmentWrapper): ActivityState? = screenFragmentWrapper.screen.activityState
open fun hasScreen(screenFragmentWrapper: ScreenFragmentWrapper?): Boolean = screenWrappers.contains(screenFragmentWrapper)
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isAttached = true
setupFragmentManager()
}
/** Removes fragments from fragment manager that are attached to this container */
private fun removeMyFragments(fragmentManager: FragmentManager) {
val transaction = fragmentManager.beginTransaction()
var hasFragments = false
for (fragment in fragmentManager.fragments) {
if (fragment is ScreenFragment && fragment.screen.container === this) {
transaction.remove(fragment)
hasFragments = true
}
}
if (hasFragments) {
transaction.commitNowAllowingStateLoss()
}
}
override fun onDetachedFromWindow() {
// if there are pending transactions and this view is about to get detached we need to perform
// them here as otherwise fragment manager will crash because it won't be able to find container
// view. We also need to make sure all the fragments attached to the given container are removed
// from fragment manager as in some cases fragment manager may be reused and in such case it'd
// attempt to reattach previously registered fragments that are not removed
fragmentManager?.let {
if (!it.isDestroyed) {
removeMyFragments(it)
it.executePendingTransactions()
}
}
parentScreenWrapper?.removeChildScreenContainer(this)
parentScreenWrapper = null
super.onDetachedFromWindow()
isAttached = false
// When fragment container view is detached we force all its children to be removed.
// It is because children screens are controlled by their fragments, which can often have a
// delayed lifecycle (due to transitions). As a result due to ongoing transitions the fragment
// may choose not to remove the view despite the parent container being completely detached
// from the view hierarchy until the transition is over. In such a case when the container gets
// re-attached while the transition is ongoing, the child view would still be there and we'd
// attempt to re-attach it to with a misconfigured fragment. This would result in a crash. To
// avoid it we clear all the children here as we attach all the child fragments when the
// container is reattached anyways. We don't use `removeAllViews` since it does not check if the
// children are not already detached, which may lead to calling `onDetachedFromWindow` on them
// twice.
// We also get the size earlier, because we will be removing child views in `for` loop.
for (i in childCount - 1 downTo 0) {
removeViewAt(i)
}
}
override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int,
) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
for (i in 0 until childCount) {
getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec)
}
}
private fun onScreenChanged() {
// we perform update in `onBeforeLayout` of `ScreensShadowNode` by adding an UIBlock
// which is called after updating children of the ScreenContainer.
// We do it there because `onUpdate` logic requires all changes of children to be already
// made in order to provide proper animation for fragment transition for ScreenStack
// and this in turn makes nested ScreenContainers detach too early and disappear
// before transition if also not dispatched after children updates.
// The exception to this rule is `updateImmediately` which is triggered by actions
// not connected to React view hierarchy changes, but rather internal events
needsUpdate = true
(context as ThemedReactContext).reactApplicationContext.runOnUiQueueThread {
// We schedule the update here because LayoutAnimations of `react-native-reanimated`
// sometimes attach/detach screens after the layout block of `ScreensShadowNode` has
// already run, and we want to update the container then too. In the other cases,
// this code will do nothing since it will run after the UIBlock when `mNeedUpdate`
// will already be false.
performUpdates()
}
}
protected fun performUpdatesNow() {
// we want to update immediately when the fragment manager is set or native back button
// dismiss is dispatched or Screen's activityState changes since it is not connected to React
// view hierarchy changes and will not trigger `onBeforeLayout` method of `ScreensShadowNode`
needsUpdate = true
performUpdates()
}
fun performUpdates() {
if (!needsUpdate || !isAttached || fragmentManager == null || fragmentManager?.isDestroyed == true) {
return
}
needsUpdate = false
onUpdate()
notifyContainerUpdate()
}
open fun onUpdate() {
createTransaction().let {
// detach screens that are no longer active
val orphaned: MutableSet<Fragment> =
HashSet(
requireNotNull(fragmentManager) {
"fragment manager is null when performing update in ScreenContainer"
}.fragments,
)
for (fragmentWrapper in screenWrappers) {
if (getActivityState(fragmentWrapper) === ActivityState.INACTIVE &&
fragmentWrapper.fragment.isAdded
) {
detachScreen(it, fragmentWrapper.fragment)
}
orphaned.remove(fragmentWrapper.fragment)
}
if (orphaned.isNotEmpty()) {
val orphanedAry = orphaned.toTypedArray()
for (fragment in orphanedAry) {
if (fragment is ScreenFragment) {
if (fragment.screen.container == null) {
detachScreen(it, fragment)
}
}
}
}
// if there is an "onTop" screen it means the transition has ended
val transitioning = topScreen == null
// attach newly activated screens
var addedBefore = false
val pendingFront: ArrayList<ScreenFragmentWrapper> = ArrayList()
for (fragmentWrapper in screenWrappers) {
val activityState = getActivityState(fragmentWrapper)
if (activityState !== ActivityState.INACTIVE && !fragmentWrapper.fragment.isAdded) {
addedBefore = true
attachScreen(it, fragmentWrapper.fragment)
} else if (activityState !== ActivityState.INACTIVE && addedBefore) {
// we detach the screen and then reattach it later to make it appear on front
detachScreen(it, fragmentWrapper.fragment)
pendingFront.add(fragmentWrapper)
}
fragmentWrapper.screen.setTransitioning(transitioning)
}
for (screenFragment in pendingFront) {
attachScreen(it, screenFragment.fragment)
}
it.commitNowAllowingStateLoss()
}
}
protected open fun notifyContainerUpdate() {
topScreen?.fragmentWrapper?.onContainerUpdate()
}
}
@@ -0,0 +1,63 @@
package com.swmansion.rnscreens
import android.view.View
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.LayoutShadowNode
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenContainerManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenContainerManagerInterface
@ReactModule(name = ScreenContainerViewManager.REACT_CLASS)
class ScreenContainerViewManager :
ViewGroupManager<ScreenContainer>(),
RNSScreenContainerManagerInterface<ScreenContainer> {
private val delegate: ViewManagerDelegate<ScreenContainer>
init {
delegate = RNSScreenContainerManagerDelegate<ScreenContainer, ScreenContainerViewManager>(this)
}
protected override fun getDelegate(): ViewManagerDelegate<ScreenContainer> = delegate
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): ScreenContainer = ScreenContainer(reactContext)
override fun addView(
parent: ScreenContainer,
child: View,
index: Int,
) {
require(child is Screen) { "Attempt attach child that is not of type RNScreens" }
parent.addScreen(child, index)
}
override fun removeViewAt(
parent: ScreenContainer,
index: Int,
) {
parent.removeScreenAt(index)
}
override fun removeAllViews(parent: ScreenContainer) {
parent.removeAllScreens()
}
override fun getChildCount(parent: ScreenContainer): Int = parent.screenCount
override fun getChildAt(
parent: ScreenContainer,
index: Int,
): View = parent.getScreenAt(index)
override fun createShadowNodeInstance(context: ReactApplicationContext): LayoutShadowNode = ScreensShadowNode(context)
override fun needsCustomLayoutForChildren(): Boolean = true
companion object {
const val REACT_CLASS = "RNSScreenContainer"
}
}
@@ -0,0 +1,38 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import com.facebook.react.bridge.ReactContext
import com.facebook.react.views.view.ReactViewGroup
/**
* When we wrap children of the Screen component inside this component in JS code,
* we can later use it to get the enclosing frame size of our content as it is rendered by RN.
*
* This is useful when adapting form sheet height to its contents height.
*/
@SuppressLint("ViewConstructor")
class ScreenContentWrapper(
reactContext: ReactContext,
) : ReactViewGroup(reactContext) {
internal var delegate: OnLayoutCallback? = null
interface OnLayoutCallback {
fun onContentWrapperLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
)
}
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
delegate?.onContentWrapperLayout(changed, left, top, right, bottom)
}
}
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenContentWrapperManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenContentWrapperManagerInterface
@ReactModule(name = ScreenContentWrapperManager.REACT_CLASS)
class ScreenContentWrapperManager :
ViewGroupManager<ScreenContentWrapper>(),
RNSScreenContentWrapperManagerInterface<ScreenContentWrapper> {
private val delegate: ViewManagerDelegate<ScreenContentWrapper> = RNSScreenContentWrapperManagerDelegate(this)
companion object {
const val REACT_CLASS = "RNSScreenContentWrapper"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): ScreenContentWrapper = ScreenContentWrapper(reactContext)
override fun getDelegate(): ViewManagerDelegate<ScreenContentWrapper> = delegate
}
@@ -0,0 +1,29 @@
package com.swmansion.rnscreens
interface ScreenEventDispatcher {
fun canDispatchLifecycleEvent(event: ScreenFragment.ScreenLifecycleEvent): Boolean
fun updateLastEventDispatched(event: ScreenFragment.ScreenLifecycleEvent)
/**
* Dispatches given screen lifecycle event to JS using screen from given fragment `fragmentWrapper`
*/
fun dispatchLifecycleEvent(
event: ScreenFragment.ScreenLifecycleEvent,
fragmentWrapper: ScreenFragmentWrapper,
)
/**
* Dispatches given screen lifecycle event from all non-empty child containers to JS
*/
fun dispatchLifecycleEventInChildContainers(event: ScreenFragment.ScreenLifecycleEvent)
fun dispatchHeaderBackButtonClickedEvent()
fun dispatchTransitionProgressEvent(
alpha: Float,
closing: Boolean,
)
// Concrete dispatchers
}
@@ -0,0 +1,299 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.bridge.ReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.math.MathUtils
import com.swmansion.rnscreens.bottomsheet.SheetUtils
import kotlin.math.max
@SuppressLint("ViewConstructor")
class ScreenFooter(
val reactContext: ReactContext,
) : ReactViewGroup(reactContext) {
private var lastContainerHeight: Int = 0
private var lastStableSheetState: Int = STATE_HIDDEN
private var isAnimationControlledByKeyboard = false
private var lastSlideOffset = 0.0f
private var lastBottomInset = 0
private var isCallbackRegistered = false
// ScreenFooter is supposed to be direct child of Screen
private val screenParent
get() = parent as? Screen
private val sheetBehavior
get() = requireScreenParent().sheetBehavior
private val hasReceivedInitialLayoutFromParent get() = lastContainerHeight > 0
// Due to Android restrictions on layout flow, particularly
// the fact that onMeasure must set `measuredHeight` & `measuredWidth` React calls `measure` on every
// view group with accurate dimensions computed by Yoga. This is our entry point to get current view dimensions.
private val reactHeight
get() = measuredHeight
private val reactWidth
get() = measuredWidth
// Main goal of this callback implementation is to handle keyboard appearance. We use it to make sure
// that the footer respects keyboard during layout.
// Note `DISPATCH_MODE_STOP` is used here to avoid propagation of insets callback to footer subtree.
private val insetsAnimation =
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat,
): WindowInsetsAnimationCompat.BoundsCompat {
isAnimationControlledByKeyboard = true
return super.onStart(animation, bounds)
}
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
): WindowInsetsCompat {
val imeBottomInset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val navigationBarBottomInset =
insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
// **It looks like** when keyboard is presented its inset does include navigation bar
// bottom inset, while it is already accounted for somewhere (dunno where).
// That is why we subtract navigation bar bottom inset here.
//
// Situations where keyboard is not visible and navigation bar is present are handled
// directly in layout function by not allowing lastBottomInset to contribute value less
// than 0. Alternative would be write logic specific to keyboard animation direction (hide / show).
lastBottomInset = imeBottomInset - navigationBarBottomInset
layoutFooterOnYAxis(
lastContainerHeight,
reactHeight,
sheetTopWhileDragging(lastSlideOffset),
lastBottomInset,
)
// Please note that we do *not* consume any insets here, so that we do not interfere with
// any other view.
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
isAnimationControlledByKeyboard = false
}
}
init {
val rootView =
checkNotNull(reactContext.currentActivity) {
"[RNScreens] Context detached from activity while creating ScreenFooter"
}.window.decorView
// Note that we do override insets animation on given view. I can see it interfering e.g.
// with reanimated keyboard or even other places in our code. Need to test this.
ViewCompat.setWindowInsetsAnimationCallback(rootView, insetsAnimation)
}
private fun requireScreenParent(): Screen = requireNotNull(screenParent)
private fun requireSheetBehavior(): BottomSheetBehavior<Screen> = requireNotNull(sheetBehavior)
// React calls `layout` function to set view dimensions, thus this is our entry point for
// fixing layout up after Yoga repositions it.
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
super.onLayout(changed, left, top, right, bottom)
if (!hasReceivedInitialLayoutFromParent) {
return
}
layoutFooterOnYAxis(
lastContainerHeight,
bottom - top,
sheetTopInStableState(requireSheetBehavior().state),
lastBottomInset,
)
}
private var footerCallback =
object : BottomSheetCallback() {
override fun onStateChanged(
bottomSheet: View,
newState: Int,
) {
if (!SheetUtils.isStateStable(newState)) {
return
}
when (newState) {
STATE_COLLAPSED,
STATE_HALF_EXPANDED,
STATE_EXPANDED,
->
layoutFooterOnYAxis(
lastContainerHeight,
reactHeight,
sheetTopInStableState(newState),
lastBottomInset,
)
else -> {}
}
lastStableSheetState = newState
}
override fun onSlide(
bottomSheet: View,
slideOffset: Float,
) {
lastSlideOffset = max(slideOffset, 0.0f)
if (!isAnimationControlledByKeyboard) {
layoutFooterOnYAxis(
lastContainerHeight,
reactHeight,
sheetTopWhileDragging(lastSlideOffset),
lastBottomInset,
)
}
}
}
// Important to keep this method idempotent! We attempt to (un)register
// our callback in different places depending on whether the behavior is already created.
fun registerWithSheetBehavior(behavior: BottomSheetBehavior<Screen>) {
if (!isCallbackRegistered) {
behavior.addBottomSheetCallback(footerCallback)
isCallbackRegistered = true
}
}
// Important to keep this method idempotent! We attempt to (un)register
// our callback in different places depending on whether the behavior is already created.
fun unregisterWithSheetBehavior(behavior: BottomSheetBehavior<Screen>) {
if (isCallbackRegistered) {
behavior.removeBottomSheetCallback(footerCallback)
isCallbackRegistered = false
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
sheetBehavior?.let { registerWithSheetBehavior(it) }
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
sheetBehavior?.let { unregisterWithSheetBehavior(it) }
}
/**
* Calculate position of sheet's top while it is in stable state given concrete sheet state.
*
* This method should not be used for sheet in unstable state.
*
* @param state sheet state as defined in [BottomSheetBehavior]
* @return position of sheet's top **relative to container**
*/
private fun sheetTopInStableState(state: Int): Int {
val behavior = requireSheetBehavior()
return when (state) {
STATE_COLLAPSED -> lastContainerHeight - behavior.peekHeight
STATE_HALF_EXPANDED -> (lastContainerHeight * (1 - behavior.halfExpandedRatio)).toInt()
STATE_EXPANDED -> behavior.expandedOffset
STATE_HIDDEN -> lastContainerHeight
else -> throw IllegalArgumentException("[RNScreens] use of stable-state method for unstable state")
}
}
/**
* Calculate position of sheet's top while it is in dragging / settling state given concrete slide offset
* as reported by [BottomSheetCallback.onSlide].
*
* This method should not be used for sheet in stable state.
*
* Currently the implementation assumes that the Screen's (sheet's) container starts at y: 0
* in global coordinates. Then we can use simply sheet's top. If that is for some reason
* unavailable, then we fallback to interpolation basing on values provided by sheet behaviour.
*
* We don't want to primarily rely on interpolation, because due to division rounding errors the footer
* will "flicker" (jump up / down a single pixel).
*
* @param slideOffset sheet offset as reported by [BottomSheetCallback.onSlide]
* @return position of sheet's top **relative to container**
*/
private fun sheetTopWhileDragging(slideOffset: Float): Int =
screenParent?.top ?: MathUtils
.lerp(
sheetTopInStableState(STATE_COLLAPSED).toFloat(),
sheetTopInStableState(
STATE_EXPANDED,
).toFloat(),
slideOffset,
).toInt()
/**
* Parent Screen will call this on it's layout. We need to be notified on any update to Screen's content
* or its container dimensions change. This is also our entrypoint to acquiring container height.
*/
fun onParentLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int,
containerHeight: Int,
) {
lastContainerHeight = containerHeight
layoutFooterOnYAxis(
containerHeight,
reactHeight,
sheetTopInStableState(requireSheetBehavior().state),
)
}
/**
* Layouts this component within parent screen. It takes care only of vertical axis, leaving
* horizontal axis solely for React to handle.
*
* This is a bit against Android rules, that parents should layout their children,
* however I wanted to keep this logic away from Screen component to avoid introducing
* complexity there and have footer logic as much separated as it is possible.
*
* Please note that React has no clue about updates enforced in below method.
*
* @param containerHeight this should be the height of the screen (sheet) container used
* to calculate sheet properties when configuring behavior (pixels)
* @param footerHeight summarized height of this component children (pixels)
* @param sheetTop current bottom sheet top (Screen top) **relative to container** (pixels)
* @param bottomInset current bottom inset, used to offset the footer by keyboard height (pixels)
*/
fun layoutFooterOnYAxis(
containerHeight: Int,
footerHeight: Int,
sheetTop: Int,
bottomInset: Int = 0,
) {
// max(bottomInset, 0) is just a hack to avoid double offset of navigation bar.
val newTop = containerHeight - footerHeight - sheetTop - max(bottomInset, 0)
val heightBeforeUpdate = reactHeight
this.top = max(newTop, 0)
this.bottom = this.top + heightBeforeUpdate
}
companion object {
const val TAG = "ScreenFooter"
}
}
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenFooterManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenFooterManagerInterface
@ReactModule(name = ScreenFooterManager.REACT_CLASS)
class ScreenFooterManager :
ViewGroupManager<ScreenFooter>(),
RNSScreenFooterManagerInterface<ScreenFooter> {
private val delegate: ViewManagerDelegate<ScreenFooter> = RNSScreenFooterManagerDelegate(this)
override fun getName(): String = REACT_CLASS
override fun createViewInstance(context: ThemedReactContext) = ScreenFooter(context)
override fun getDelegate(): ViewManagerDelegate<ScreenFooter> = delegate
companion object {
const val REACT_CLASS = "RNSScreenFooter"
}
}
@@ -0,0 +1,352 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.EventDispatcher
import com.swmansion.rnscreens.events.HeaderBackButtonClickedEvent
import com.swmansion.rnscreens.events.ScreenAppearEvent
import com.swmansion.rnscreens.events.ScreenDisappearEvent
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.events.ScreenTransitionProgressEvent
import com.swmansion.rnscreens.events.ScreenWillAppearEvent
import com.swmansion.rnscreens.events.ScreenWillDisappearEvent
import com.swmansion.rnscreens.ext.recycle
import kotlin.math.max
import kotlin.math.min
open class ScreenFragment :
Fragment,
ScreenFragmentWrapper {
enum class ScreenLifecycleEvent {
DID_APPEAR,
WILL_APPEAR,
DID_DISAPPEAR,
WILL_DISAPPEAR,
}
override val fragment: Fragment
get() = this
// if we call empty constructor, there is no screen to be assigned so not sure why it is suggested
@Suppress("JoinDeclarationAndAssignment")
override lateinit var screen: Screen
override val childScreenContainers: MutableList<ScreenContainer> = ArrayList()
private var shouldUpdateOnResume = false
// if we don't set it, it will be 0.0f at the beginning so the progress will not be sent
// due to progress value being already 0.0f
private var transitionProgress = -1f
// those 2 vars are needed since sometimes the events would be dispatched twice in child containers
// (should only happen if parent has `NONE` animation) and we don't need too complicated logic.
// We just check if, after the event was dispatched, its "counter-event" has been also dispatched before sending the same event again.
// We do it for 'willAppear' -> 'willDisappear' and 'appear' -> 'disappear'
private var canDispatchWillAppear = true
private var canDispatchAppear = true
// we want to know if we are currently transitioning in order not to fire lifecycle events
// in nested fragments. See more explanation in dispatchViewAnimationEvent
private var isTransitioning = false
constructor() {
throw IllegalStateException(
"Screen fragments should never be restored. Follow instructions from https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704067 to properly configure your main activity.",
)
}
@SuppressLint("ValidFragment")
constructor(screenView: Screen) : super() {
screen = screenView
}
override fun onResume() {
super.onResume()
if (shouldUpdateOnResume) {
shouldUpdateOnResume = false
ScreenWindowTraits.trySetWindowTraits(screen, tryGetActivity(), tryGetContext())
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
screen.layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
val wrapper =
context?.let { ScreensFrameLayout(it) }?.apply {
addView(screen.recycle())
}
return wrapper
}
private class ScreensFrameLayout(
context: Context,
) : FrameLayout(context) {
/**
* This method implements a workaround for RN's autoFocus functionality. Because of the way
* autoFocus is implemented it dismisses soft keyboard in fragment transition
* due to change of visibility of the view at the start of the transition. Here we override the
* call to `clearFocus` when the visibility of view is `INVISIBLE` since `clearFocus` triggers the
* hiding of the keyboard in `ReactEditText.java`.
*/
override fun clearFocus() {
if (visibility != INVISIBLE) {
super.clearFocus()
}
}
}
override fun onContainerUpdate() {
updateWindowTraits()
}
private fun updateWindowTraits() {
val activity: Activity? = activity
if (activity == null) {
shouldUpdateOnResume = true
return
}
ScreenWindowTraits.trySetWindowTraits(screen, activity, tryGetContext())
}
// Plain ScreenFragments can not be translucent
override fun isTranslucent() = false
override fun tryGetActivity(): Activity? {
activity?.let { return it }
val context = screen.context
if (context is ReactContext && context.currentActivity != null) {
return context.currentActivity
}
var parent: ViewParent? = screen.container
while (parent != null) {
if (parent is Screen) {
val fragment = parent.fragment
fragment?.activity?.let { return it }
}
parent = parent.parent
}
return null
}
override fun tryGetContext(): ReactContext? {
if (context is ReactContext) {
return context as ReactContext
}
if (screen.context is ReactContext) {
return screen.context as ReactContext
}
var parent: ViewParent? = screen.container
while (parent != null) {
if (parent is Screen) {
if (parent.context is ReactContext) {
return parent.context as ReactContext
}
}
parent = parent.parent
}
return null
}
override fun canDispatchLifecycleEvent(event: ScreenLifecycleEvent): Boolean =
when (event) {
ScreenLifecycleEvent.WILL_APPEAR -> canDispatchWillAppear
ScreenLifecycleEvent.DID_APPEAR -> canDispatchAppear
ScreenLifecycleEvent.WILL_DISAPPEAR -> !canDispatchWillAppear
ScreenLifecycleEvent.DID_DISAPPEAR -> !canDispatchAppear
}
override fun updateLastEventDispatched(event: ScreenLifecycleEvent) {
when (event) {
ScreenLifecycleEvent.WILL_APPEAR -> canDispatchWillAppear = false
ScreenLifecycleEvent.DID_APPEAR -> canDispatchAppear = false
ScreenLifecycleEvent.WILL_DISAPPEAR -> canDispatchWillAppear = true
ScreenLifecycleEvent.DID_DISAPPEAR -> canDispatchAppear = true
}
}
private fun dispatchOnWillAppear() {
dispatchLifecycleEvent(ScreenLifecycleEvent.WILL_APPEAR, this)
dispatchTransitionProgressEvent(0.0f, false)
}
private fun dispatchOnAppear() {
dispatchLifecycleEvent(ScreenLifecycleEvent.DID_APPEAR, this)
dispatchTransitionProgressEvent(1.0f, false)
}
private fun dispatchOnWillDisappear() {
dispatchLifecycleEvent(ScreenLifecycleEvent.WILL_DISAPPEAR, this)
dispatchTransitionProgressEvent(0.0f, true)
}
private fun dispatchOnDisappear() {
dispatchLifecycleEvent(ScreenLifecycleEvent.DID_DISAPPEAR, this)
dispatchTransitionProgressEvent(1.0f, true)
}
override fun dispatchLifecycleEvent(
event: ScreenLifecycleEvent,
fragmentWrapper: ScreenFragmentWrapper,
) {
val fragment = fragmentWrapper.fragment
if (fragment is ScreenStackFragment && fragment.canDispatchLifecycleEvent(event)) {
fragment.screen.let {
fragmentWrapper.updateLastEventDispatched(event)
val surfaceId = UIManagerHelper.getSurfaceId(it)
val lifecycleEvent: Event<*> =
when (event) {
ScreenLifecycleEvent.WILL_APPEAR -> ScreenWillAppearEvent(surfaceId, it.id)
ScreenLifecycleEvent.DID_APPEAR -> ScreenAppearEvent(surfaceId, it.id)
ScreenLifecycleEvent.WILL_DISAPPEAR -> ScreenWillDisappearEvent(surfaceId, it.id)
ScreenLifecycleEvent.DID_DISAPPEAR -> ScreenDisappearEvent(surfaceId, it.id)
}
val screenContext = screen.context as ReactContext
val eventDispatcher: EventDispatcher? =
UIManagerHelper.getEventDispatcherForReactTag(screenContext, screen.id)
eventDispatcher?.dispatchEvent(lifecycleEvent)
fragmentWrapper.dispatchLifecycleEventInChildContainers(event)
}
}
}
override fun dispatchLifecycleEventInChildContainers(event: ScreenLifecycleEvent) {
childScreenContainers.filter { it.screenCount > 0 }.forEach {
it.topScreen?.fragmentWrapper?.let { fragment -> dispatchLifecycleEvent(event, fragment) }
}
}
override fun dispatchHeaderBackButtonClickedEvent() {
val screenContext = screen.context as ReactContext
val surfaceId = UIManagerHelper.getSurfaceId(screenContext)
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, screen.id)
?.dispatchEvent(HeaderBackButtonClickedEvent(surfaceId, screen.id))
}
override fun dispatchTransitionProgressEvent(
alpha: Float,
closing: Boolean,
) {
if (this is ScreenStackFragment) {
if (transitionProgress != alpha) {
transitionProgress = max(0.0f, min(1.0f, alpha))
val coalescingKey = getCoalescingKey(transitionProgress)
val container: ScreenContainer? = screen.container
val goingForward = if (container is ScreenStack) container.goingForward else false
val screenContext = screen.context as ReactContext
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, screen.id)
?.dispatchEvent(
ScreenTransitionProgressEvent(
UIManagerHelper.getSurfaceId(screenContext),
screen.id,
transitionProgress,
closing,
goingForward,
coalescingKey,
),
)
}
}
}
override fun addChildScreenContainer(container: ScreenContainer) {
childScreenContainers.add(container)
}
override fun removeChildScreenContainer(container: ScreenContainer) {
childScreenContainers.remove(container)
}
override fun onViewAnimationStart() {
dispatchViewAnimationEvent(false)
}
override fun onViewAnimationEnd() {
dispatchViewAnimationEvent(true)
}
private fun dispatchViewAnimationEvent(animationEnd: Boolean) {
isTransitioning = !animationEnd
// if parent fragment is transitioning, we do not want the events dispatched from the child,
// since we subscribe to parent's animation start/end and dispatch events in child from there
// check for `isTransitioning` should be enough since the child's animation should take only
// 20ms due to always being `StackAnimation.NONE` when nested stack being pushed
val parent = parentFragment
if (parent == null || (parent is ScreenFragment && !parent.isTransitioning)) {
// onViewAnimationStart/End is triggered from View#onAnimationStart/End method of the fragment's root
// view. We override an appropriate method of the StackFragment's
// root view in order to achieve this.
if (isResumed) {
// Android dispatches the animation start event for the fragment that is being added first
// however we want the one being dismissed first to match iOS. It also makes more sense from
// a navigation point of view to have the disappear event first.
// Since there are no explicit relationships between the fragment being added / removed the
// practical way to fix this is delaying dispatching the appear events at the end of the
// frame.
UiThreadUtil.runOnUiThread {
if (animationEnd) dispatchOnAppear() else dispatchOnWillAppear()
}
} else {
if (animationEnd) dispatchOnDisappear() else dispatchOnWillDisappear()
}
}
}
override fun onDestroy() {
super.onDestroy()
val container = screen.container
if (container == null || !container.hasScreen(this.screen.fragmentWrapper)) {
// we only send dismissed even when the screen has been removed from its container
val screenContext = screen.context
if (screenContext is ReactContext) {
val surfaceId = UIManagerHelper.getSurfaceId(screenContext)
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, screen.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
}
}
childScreenContainers.clear()
}
companion object {
const val TAG = "ScreenFragment"
fun getCoalescingKey(progress: Float): Short {
/* We want value of 0 and 1 to be always dispatched so we base coalescing key on the progress:
- progress is 0 -> key 1
- progress is 1 -> key 2
- progress is between 0 and 1 -> key 3
*/
return (
if (progress == 0.0f) {
1
} else if (progress == 1.0f) {
2
} else {
3
}
).toShort()
}
}
}
@@ -0,0 +1,41 @@
package com.swmansion.rnscreens
import android.app.Activity
import com.facebook.react.bridge.ReactContext
interface ScreenFragmentWrapper :
FragmentHolder,
ScreenEventDispatcher {
var screen: Screen
// Communication with container
val childScreenContainers: List<ScreenContainer>
fun addChildScreenContainer(container: ScreenContainer)
fun removeChildScreenContainer(container: ScreenContainer)
/**
* Container that this fragment belongs to calls it to notify the fragment,
* that the container has updated.
*/
fun onContainerUpdate()
// Animation phase callbacks
fun onViewAnimationStart()
fun onViewAnimationEnd()
// Fragment information
/**
* Whether this screen fragment makes it possible to see content underneath it
* (not fully opaque or does not fill full screen).
*/
fun isTranslucent(): Boolean
// Helpers
fun tryGetActivity(): Activity?
fun tryGetContext(): ReactContext?
}
@@ -0,0 +1,286 @@
package com.swmansion.rnscreens
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.swmansion.rnscreens.bottomsheet.BottomSheetDialogRootView
import com.swmansion.rnscreens.bottomsheet.BottomSheetDialogScreen
import com.swmansion.rnscreens.bottomsheet.SheetUtils
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.ext.parentAsView
import com.swmansion.rnscreens.ext.recycle
class ScreenModalFragment :
BottomSheetDialogFragment,
ScreenStackFragmentWrapper {
override lateinit var screen: Screen
// Nested containers
override val childScreenContainers = ArrayList<ScreenContainer>()
private val container: ScreenStack?
get() = screen.container as? ScreenStack
/**
* Dialog instance. Note that we are responsible for creating the dialog.
* This member is valid after `onCreateDialog` method runs.
*/
private lateinit var sheetDialog: BottomSheetDialog
/**
* Behaviour attached to bottom sheet dialog.
* This member is valid after `onCreateDialog` method runs.
*/
private val behavior
get() = sheetDialog.behavior
override val fragment: Fragment
get() = this
constructor() {
throw IllegalStateException(
"Screen fragments should never be restored. Follow instructions from https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704067 to properly configure your main activity.",
)
}
constructor(screen: Screen) : super() {
this.screen = screen
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Right now whole purpose of this Fragment is to be displayed as a dialog.
// I've experimented with setting false here, but could not get it to work.
showsDialog = true
}
// We override this method to provide our custom dialog type instead of the default Dialog.
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
configureDialogAndBehaviour()
val reactEventDispatcher =
checkNotNull(
screen.reactEventDispatcher,
) { "[RNScreens] No ReactEventDispatcher attached to screen while creating modal fragment" }
val rootView = BottomSheetDialogRootView(screen.reactContext, reactEventDispatcher)
rootView.addView(screen.recycle())
sheetDialog.setContentView(rootView)
rootView.parentAsView()?.clipToOutline = true
return sheetDialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? = null
override fun isTranslucent(): Boolean = true
override fun dismissFromContainer() {
check(container is ScreenStack)
val container = container as ScreenStack
container.dismiss(this)
}
// Modal can never be first on the stack
override fun canNavigateBack(): Boolean = true
override fun addChildScreenContainer(container: ScreenContainer) {
childScreenContainers.add(container)
}
override fun removeChildScreenContainer(container: ScreenContainer) {
childScreenContainers.remove(container)
}
override fun onContainerUpdate() {
}
override fun onViewAnimationStart() {
}
override fun onViewAnimationEnd() {
}
override fun tryGetActivity(): Activity? = requireActivity()
override fun tryGetContext(): ReactContext? {
if (context is ReactContext) {
return context as ReactContext
}
if (screen.context is ReactContext) {
return screen.context as ReactContext
}
var parent: ViewParent? = screen.container
while (parent != null) {
if (parent is Screen && parent.context is ReactContext) {
return parent.context as ReactContext
}
parent = parent.parent
}
return null
}
override fun canDispatchLifecycleEvent(event: ScreenFragment.ScreenLifecycleEvent): Boolean {
TODO("Not yet implemented")
}
override fun updateLastEventDispatched(event: ScreenFragment.ScreenLifecycleEvent) {
TODO("Not yet implemented")
}
override fun dispatchLifecycleEvent(
event: ScreenFragment.ScreenLifecycleEvent,
fragmentWrapper: ScreenFragmentWrapper,
) {
TODO("Not yet implemented")
}
override fun dispatchLifecycleEventInChildContainers(event: ScreenFragment.ScreenLifecycleEvent) {
TODO("Not yet implemented")
}
override fun dispatchHeaderBackButtonClickedEvent() {
TODO("Not yet implemented")
}
override fun dispatchTransitionProgressEvent(
alpha: Float,
closing: Boolean,
) {
TODO("Not yet implemented")
}
override fun onDestroy() {
super.onDestroy()
val container = container
if (container == null || !container.hasScreen(this)) {
val screenContext = screen.context
if (screenContext is ReactContext) {
val surfaceId = UIManagerHelper.getSurfaceId(screenContext)
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, screen.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
}
}
childScreenContainers.clear()
}
override fun removeToolbar(): Unit = throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now")
override fun setToolbar(toolbar: Toolbar): Unit =
throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now")
override fun setToolbarShadowHidden(hidden: Boolean): Unit =
throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now")
override fun setToolbarTranslucent(translucent: Boolean): Unit =
throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now")
private fun configureDialogAndBehaviour(): BottomSheetDialog {
sheetDialog = BottomSheetDialogScreen(requireContext(), this)
sheetDialog.dismissWithAnimation = true
sheetDialog.setCanceledOnTouchOutside(screen.sheetClosesOnTouchOutside)
configureBehaviour()
return sheetDialog
}
/**
* This method might return slightly different values depending on code path,
* but during testing I've found this effect negligible. For practical purposes
* this is acceptable.
*/
private fun tryResolveContainerHeight(): Int? {
screen.container?.height?.let { return it }
context
?.resources
?.displayMetrics
?.heightPixels
?.let { return it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
(context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)
?.currentWindowMetrics
?.bounds
?.height()
?.let { return it }
}
return null
}
private fun configureBehaviour() {
val containerHeight = tryResolveContainerHeight()
check(containerHeight != null) { "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" }
behavior.apply {
isHideable = true
isDraggable = true
}
when (screen.sheetDetents.count()) {
1 ->
behavior.apply {
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
isFitToContents = true
maxHeight = (screen.sheetDetents.first() * containerHeight).toInt()
}
2 ->
behavior.apply {
state =
SheetUtils.sheetStateFromDetentIndex(
screen.sheetInitialDetentIndex,
screen.sheetDetents.count(),
)
skipCollapsed = false
isFitToContents = true
peekHeight = (screen.sheetDetents[0] * containerHeight).toInt()
maxHeight = (screen.sheetDetents[1] * containerHeight).toInt()
}
3 ->
behavior.apply {
state =
SheetUtils.sheetStateFromDetentIndex(
screen.sheetInitialDetentIndex,
screen.sheetDetents.count(),
)
skipCollapsed = false
isFitToContents = false
peekHeight = (screen.sheetDetents[0] * containerHeight).toInt()
expandedOffset = ((1 - screen.sheetDetents[2]) * containerHeight).toInt()
halfExpandedRatio =
(screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat()
}
else -> throw IllegalStateException("[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.")
}
}
companion object {
const val TAG = "ScreenModalFragment"
}
}
@@ -0,0 +1,395 @@
package com.swmansion.rnscreens
import android.content.Context
import android.graphics.Canvas
import android.os.Build
import android.view.View
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.swmansion.rnscreens.Screen.StackAnimation
import com.swmansion.rnscreens.bottomsheet.requiresEnterTransitionPostponing
import com.swmansion.rnscreens.events.StackFinishTransitioningEvent
import com.swmansion.rnscreens.stack.views.ChildrenDrawingOrderStrategy
import com.swmansion.rnscreens.stack.views.ReverseFromIndex
import com.swmansion.rnscreens.stack.views.ReverseOrder
import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
import com.swmansion.rnscreens.utils.setTweenAnimations
import kotlin.collections.ArrayList
import kotlin.math.max
class ScreenStack(
context: Context?,
) : ScreenContainer(context) {
private val stack = ArrayList<ScreenStackFragmentWrapper>()
private val dismissedWrappers: MutableSet<ScreenStackFragmentWrapper> = HashSet()
private var preloadedWrappers: List<ScreenFragmentWrapper> = ArrayList()
private val drawingOpPool: MutableList<DrawingOp> = ArrayList()
private var drawingOps: MutableList<DrawingOp> = ArrayList()
private var topScreenWrapper: ScreenStackFragmentWrapper? = null
private var removalTransitionStarted = false
private var childrenDrawingOrderStrategy: ChildrenDrawingOrderStrategy? = null
private var disappearingTransitioningChildren: MutableList<View> = ArrayList()
var goingForward = false
/**
* Marks given fragment as to-be-dismissed and performs updates on container
*
* @param fragmentWrapper to-be-dismissed wrapper
*/
fun dismiss(screenFragment: ScreenStackFragmentWrapper) {
dismissedWrappers.add(screenFragment)
performUpdatesNow()
}
override val topScreen: Screen?
get() = topScreenWrapper?.screen
val fragments: ArrayList<ScreenStackFragmentWrapper>
get() = stack
val rootScreen: Screen
get() =
screenWrappers.firstOrNull { !dismissedWrappers.contains(it) }?.screen
?: throw IllegalStateException("[RNScreens] Stack has no root screen set")
override fun adapt(screen: Screen): ScreenStackFragmentWrapper =
when (screen.stackPresentation) {
Screen.StackPresentation.FORM_SHEET -> ScreenStackFragment(screen)
else -> ScreenStackFragment(screen)
}
override fun startViewTransition(view: View) {
check(view is ScreensCoordinatorLayout) { "[RNScreens] Unexpected type of ScreenStack direct subview ${view.javaClass}" }
super.startViewTransition(view)
if (view.fragment.isRemoving) {
disappearingTransitioningChildren.add(view)
}
if (disappearingTransitioningChildren.isNotEmpty()) {
childrenDrawingOrderStrategy?.enable()
}
removalTransitionStarted = true
}
override fun endViewTransition(view: View) {
super.endViewTransition(view)
disappearingTransitioningChildren.remove(view)
if (disappearingTransitioningChildren.isEmpty()) {
childrenDrawingOrderStrategy?.disable()
}
if (removalTransitionStarted) {
removalTransitionStarted = false
dispatchOnFinishTransitioning()
}
}
fun onViewAppearTransitionEnd() {
if (!removalTransitionStarted) {
dispatchOnFinishTransitioning()
}
}
/**
* Integration function. Allows other solutions to retrieve list of screens owned by this stack.
*/
fun getScreenIds(): List<String?> = screenWrappers.map { it.screen.screenId }
private fun dispatchOnFinishTransitioning() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
UIManagerHelper
.getEventDispatcherForReactTag((context as ReactContext), id)
?.dispatchEvent(StackFinishTransitioningEvent(surfaceId, id))
}
override fun removeScreenAt(index: Int) {
dismissedWrappers.remove(getScreenFragmentWrapperAt(index))
super.removeScreenAt(index)
}
override fun removeAllScreens() {
dismissedWrappers.clear()
super.removeAllScreens()
}
override fun hasScreen(screenFragmentWrapper: ScreenFragmentWrapper?): Boolean =
super.hasScreen(screenFragmentWrapper) && !dismissedWrappers.contains(screenFragmentWrapper)
override fun onUpdate() {
// When going back from a nested stack with a single screen on it, we may hit an edge case
// when all screens are dismissed and no screen is to be displayed on top. We need to gracefully
// handle the case of newTop being NULL, which happens in several places below
var newTop: ScreenFragmentWrapper? = null // newTop is nullable, see the above comment ^
// this is only set if newTop has one of transparent presentation modes
var visibleBottom: ScreenFragmentWrapper? = null
// reset, to not use previously set strategy by mistake
childrenDrawingOrderStrategy = null
// Determine new first & last visible screens.
val notDismissedWrappers =
screenWrappers
.asReversed()
.asSequence()
.filter {
!dismissedWrappers.contains(it) &&
it.screen.activityState !== Screen.ActivityState.INACTIVE
}
newTop = notDismissedWrappers.firstOrNull()
visibleBottom =
notDismissedWrappers
.dropWhile { it.isTranslucent() }
.firstOrNull()
?.takeUnless { it === newTop }
var shouldUseOpenAnimation = true
var stackAnimation: StackAnimation? = null
// We don't count preloaded screen as "already in stack" until it appears with state == ON_TOP
// See https://github.com/software-mansion/react-native-screens/pull/3062
val newTopAlreadyInStack = stack.contains(newTop) && !preloadedWrappers.contains(newTop)
val topScreenWillChange = newTop !== topScreenWrapper
if (newTop != null && !newTopAlreadyInStack) {
// if new top screen wasn't on stack we do "open animation" so long it is not the very first
// screen on stack
if (topScreenWrapper != null) {
// there was some other screen attached before
// if the previous top screen does not exist anymore and the new top was not on the stack
// before, probably replace or reset was called, so we play the "close animation".
// Otherwise it's open animation
val previousTopScreenRemainsInStack =
topScreenWrapper?.let { screenWrappers.contains(it) } == true
val isPushReplace = newTop.screen.replaceAnimation === Screen.ReplaceAnimation.PUSH
shouldUseOpenAnimation = previousTopScreenRemainsInStack || isPushReplace
// if the replace animation is `push`, the new top screen provides the animation, otherwise the previous one
stackAnimation =
if (shouldUseOpenAnimation) newTop.screen.stackAnimation else topScreenWrapper?.screen?.stackAnimation
} else {
// mTopScreen was not present before so newTop is the first screen added to a stack
// and we don't want the animation when it is entering
stackAnimation = StackAnimation.NONE
goingForward = true
}
} else if (newTop != null && topScreenWrapper != null && topScreenWillChange) {
// otherwise if we are performing top screen change we do "close animation"
shouldUseOpenAnimation = false
stackAnimation = topScreenWrapper?.screen?.stackAnimation
}
goingForward = shouldUseOpenAnimation
if (shouldUseOpenAnimation &&
newTop != null &&
needsDrawReordering(newTop, stackAnimation) &&
visibleBottom == null
) {
// When using an open animation in which screens overlap (eg. fade_from_bottom or
// slide_from_bottom), we want to draw the previous screens under the new one,
// which is apparently not the default option. Android always draws the disappearing views
// on top of the appearing one. We then reverse the order of the views so the new screen
// appears on top of the previous ones. You can read more about in the comment
// for the code we use to change that behavior:
// https://github.com/airbnb/native-navigation/blob/9cf50bf9b751b40778f473f3b19fcfe2c4d40599/lib/android/src/main/java/com/airbnb/android/react/navigation/ScreenCoordinatorLayout.java#L18
// Note: This should not be set in case there is only a single screen in stack or animation `none` is used.
// Atm needsDrawReordering implementation guards that assuming that first screen on stack uses `NONE` animation.
childrenDrawingOrderStrategy = ReverseOrder()
} else if (newTop != null &&
newTopAlreadyInStack &&
topScreenWrapper?.isTranslucent() == true &&
newTop.isTranslucent() == false
) {
// In case where we dismiss multiple transparent views we want to ensure
// that they are drawn in correct order - Android swaps them by default,
// so we need to swap the swap to unswap :D
val dismissedTransparentScreenApproxCount =
stack
.asReversed()
.asSequence()
.takeWhile {
it !== newTop &&
it.isTranslucent()
}.count()
if (dismissedTransparentScreenApproxCount > 1) {
childrenDrawingOrderStrategy =
ReverseFromIndex(max(stack.lastIndex - dismissedTransparentScreenApproxCount + 1, 0))
}
}
createTransaction().let { transaction ->
if (stackAnimation != null) {
transaction.setTweenAnimations(stackAnimation, shouldUseOpenAnimation)
}
// Remove all screens that are currently on stack, but should be dismissed, because they're
// no longer rendered or were dismissed natively.
stack
.asSequence()
.filter { wrapper ->
!screenWrappers.contains(wrapper) ||
dismissedWrappers.contains(
wrapper,
)
}.forEach { wrapper -> transaction.remove(wrapper.fragment) }
// Remove all screens underneath visibleBottom && these marked for preload, but keep newTop.
screenWrappers
.asSequence()
.takeWhile { it !== visibleBottom }
.filter { (it !== newTop && !dismissedWrappers.contains(it)) || it.screen.activityState === Screen.ActivityState.INACTIVE }
.forEach { wrapper -> transaction.remove(wrapper.fragment) }
// attach screens that just became visible
if (visibleBottom != null && !visibleBottom.fragment.isAdded) {
val top = newTop
screenWrappers
.asSequence()
.dropWhile { it !== visibleBottom } // ignore all screens beneath the visible bottom
.forEach { wrapper ->
// TODO: It should be enough to dispatch this on commit action once.
transaction.add(id, wrapper.fragment).runOnCommit {
top?.screen?.bringToFront()
}
}
} else if (newTop != null && !newTop.fragment.isAdded) {
if (newTop.screen.requiresEnterTransitionPostponing()) {
newTop.fragment.postponeEnterTransition()
}
transaction.add(id, newTop.fragment)
}
topScreenWrapper = newTop as? ScreenStackFragmentWrapper
stack.clear()
stack.addAll(screenWrappers.asSequence().map { it as ScreenStackFragmentWrapper })
// All screens that were displayed at some point in time should, confusingly,
// have state set to ON_TOP == 2, and the ones that were preloaded should have state INACTIVE == 0
// There could be special cases that I didn't know of at the time of writing,
// and the list could contain some other inactive screens that were not being preloaded
// but we are only really interested in and check the INACTIVE screens that are above
// newTop screen, which ARE the preloaded ones
preloadedWrappers =
screenWrappers
.asSequence()
.filter { it.screen.activityState == Screen.ActivityState.INACTIVE }
.toList()
turnOffA11yUnderTransparentScreen(visibleBottom)
transaction.commitNowAllowingStateLoss()
}
}
// only top visible screen should be accessible
private fun turnOffA11yUnderTransparentScreen(visibleBottom: ScreenFragmentWrapper?) {
if (screenWrappers.size > 1 && visibleBottom != null) {
topScreenWrapper?.let {
if (it.isTranslucent()) {
val screenFragmentsBeneathTop =
screenWrappers.slice(0 until screenWrappers.size - 1).asReversed()
// go from the top of the stack excluding the top screen
for (fragmentWrapper in screenFragmentsBeneathTop) {
fragmentWrapper.screen.changeAccessibilityMode(
IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
)
// don't change a11y below non-transparent screens
if (fragmentWrapper == visibleBottom) {
break
}
}
}
}
}
topScreen?.changeAccessibilityMode(IMPORTANT_FOR_ACCESSIBILITY_AUTO)
}
override fun notifyContainerUpdate() {
stack.forEach { it.onContainerUpdate() }
}
private fun drawAndRelease() {
// We make a copy of the drawingOps and use it to dispatch draws in order to be sure
// that we do not modify the original list. There are cases when `op.draw` can call
// `drawChild` which would modify the list through which we are iterating. See more:
// https://github.com/software-mansion/react-native-screens/pull/1406
val drawingOpsCopy = drawingOps
drawingOps = ArrayList()
for (op in drawingOpsCopy) {
op.draw()
drawingOpPool.add(op)
}
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
childrenDrawingOrderStrategy?.apply(drawingOps)
drawAndRelease()
}
override fun drawChild(
canvas: Canvas,
child: View,
drawingTime: Long,
): Boolean {
drawingOps.add(
obtainDrawingOp().apply {
this.canvas = canvas
this.child = child
this.drawingTime = drawingTime
},
)
return true
}
private fun performDraw(op: DrawingOp) {
// Canvas parameter can not be null here https://developer.android.com/reference/android/view/ViewGroup#drawChild(android.graphics.Canvas,%20android.view.View,%20long)
// So if we are passing null here, we would crash anyway
super.drawChild(op.canvas!!, op.child, op.drawingTime)
}
// Can't use `drawingOpPool.removeLast` here due to issues with static name resolution in Android SDK 35+.
// See: https://developer.android.com/about/versions/15/behavior-changes-15?hl=en#openjdk-api-changes
private fun obtainDrawingOp(): DrawingOp = if (drawingOpPool.isEmpty()) DrawingOp() else drawingOpPool.removeAt(drawingOpPool.lastIndex)
internal inner class DrawingOp {
var canvas: Canvas? = null
var child: View? = null
var drawingTime: Long = 0
fun draw() {
performDraw(this)
canvas = null
child = null
drawingTime = 0
}
}
companion object {
const val TAG = "ScreenStack"
private fun needsDrawReordering(
fragmentWrapper: ScreenFragmentWrapper,
resolvedStackAnimation: StackAnimation?,
): Boolean {
val stackAnimation = resolvedStackAnimation ?: fragmentWrapper.screen.stackAnimation
// On Android sdk 33 and above the animation is different and requires draw reordering.
// For React Native 0.70 and lower versions, `Build.VERSION_CODES.TIRAMISU` is not defined yet.
// Hence, we're comparing numerical version here.
return (
Build.VERSION.SDK_INT >= 33 ||
stackAnimation === StackAnimation.SLIDE_FROM_BOTTOM ||
stackAnimation === StackAnimation.FADE_FROM_BOTTOM ||
stackAnimation === StackAnimation.IOS_FROM_RIGHT ||
stackAnimation === StackAnimation.IOS_FROM_LEFT
) &&
stackAnimation !== StackAnimation.NONE
}
}
}
@@ -0,0 +1,490 @@
package com.swmansion.rnscreens
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.widget.LinearLayout
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.UIManagerHelper
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.swmansion.rnscreens.bottomsheet.DimmingViewManager
import com.swmansion.rnscreens.bottomsheet.SheetDelegate
import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
import com.swmansion.rnscreens.events.ScreenAnimationDelegate
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.events.ScreenEventEmitter
import com.swmansion.rnscreens.ext.recycle
import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
import com.swmansion.rnscreens.utils.DeviceUtils
import com.swmansion.rnscreens.utils.resolveBackgroundColor
sealed class KeyboardState
object KeyboardNotVisible : KeyboardState()
object KeyboardDidHide : KeyboardState()
class KeyboardVisible(
val height: Int,
) : KeyboardState()
class ScreenStackFragment :
ScreenFragment,
ScreenStackFragmentWrapper {
private var appBarLayout: AppBarLayout? = null
private var toolbar: Toolbar? = null
private var isToolbarShadowHidden = false
private var isToolbarTranslucent = false
private var lastFocusedChild: View? = null
var searchView: CustomSearchView? = null
var onSearchViewCreate: ((searchView: CustomSearchView) -> Unit)? = null
private lateinit var coordinatorLayout: ScreensCoordinatorLayout
private val screenStack: ScreenStack
get() {
val container = screen.container
check(container is ScreenStack) { "ScreenStackFragment added into a non-stack container" }
return container
}
private var dimmingDelegate: DimmingViewManager? = null
internal var sheetDelegate: SheetDelegate? = null
@SuppressLint("ValidFragment")
constructor(screenView: Screen) : super(screenView)
constructor() {
throw IllegalStateException(
"ScreenStack fragments should never be restored. Follow instructions from https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704067 to properly configure your main activity.",
)
}
override fun isTranslucent(): Boolean = screen.isTranslucent()
override fun removeToolbar() {
appBarLayout?.let {
toolbar?.let { toolbar ->
if (toolbar.parent === it) {
it.removeView(toolbar)
}
}
}
toolbar = null
}
override fun setToolbar(toolbar: Toolbar) {
appBarLayout?.addView(toolbar)
toolbar.layoutParams =
AppBarLayout
.LayoutParams(
AppBarLayout.LayoutParams.MATCH_PARENT,
AppBarLayout.LayoutParams.WRAP_CONTENT,
).apply { scrollFlags = 0 }
this.toolbar = toolbar
}
override fun setToolbarShadowHidden(hidden: Boolean) {
if (isToolbarShadowHidden != hidden) {
appBarLayout?.elevation = if (hidden) 0f else PixelUtil.toPixelFromDIP(4f)
appBarLayout?.stateListAnimator = null
isToolbarShadowHidden = hidden
}
}
override fun setToolbarTranslucent(translucent: Boolean) {
if (isToolbarTranslucent != translucent) {
val params = screen.layoutParams
(params as CoordinatorLayout.LayoutParams).behavior =
if (translucent) null else ScrollingViewBehavior()
isToolbarTranslucent = translucent
}
}
override fun onContainerUpdate() {
super.onContainerUpdate()
screen.headerConfig?.onUpdate()
}
override fun onViewAnimationEnd() {
super.onViewAnimationEnd()
// Rely on guards inside the callee to detect whether this was indeed appear transition.
notifyViewAppearTransitionEnd()
// Rely on guards inside the callee to detect whether this was indeed removal transition.
screen.endRemovalTransition()
}
private fun notifyViewAppearTransitionEnd() {
val screenStack = view?.parent
if (screenStack is ScreenStack) {
screenStack.onViewAppearTransitionEnd()
}
}
/**
* Currently this method dispatches event to JS where state is recomputed and fragment
* gets removed in the result of incoming state update.
*/
internal fun dismissSelf() {
if (!this.isRemoving || !this.isDetached) {
val reactContext = screen.reactContext
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
UIManagerHelper
.getEventDispatcherForReactTag(reactContext, screen.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
}
}
internal fun onSheetCornerRadiusChange() {
screen.onSheetCornerRadiusChange()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
coordinatorLayout = ScreensCoordinatorLayout(requireContext(), this)
screen.layoutParams =
CoordinatorLayout
.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
).apply {
behavior =
if (screen.usesFormSheetPresentation()) {
createBottomSheetBehaviour()
} else if (isToolbarTranslucent) {
null
} else {
ScrollingViewBehavior()
}
}
// This must be called before further sheet configuration.
// Otherwise there is no enter animation -> dunno why, just observed it.
coordinatorLayout.addView(screen.recycle())
if (!screen.usesFormSheetPresentation()) {
appBarLayout =
context?.let { AppBarLayout(it) }?.apply {
// By default AppBarLayout will have a background color set but since we cover the whole layout
// with toolbar (that can be semi-transparent) the bar layout background color does not pay a
// role. On top of that it breaks screens animations when alfa offscreen compositing is off
// (which is the default)
setBackgroundColor(Color.TRANSPARENT)
layoutParams =
AppBarLayout.LayoutParams(
AppBarLayout.LayoutParams.MATCH_PARENT,
AppBarLayout.LayoutParams.WRAP_CONTENT,
)
}
coordinatorLayout.addView(appBarLayout)
if (isToolbarShadowHidden) {
appBarLayout?.targetElevation = 0f
}
toolbar?.let { appBarLayout?.addView(it.recycle()) }
setHasOptionsMenu(true)
} else {
screen.clipToOutline = true
// TODO(@kkafar): without this line there is no drawable / outline & nothing shows...? Determine what's going on here
attachShapeToScreen(screen)
screen.elevation = screen.sheetElevation
// Lifecycle of sheet delegate is tied to fragment.
val sheetDelegate = requireSheetDelegate()
sheetDelegate.configureBottomSheetBehaviour(screen.sheetBehavior!!)
val dimmingDelegate = requireDimmingDelegate(forceCreation = true)
dimmingDelegate.onViewHierarchyCreated(screen, coordinatorLayout)
dimmingDelegate.onBehaviourAttached(screen, screen.sheetBehavior!!)
// Pre-layout the content for the sake of enter transition.
val container = screen.container!!
coordinatorLayout.measure(
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(container.height, View.MeasureSpec.EXACTLY),
)
coordinatorLayout.layout(0, 0, container.width, container.height)
// Replace InsetsAnimationCallback created by BottomSheetBehavior with empty
// implementation so it does not interfere with our custom formSheet entering animation
// More details: https://github.com/software-mansion/react-native-screens/pull/2909
ViewCompat.setWindowInsetsAnimationCallback(
screen,
object : WindowInsetsAnimationCompat.Callback(
DISPATCH_MODE_STOP,
) {
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
): WindowInsetsCompat = insets
},
)
}
return coordinatorLayout
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
}
override fun onCreateAnimation(
transit: Int,
enter: Boolean,
nextAnim: Int,
): Animation? {
// Ensure onCreateAnimator is called
return null
}
override fun onCreateAnimator(
transit: Int,
enter: Boolean,
nextAnim: Int,
): Animator? {
if (!screen.usesFormSheetPresentation()) {
// Use animation defined while defining transaction in screen stack
return null
}
val animatorSet = AnimatorSet()
val dimmingDelegate = requireDimmingDelegate()
if (enter) {
val alphaAnimator =
ValueAnimator.ofFloat(0f, dimmingDelegate.maxAlpha).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { dimmingDelegate.dimmingView.alpha = it }
}
}
val startValueCallback = { initialStartValue: Number? -> screen.height.toFloat() }
val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })
val slideAnimator =
ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { screen.translationY = it }
}
}
animatorSet
.play(slideAnimator)
.takeIf {
dimmingDelegate.willDimForDetentIndex(
screen,
screen.sheetInitialDetentIndex,
)
}?.with(alphaAnimator)
} else {
val alphaAnimator =
ValueAnimator.ofFloat(dimmingDelegate.dimmingView.alpha, 0f).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { dimmingDelegate.dimmingView.alpha = it }
}
}
val slideAnimator =
ValueAnimator.ofFloat(0f, (coordinatorLayout.bottom - screen.top).toFloat()).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { screen.translationY = it }
}
}
animatorSet.play(alphaAnimator).with(slideAnimator)
}
animatorSet.addListener(
ScreenAnimationDelegate(
this,
ScreenEventEmitter(this.screen),
if (enter) {
ScreenAnimationDelegate.AnimationType.ENTER
} else {
ScreenAnimationDelegate.AnimationType.EXIT
},
),
)
return animatorSet
}
private fun createBottomSheetBehaviour(): BottomSheetBehavior<Screen> = BottomSheetBehavior<Screen>()
private fun resolveBackgroundColor(screen: Screen): Int? {
val screenColor =
(screen.background as? ColorDrawable?)?.color
?: (screen.background as? MaterialShapeDrawable?)?.tintList?.defaultColor
if (screenColor != null) {
return screenColor
}
val contentWrapper = screen.contentWrapper
if (contentWrapper == null) {
return null
}
val contentWrapperColor = contentWrapper.resolveBackgroundColor()
return contentWrapperColor
}
private fun attachShapeToScreen(screen: Screen) {
val cornerSize = PixelUtil.toPixelFromDIP(screen.sheetCornerRadius)
val shapeAppearanceModel =
ShapeAppearanceModel
.Builder()
.apply {
setTopLeftCorner(CornerFamily.ROUNDED, cornerSize)
setTopRightCorner(CornerFamily.ROUNDED, cornerSize)
}.build()
val shape = MaterialShapeDrawable(shapeAppearanceModel)
val backgroundColor = resolveBackgroundColor(screen)
shape.setTint(backgroundColor ?: Color.TRANSPARENT)
screen.background = shape
}
override fun onStart() {
lastFocusedChild?.requestFocus()
super.onStart()
}
override fun onStop() {
if (DeviceUtils.isPlatformAndroidTV(context)) {
lastFocusedChild = findLastFocusedChild()
}
super.onStop()
}
override fun onPrepareOptionsMenu(menu: Menu) {
// If the screen is a transparent modal with hidden header we don't want to update the toolbar
// menu because it may erase the menu of the previous screen (which is still visible in these
// circumstances). See here: https://github.com/software-mansion/react-native-screens/issues/2271
if (!screen.isTranslucent() || screen.headerConfig?.isHeaderHidden == false) {
updateToolbarMenu(menu)
}
return super.onPrepareOptionsMenu(menu)
}
override fun onCreateOptionsMenu(
menu: Menu,
inflater: MenuInflater,
) {
updateToolbarMenu(menu)
return super.onCreateOptionsMenu(menu, inflater)
}
private fun shouldShowSearchBar(): Boolean {
val config = screen.headerConfig
val numberOfSubViews = config?.configSubviewsCount ?: 0
if (config != null && numberOfSubViews > 0) {
for (i in 0 until numberOfSubViews) {
val subView = config.getConfigSubview(i)
if (subView.type == ScreenStackHeaderSubview.Type.SEARCH_BAR) {
return true
}
}
}
return false
}
private fun updateToolbarMenu(menu: Menu) {
menu.clear()
if (shouldShowSearchBar()) {
val currentContext = context
if (searchView == null && currentContext != null) {
val newSearchView = CustomSearchView(currentContext, this)
searchView = newSearchView
onSearchViewCreate?.invoke(newSearchView)
}
menu.add("").apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
actionView = searchView
}
}
}
private fun findLastFocusedChild(): View? {
var view: View? = screen
while (view != null) {
if (view.isFocused) return view
view = if (view is ViewGroup) view.focusedChild else null
}
return null
}
override fun canNavigateBack(): Boolean {
val container: ScreenContainer? = screen.container
check(container is ScreenStack) { "ScreenStackFragment added into a non-stack container" }
return if (container.rootScreen == screen) {
// this screen is the root of the container, if it is nested we can check parent container
// if it is also a root or not
val parentFragment = parentFragment
if (parentFragment is ScreenStackFragment) {
parentFragment.canNavigateBack()
} else {
false
}
} else {
true
}
}
override fun dismissFromContainer() {
screenStack.dismiss(this)
}
private fun requireDimmingDelegate(forceCreation: Boolean = false): DimmingViewManager {
if (dimmingDelegate == null || forceCreation) {
dimmingDelegate?.invalidate(screen.sheetBehavior)
dimmingDelegate = DimmingViewManager(screen.reactContext, screen)
}
return dimmingDelegate!!
}
private fun requireSheetDelegate(): SheetDelegate {
if (sheetDelegate == null) {
sheetDelegate = SheetDelegate(screen)
}
return sheetDelegate!!
}
}
@@ -0,0 +1,22 @@
package com.swmansion.rnscreens
import androidx.appcompat.widget.Toolbar
interface ScreenStackFragmentWrapper : ScreenFragmentWrapper {
// Toolbar management
fun removeToolbar()
fun setToolbar(toolbar: Toolbar)
fun setToolbarShadowHidden(hidden: Boolean)
fun setToolbarTranslucent(translucent: Boolean)
// Navigation
fun canNavigateBack(): Boolean
/**
* Removes this fragment from the container it/it's screen belongs to.
*/
fun dismissFromContainer()
}
@@ -0,0 +1,470 @@
package com.swmansion.rnscreens
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.text.TextUtils
import android.util.TypedValue
import android.view.Gravity
import android.view.View.OnClickListener
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import com.facebook.react.ReactApplication
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.ReactPointerEventsView
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.views.text.ReactTypefaceUtils
import com.swmansion.rnscreens.events.HeaderAttachedEvent
import com.swmansion.rnscreens.events.HeaderDetachedEvent
import kotlin.math.max
class ScreenStackHeaderConfig(
context: Context,
private val pointerEventsImpl: ReactPointerEventsView,
) : FabricEnabledHeaderConfigViewGroup(context),
ReactPointerEventsView by pointerEventsImpl {
constructor(context: Context) : this(context, pointerEventsImpl = PointerEventsBoxNoneImpl())
private val configSubviews = ArrayList<ScreenStackHeaderSubview>(3)
val toolbar: CustomToolbar
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 title: String? = null
private var titleColor = 0
private var titleFontFamily: String? = null
private var direction: String? = null
private var titleFontSize = 0f
private var titleFontWeight = 0
private var backgroundColor: Int? = null
private var isBackButtonHidden = false
private var isShadowHidden = false
private var isDestroyed = false
private var backButtonInCustomView = false
var isTopInsetEnabled = true
private set
private var tintColor = 0
private var isAttachedToWindow = false
private val defaultStartInset: Int
private val defaultStartInsetWithNavigation: Int
private val backClickListener =
OnClickListener {
screenFragment?.let {
val stack = screenStack
if (stack != null && stack.rootScreen == it.screen) {
val parentFragment = it.parentFragment
if (parentFragment is ScreenStackFragment) {
if (parentFragment.screen.nativeBackButtonDismissalEnabled) {
parentFragment.dismissFromContainer()
} else {
parentFragment.dispatchHeaderBackButtonClickedEvent()
}
}
} else {
if (it.screen.nativeBackButtonDismissalEnabled) {
it.dismissFromContainer()
} else {
it.dispatchHeaderBackButtonClickedEvent()
}
}
}
}
var isTitleEmpty: Boolean = false
val preferredContentInsetStart
get() = defaultStartInset
val preferredContentInsetEnd
get() = defaultStartInset
val preferredContentInsetStartWithNavigation
get() =
// Reset toolbar insets. By default we set symmetric inset for start and end to match iOS
// implementation where both right and left icons are offset from the edge by default. We also
// reset startWithNavigation inset which corresponds to the distance between navigation icon and
// title. If title isn't set we clear that value few lines below to give more space to custom
// center-mounted views.
if (isTitleEmpty) {
0
} else {
defaultStartInsetWithNavigation
}
fun destroy() {
isDestroyed = true
}
/**
* Native toolbar should notify the header config component that it has completed its layout.
*/
fun onNativeToolbarLayout(
toolbar: Toolbar,
shouldUpdateShadowStateHint: Boolean,
) {
if (!shouldUpdateShadowStateHint) {
return
}
val isBackButtonDisplayed = toolbar.navigationIcon != null
val contentInsetStartEstimation =
if (isBackButtonDisplayed) {
toolbar.currentContentInsetStart + toolbar.paddingStart
} else {
max(toolbar.currentContentInsetStart, toolbar.paddingStart)
}
// Assuming that there is nothing to the left of back button here, the content
// offset we're interested in in ShadowTree is the `left` of the subview left.
// In case it is not available we fallback to approximation.
val contentInsetStart =
configSubviews.firstOrNull { it.type === ScreenStackHeaderSubview.Type.LEFT }?.left
?: contentInsetStartEstimation
val contentInsetEnd = toolbar.currentContentInsetEnd + toolbar.paddingEnd
// Note that implementation of the callee differs between architectures.
updateHeaderConfigState(
toolbar.width,
toolbar.height,
contentInsetStart,
contentInsetEnd,
)
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) = Unit
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isAttachedToWindow = true
val surfaceId = UIManagerHelper.getSurfaceId(this)
UIManagerHelper
.getEventDispatcherForReactTag(context as ReactContext, id)
?.dispatchEvent(HeaderAttachedEvent(surfaceId, id))
onUpdate()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
isAttachedToWindow = false
val surfaceId = UIManagerHelper.getSurfaceId(this)
UIManagerHelper
.getEventDispatcherForReactTag(context as ReactContext, id)
?.dispatchEvent(HeaderDetachedEvent(surfaceId, id))
}
private val screen: Screen?
get() = parent as? Screen
private val screenStack: ScreenStack?
get() = screen?.container as? ScreenStack
val screenFragment: ScreenStackFragment?
get() {
val screen = parent
if (screen is Screen) {
val fragment: Fragment? = screen.fragment
if (fragment is ScreenStackFragment) {
return fragment
}
}
return null
}
fun onUpdate() {
val stack = screenStack
val isTop = stack == null || stack.topScreen == parent
if (!isAttachedToWindow || !isTop || isDestroyed) {
return
}
val activity = screenFragment?.activity as AppCompatActivity? ?: return
if (direction != null) {
if (direction == "rtl") {
toolbar.layoutDirection = LAYOUT_DIRECTION_RTL
} else if (direction == "ltr") {
toolbar.layoutDirection = LAYOUT_DIRECTION_LTR
}
}
// orientation and status bar management
screen?.let {
// we set the traits here too, not only when the prop for Screen is passed
// because sometimes we don't have the Fragment and Activity available then yet, e.g. on the
// first setting of props. Similar thing is done for Screens of ScreenContainers, but in
// `onContainerUpdate` of their Fragment
val reactContext =
if (context is ReactContext) {
context as ReactContext
} else {
it.fragmentWrapper?.tryGetContext()
}
ScreenWindowTraits.trySetWindowTraits(it, activity, reactContext)
}
if (isHeaderHidden) {
if (toolbar.parent != null) {
screenFragment?.removeToolbar()
}
return
}
if (toolbar.parent == null) {
screenFragment?.setToolbar(toolbar)
}
activity.setSupportActionBar(toolbar)
// non-null toolbar is set in the line above and it is used here
val actionBar = requireNotNull(activity.supportActionBar)
// hide back button
actionBar.setDisplayHomeAsUpEnabled(
screenFragment?.canNavigateBack() == true && !isBackButtonHidden,
)
// title
actionBar.title = title
if (TextUtils.isEmpty(title)) {
isTitleEmpty = true
}
// Reset toolbar insets. By default we set symmetric inset for start and end to match iOS
// implementation where both right and left icons are offset from the edge by default. We also
// reset startWithNavigation inset which corresponds to the distance between navigation icon and
// title. If title isn't set we clear that value few lines below to give more space to custom
// center-mounted views.
toolbar.updateContentInsets()
// when setSupportActionBar is called a toolbar wrapper gets initialized that overwrites
// navigation click listener. The default behavior set in the wrapper is to call into
// menu options handlers, but we prefer the back handling logic to stay here instead.
toolbar.setNavigationOnClickListener(backClickListener)
// shadow
screenFragment?.setToolbarShadowHidden(isShadowHidden)
// translucent
screenFragment?.setToolbarTranslucent(isHeaderTranslucent)
val titleTextView = findTitleTextViewInToolbar(toolbar)
if (titleColor != 0) {
toolbar.setTitleTextColor(titleColor)
}
if (titleTextView != null) {
if (titleFontFamily != null || titleFontWeight > 0) {
val titleTypeface =
ReactTypefaceUtils.applyStyles(
null,
0,
titleFontWeight,
titleFontFamily,
context.assets,
)
titleTextView.typeface = titleTypeface
}
if (titleFontSize > 0) {
titleTextView.textSize = titleFontSize
}
}
// background
backgroundColor?.let { toolbar.setBackgroundColor(it) }
// color
if (tintColor != 0) {
toolbar.navigationIcon?.colorFilter =
PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_ATOP)
}
// subviews
for (i in toolbar.childCount - 1 downTo 0) {
if (toolbar.getChildAt(i) is ScreenStackHeaderSubview) {
toolbar.removeViewAt(i)
}
}
var i = 0
val size = configSubviews.size
while (i < size) {
val view = configSubviews[i]
val type = view.type
if (type === ScreenStackHeaderSubview.Type.BACK) {
// we special case BACK button header config type as we don't add it as a view into toolbar
// but instead just copy the drawable from imageview that's added as a first child to it.
val firstChild =
view.getChildAt(0) as? ImageView
?: throw JSApplicationIllegalArgumentException(
"Back button header config view should have Image as first child",
)
actionBar.setHomeAsUpIndicator(firstChild.drawable)
i++
continue
}
val params = Toolbar.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)
when (type) {
ScreenStackHeaderSubview.Type.LEFT -> {
// when there is a left item we need to disable navigation icon by default
// we also hide title as there is no other way to display left side items
if (!backButtonInCustomView) {
toolbar.navigationIcon = null
}
toolbar.title = null
params.gravity = Gravity.START
}
ScreenStackHeaderSubview.Type.RIGHT -> params.gravity = Gravity.END
ScreenStackHeaderSubview.Type.CENTER -> {
params.width = LayoutParams.MATCH_PARENT
params.gravity = Gravity.CENTER_HORIZONTAL
toolbar.title = null
}
else -> {}
}
view.layoutParams = params
toolbar.addView(view)
i++
}
}
private fun maybeUpdate() {
if (parent != null && !isDestroyed && screen?.isBeingRemoved == false) {
onUpdate()
}
}
fun getConfigSubview(index: Int): ScreenStackHeaderSubview = configSubviews[index]
val configSubviewsCount: Int
get() = configSubviews.size
fun removeConfigSubview(index: Int) {
configSubviews.removeAt(index)
maybeUpdate()
}
fun removeAllConfigSubviews() {
configSubviews.clear()
maybeUpdate()
}
fun addConfigSubview(
child: ScreenStackHeaderSubview,
index: Int,
) {
configSubviews.add(index, child)
maybeUpdate()
}
fun setTitle(title: String?) {
this.title = title
}
fun setTitleFontFamily(titleFontFamily: String?) {
this.titleFontFamily = titleFontFamily
}
fun setTitleFontWeight(fontWeightString: String?) {
titleFontWeight = ReactTypefaceUtils.parseFontWeight(fontWeightString)
}
fun setTitleFontSize(titleFontSize: Float) {
this.titleFontSize = titleFontSize
}
fun setTitleColor(color: Int) {
titleColor = color
}
fun setTintColor(color: Int) {
tintColor = color
}
fun setTopInsetEnabled(topInsetEnabled: Boolean) {
isTopInsetEnabled = topInsetEnabled
}
fun setBackgroundColor(color: Int?) {
backgroundColor = color
}
fun setHideShadow(hideShadow: Boolean) {
isShadowHidden = hideShadow
}
fun setHideBackButton(hideBackButton: Boolean) {
isBackButtonHidden = hideBackButton
}
fun setHidden(hidden: Boolean) {
isHeaderHidden = hidden
}
fun setTranslucent(translucent: Boolean) {
isHeaderTranslucent = translucent
}
fun setBackButtonInCustomView(backButtonInCustomView: Boolean) {
this.backButtonInCustomView = backButtonInCustomView
}
fun setDirection(direction: String?) {
this.direction = direction
}
private class DebugMenuToolbar(
context: Context,
config: ScreenStackHeaderConfig,
) : CustomToolbar(context, config) {
override fun showOverflowMenu(): Boolean {
(context.applicationContext as ReactApplication)
.reactNativeHost
.reactInstanceManager
.showDevOptionsDialog()
return true
}
}
init {
visibility = GONE
toolbar =
if (BuildConfig.DEBUG) DebugMenuToolbar(context, this) else CustomToolbar(context, this)
defaultStartInset = toolbar.contentInsetStart
defaultStartInsetWithNavigation = toolbar.contentInsetStartWithNavigation
// set primary color as background by default
val tv = TypedValue()
if (context.theme.resolveAttribute(android.R.attr.colorPrimary, tv, true)) {
toolbar.setBackgroundColor(tv.data)
}
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 (TextUtils.equals(view.text, toolbar.title)) {
return view
}
}
}
return null
}
}
}
@@ -0,0 +1,28 @@
package com.swmansion.rnscreens
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.LayoutShadowNode
import com.facebook.react.uimanager.Spacing
import com.swmansion.rnscreens.utils.PaddingBundle
internal class ScreenStackHeaderConfigShadowNode(
private var context: ReactContext,
) : LayoutShadowNode() {
var paddingStart: Float = 0f
var paddingEnd: Float = 0f
var height: Float = 0f
override fun setLocalData(data: Any?) {
if (data is PaddingBundle) {
paddingStart = data.paddingStart
paddingEnd = data.paddingEnd
height = data.height
setPadding(Spacing.START, paddingStart)
setPadding(Spacing.END, paddingEnd)
setPosition(Spacing.TOP, -height)
} else {
super.setLocalData(data)
}
}
}
@@ -0,0 +1,322 @@
package com.swmansion.rnscreens
import android.util.Log
import android.view.View
import com.facebook.react.bridge.JSApplicationCausedNativeException
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.LayoutShadowNode
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.RNSScreenStackHeaderConfigManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenStackHeaderConfigManagerInterface
import com.swmansion.rnscreens.events.HeaderAttachedEvent
import com.swmansion.rnscreens.events.HeaderDetachedEvent
import javax.annotation.Nonnull
@ReactModule(name = ScreenStackHeaderConfigViewManager.REACT_CLASS)
class ScreenStackHeaderConfigViewManager :
ViewGroupManager<ScreenStackHeaderConfig>(),
RNSScreenStackHeaderConfigManagerInterface<ScreenStackHeaderConfig> {
private val delegate: ViewManagerDelegate<ScreenStackHeaderConfig>
init {
delegate = RNSScreenStackHeaderConfigManagerDelegate<ScreenStackHeaderConfig, ScreenStackHeaderConfigViewManager>(this)
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) = ScreenStackHeaderConfig(reactContext)
// This works only on Paper. On Fabric the shadow node is implemented in C++ layer.
override fun createShadowNodeInstance(context: ReactApplicationContext): LayoutShadowNode = ScreenStackHeaderConfigShadowNode(context)
override fun addView(
parent: ScreenStackHeaderConfig,
child: View,
index: Int,
) {
if (child !is ScreenStackHeaderSubview) {
throw JSApplicationCausedNativeException(
"Config children should be of type " + ScreenStackHeaderSubviewManager.REACT_CLASS,
)
}
parent.addConfigSubview(child, index)
}
override fun updateState(
view: ScreenStackHeaderConfig,
props: ReactStylesDiffMap?,
stateWrapper: StateWrapper?,
): Any? {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
view.setStateWrapper(stateWrapper)
}
return super.updateState(view, props, stateWrapper)
}
override fun onDropViewInstance(
@Nonnull view: ScreenStackHeaderConfig,
) {
view.destroy()
}
override fun removeAllViews(parent: ScreenStackHeaderConfig) {
parent.removeAllConfigSubviews()
}
override fun removeViewAt(
parent: ScreenStackHeaderConfig,
index: Int,
) {
parent.removeConfigSubview(index)
}
override fun getChildCount(parent: ScreenStackHeaderConfig): Int = parent.configSubviewsCount
override fun getChildAt(
parent: ScreenStackHeaderConfig,
index: Int,
): View = parent.getConfigSubview(index)
override fun needsCustomLayoutForChildren() = true
override fun onAfterUpdateTransaction(parent: ScreenStackHeaderConfig) {
super.onAfterUpdateTransaction(parent)
parent.onUpdate()
}
@ReactProp(name = "title")
override fun setTitle(
config: ScreenStackHeaderConfig,
title: String?,
) {
config.setTitle(title)
}
@ReactProp(name = "titleFontFamily")
override fun setTitleFontFamily(
config: ScreenStackHeaderConfig,
titleFontFamily: String?,
) {
config.setTitleFontFamily(titleFontFamily)
}
@ReactProp(name = "titleFontSize")
override fun setTitleFontSize(
config: ScreenStackHeaderConfig,
titleFontSize: Int,
) {
config.setTitleFontSize(titleFontSize.toFloat())
}
@ReactProp(name = "titleFontWeight")
override fun setTitleFontWeight(
config: ScreenStackHeaderConfig,
titleFontWeight: String?,
) {
config.setTitleFontWeight(titleFontWeight)
}
@ReactProp(name = "titleColor", customType = "Color")
override fun setTitleColor(
config: ScreenStackHeaderConfig,
titleColor: Int?,
) {
if (titleColor != null) {
config.setTitleColor(titleColor)
}
}
@ReactProp(name = "backgroundColor", customType = "Color")
override fun setBackgroundColor(
config: ScreenStackHeaderConfig,
backgroundColor: Int?,
) {
config.setBackgroundColor(backgroundColor)
}
@ReactProp(name = "hideShadow")
override fun setHideShadow(
config: ScreenStackHeaderConfig,
hideShadow: Boolean,
) {
config.setHideShadow(hideShadow)
}
@ReactProp(name = "hideBackButton")
override fun setHideBackButton(
config: ScreenStackHeaderConfig,
hideBackButton: Boolean,
) {
config.setHideBackButton(hideBackButton)
}
@Deprecated("For apps targeting SDK 35 or above edge-to-edge is enabled by default.")
@ReactProp(name = "topInsetEnabled")
override fun setTopInsetEnabled(
config: ScreenStackHeaderConfig,
topInsetEnabled: Boolean,
) {
config.setTopInsetEnabled(topInsetEnabled)
}
@ReactProp(name = "color", customType = "Color")
override fun setColor(
config: ScreenStackHeaderConfig,
color: Int?,
) {
config.setTintColor(color ?: 0)
}
@ReactProp(name = "hidden")
override fun setHidden(
config: ScreenStackHeaderConfig,
hidden: Boolean,
) {
config.setHidden(hidden)
}
@ReactProp(name = "translucent")
override fun setTranslucent(
config: ScreenStackHeaderConfig,
translucent: Boolean,
) {
config.setTranslucent(translucent)
}
@ReactProp(name = "backButtonInCustomView")
override fun setBackButtonInCustomView(
config: ScreenStackHeaderConfig,
backButtonInCustomView: Boolean,
) {
config.setBackButtonInCustomView(backButtonInCustomView)
}
@ReactProp(name = "direction")
override fun setDirection(
config: ScreenStackHeaderConfig,
direction: String?,
) {
config.setDirection(direction)
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
hashMapOf(
HeaderAttachedEvent.EVENT_NAME to hashMapOf("registrationName" to "onAttached"),
HeaderDetachedEvent.EVENT_NAME to hashMapOf("registrationName" to "onDetached"),
)
protected override fun getDelegate(): ViewManagerDelegate<ScreenStackHeaderConfig> = delegate
companion object {
const val REACT_CLASS = "RNSScreenStackHeaderConfig"
}
// TODO: Find better way to handle platform specific props
private fun logNotAvailable(propName: String) {
Log.w("[RNScreens]", "$propName prop is not available on Android")
}
override fun setBackTitle(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("backTitle")
}
override fun setBackTitleFontFamily(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("backTitleFontFamily")
}
override fun setBackTitleFontSize(
view: ScreenStackHeaderConfig?,
value: Int,
) {
logNotAvailable("backTitleFontSize")
}
override fun setBackTitleVisible(
view: ScreenStackHeaderConfig?,
value: Boolean,
) {
logNotAvailable("backTitleVisible")
}
override fun setLargeTitle(
view: ScreenStackHeaderConfig?,
value: Boolean,
) {
logNotAvailable("largeTitle")
}
override fun setLargeTitleFontFamily(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("largeTitleFontFamily")
}
override fun setLargeTitleFontSize(
view: ScreenStackHeaderConfig?,
value: Int,
) {
logNotAvailable("largeTitleFontSize")
}
override fun setLargeTitleFontWeight(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("largeTitleFontWeight")
}
override fun setLargeTitleBackgroundColor(
view: ScreenStackHeaderConfig?,
value: Int?,
) {
logNotAvailable("largeTitleBackgroundColor")
}
override fun setLargeTitleHideShadow(
view: ScreenStackHeaderConfig?,
value: Boolean,
) {
logNotAvailable("largeTitleHideShadow")
}
override fun setLargeTitleColor(
view: ScreenStackHeaderConfig?,
value: Int?,
) {
logNotAvailable("largeTitleColor")
}
override fun setDisableBackButtonMenu(
view: ScreenStackHeaderConfig?,
value: Boolean,
) {
logNotAvailable("disableBackButtonMenu")
}
override fun setBackButtonDisplayMode(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("backButtonDisplayMode")
}
override fun setBlurEffect(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("blurEffect")
}
}
@@ -0,0 +1,75 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.view.View
import com.facebook.react.bridge.ReactContext
@SuppressLint("ViewConstructor")
class ScreenStackHeaderSubview(
context: ReactContext?,
) : FabricEnabledHeaderSubviewViewGroup(context) {
private var reactWidth = 0
private var reactHeight = 0
/**
* Semantics: true iff we **believe** that SurfaceMountingManager has measured this view during mount item
* execution. We recognize this case by checking measure mode in `onMeasure`. If Androidx
* happens to use `EXACTLY` for both dimensions this property might convey invalid information.
*/
private var isReactSizeSet = false
var type = Type.RIGHT
val config: ScreenStackHeaderConfig?
get() = (parent as? CustomToolbar)?.config
override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int,
) {
if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY &&
MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY
) {
// dimensions provided by react (with high probability)
reactWidth = MeasureSpec.getSize(widthMeasureSpec)
reactHeight = MeasureSpec.getSize(heightMeasureSpec)
isReactSizeSet = true
val parent = parent
if (parent != null) {
forceLayout()
(parent as View).requestLayout()
}
}
setMeasuredDimension(reactWidth, reactHeight)
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
if (changed && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
val width = r - l
val height = b - t
// When setting subviews via `setOptions` from `useEffect` hook in a component, the first
// frame received might be computed by native layout & completely invalid (zero height).
// RN layout is the source of subview **size** (not origin) & we need to avoid sending
// this native size to ST. Doing otherwise might lead to problems.
// See: https://github.com/software-mansion/react-native-screens/pull/2812
if (isReactSizeSet) {
updateSubviewFrameState(width, height, l, t)
}
}
}
enum class Type {
LEFT,
CENTER,
RIGHT,
BACK,
SEARCH_BAR,
}
}
@@ -0,0 +1,60 @@
package com.swmansion.rnscreens
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
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.RNSScreenStackHeaderSubviewManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenStackHeaderSubviewManagerInterface
@ReactModule(name = ScreenStackHeaderSubviewManager.REACT_CLASS)
class ScreenStackHeaderSubviewManager :
ViewGroupManager<ScreenStackHeaderSubview>(),
RNSScreenStackHeaderSubviewManagerInterface<ScreenStackHeaderSubview> {
private val delegate: ViewManagerDelegate<ScreenStackHeaderSubview>
init {
delegate = RNSScreenStackHeaderSubviewManagerDelegate<ScreenStackHeaderSubview, ScreenStackHeaderSubviewManager>(this)
}
override fun getName() = REACT_CLASS
override fun createViewInstance(context: ThemedReactContext) = ScreenStackHeaderSubview(context)
@ReactProp(name = "type")
override fun setType(
view: ScreenStackHeaderSubview,
type: String?,
) {
view.type =
when (type) {
"left" -> ScreenStackHeaderSubview.Type.LEFT
"center" -> ScreenStackHeaderSubview.Type.CENTER
"right" -> ScreenStackHeaderSubview.Type.RIGHT
"back" -> ScreenStackHeaderSubview.Type.BACK
"searchBar" -> ScreenStackHeaderSubview.Type.SEARCH_BAR
else -> throw JSApplicationIllegalArgumentException("Unknown type $type")
}
}
override fun updateState(
view: ScreenStackHeaderSubview,
props: ReactStylesDiffMap?,
stateWrapper: StateWrapper?,
): Any? {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
view.setStateWrapper(stateWrapper)
}
return super.updateState(view, props, stateWrapper)
}
protected override fun getDelegate(): ViewManagerDelegate<ScreenStackHeaderSubview> = delegate
companion object {
const val REACT_CLASS = "RNSScreenStackHeaderSubview"
}
}
@@ -0,0 +1,79 @@
package com.swmansion.rnscreens
import android.view.View
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.LayoutShadowNode
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenStackManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenStackManagerInterface
import com.swmansion.rnscreens.events.StackFinishTransitioningEvent
@ReactModule(name = ScreenStackViewManager.REACT_CLASS)
class ScreenStackViewManager :
ViewGroupManager<ScreenStack>(),
RNSScreenStackManagerInterface<ScreenStack> {
private val delegate: ViewManagerDelegate<ScreenStack>
init {
delegate = RNSScreenStackManagerDelegate<ScreenStack, ScreenStackViewManager>(this)
}
override fun getName() = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) = ScreenStack(reactContext)
override fun addView(
parent: ScreenStack,
child: View,
index: Int,
) {
require(child is Screen) { "Attempt attach child that is not of type Screen" }
NativeProxy.addScreenToMap(child.id, child)
parent.addScreen(child, index)
}
override fun removeViewAt(
parent: ScreenStack,
index: Int,
) {
val screen = parent.getScreenAt(index)
prepareOutTransition(screen)
parent.removeScreenAt(index)
NativeProxy.removeScreenFromMap(screen.id)
}
private fun prepareOutTransition(screen: Screen?) {
screen?.startRemovalTransition()
}
override fun invalidate() {
super.invalidate()
NativeProxy.clearMapOnInvalidate()
}
override fun getChildCount(parent: ScreenStack) = parent.screenCount
override fun getChildAt(
parent: ScreenStack,
index: Int,
): View = parent.getScreenAt(index)
// Old architecture only.
override fun createShadowNodeInstance(context: ReactApplicationContext): LayoutShadowNode = ScreensShadowNode(context)
override fun needsCustomLayoutForChildren() = true
protected override fun getDelegate(): ViewManagerDelegate<ScreenStack> = delegate
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
StackFinishTransitioningEvent.EVENT_NAME to mutableMapOf("registrationName" to "onFinishTransitioning"),
)
companion object {
const val REACT_CLASS = "RNSScreenStack"
}
}
@@ -0,0 +1,404 @@
package com.swmansion.rnscreens
import android.view.View
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.bridge.ReadableArray
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.RNSScreenManagerDelegate
import com.facebook.react.viewmanagers.RNSScreenManagerInterface
import com.swmansion.rnscreens.events.HeaderBackButtonClickedEvent
import com.swmansion.rnscreens.events.HeaderHeightChangeEvent
import com.swmansion.rnscreens.events.ScreenAppearEvent
import com.swmansion.rnscreens.events.ScreenDisappearEvent
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.events.ScreenTransitionProgressEvent
import com.swmansion.rnscreens.events.ScreenWillAppearEvent
import com.swmansion.rnscreens.events.ScreenWillDisappearEvent
import com.swmansion.rnscreens.events.SheetDetentChangedEvent
@ReactModule(name = ScreenViewManager.REACT_CLASS)
open class ScreenViewManager :
ViewGroupManager<Screen>(),
RNSScreenManagerInterface<Screen> {
private val delegate: ViewManagerDelegate<Screen>
init {
delegate = RNSScreenManagerDelegate<Screen, ScreenViewManager>(this)
}
override fun getName() = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) = Screen(reactContext)
override fun setActivityState(
view: Screen,
activityState: Float,
) {
setActivityState(view, activityState.toInt())
}
override fun addView(
parent: Screen,
child: View,
index: Int,
) {
if (child is ScreenContentWrapper) {
parent.registerLayoutCallbackForWrapper(child)
} else if (child is ScreenFooter) {
parent.footer = child
}
super.addView(parent, child, index)
}
// Overriding all three remove methods despite the fact, that they all do use removeViewAt in parent
// class implementation to make it safe in case this changes. Relying on implementation details in this
// case in unnecessary.
override fun removeViewAt(
parent: Screen,
index: Int,
) {
if (parent.getChildAt(index) is ScreenFooter) {
parent.footer = null
}
super.removeViewAt(parent, index)
}
override fun removeView(
parent: Screen,
view: View,
) {
super.removeView(parent, view)
if (view is ScreenFooter) {
parent.footer = null
}
}
override fun updateState(
view: Screen,
props: ReactStylesDiffMap?,
stateWrapper: StateWrapper?,
): Any? {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
view.setStateWrapper(stateWrapper)
}
return super.updateState(view, props, stateWrapper)
}
// Called after all props are updated for given view
override fun onAfterUpdateTransaction(view: Screen) {
super.onAfterUpdateTransaction(view)
view.onFinalizePropsUpdate()
}
@ReactProp(name = "activityState")
fun setActivityState(
view: Screen,
activityState: Int,
) {
if (activityState == -1) {
// Null will be provided when activityState is set as an animated value and we change
// it from JS to be a plain value (non animated).
// In case when null is received, we want to ignore such value and not make
// any updates as the actual non-null value will follow immediately.
return
}
when (activityState) {
0 -> view.setActivityState(Screen.ActivityState.INACTIVE)
1 -> view.setActivityState(Screen.ActivityState.TRANSITIONING_OR_BELOW_TOP)
2 -> view.setActivityState(Screen.ActivityState.ON_TOP)
}
}
@ReactProp(name = "stackPresentation")
override fun setStackPresentation(
view: Screen,
presentation: String?,
) {
view.stackPresentation =
when (presentation) {
"push" -> Screen.StackPresentation.PUSH
"formSheet" -> Screen.StackPresentation.FORM_SHEET
"modal", "containedModal", "fullScreenModal", "pageSheet" ->
Screen.StackPresentation.MODAL
"transparentModal", "containedTransparentModal" ->
Screen.StackPresentation.TRANSPARENT_MODAL
else -> throw JSApplicationIllegalArgumentException("Unknown presentation type $presentation")
}
}
@ReactProp(name = "stackAnimation")
override fun setStackAnimation(
view: Screen,
animation: String?,
) {
view.stackAnimation =
when (animation) {
null, "default", "flip", "simple_push" -> Screen.StackAnimation.DEFAULT
"none" -> Screen.StackAnimation.NONE
"fade" -> Screen.StackAnimation.FADE
"slide_from_right" -> Screen.StackAnimation.SLIDE_FROM_RIGHT
"slide_from_left" -> Screen.StackAnimation.SLIDE_FROM_LEFT
"slide_from_bottom" -> Screen.StackAnimation.SLIDE_FROM_BOTTOM
"fade_from_bottom" -> Screen.StackAnimation.FADE_FROM_BOTTOM
"ios_from_right" -> Screen.StackAnimation.IOS_FROM_RIGHT
"ios_from_left" -> Screen.StackAnimation.IOS_FROM_LEFT
else -> throw JSApplicationIllegalArgumentException("Unknown animation type $animation")
}
}
@ReactProp(name = "gestureEnabled", defaultBoolean = true)
override fun setGestureEnabled(
view: Screen,
gestureEnabled: Boolean,
) {
view.isGestureEnabled = gestureEnabled
}
@ReactProp(name = "replaceAnimation")
override fun setReplaceAnimation(
view: Screen,
animation: String?,
) {
view.replaceAnimation =
when (animation) {
null, "pop" -> Screen.ReplaceAnimation.POP
"push" -> Screen.ReplaceAnimation.PUSH
else -> throw JSApplicationIllegalArgumentException("Unknown replace animation type $animation")
}
}
@ReactProp(name = "screenOrientation")
override fun setScreenOrientation(
view: Screen,
screenOrientation: String?,
) {
view.setScreenOrientation(screenOrientation)
}
@ReactProp(name = "statusBarAnimation")
override fun setStatusBarAnimation(
view: Screen,
statusBarAnimation: String?,
) {
val animated = statusBarAnimation != null && "none" != statusBarAnimation
view.isStatusBarAnimated = animated
}
@Deprecated(
"For apps targeting SDK 35 or above this prop has no effect. " +
"Since the edge-to-edge is enabled by default the color is always translucent.",
)
@ReactProp(name = "statusBarColor", customType = "Color")
override fun setStatusBarColor(
view: Screen,
statusBarColor: Int?,
) {
view.statusBarColor = statusBarColor
}
@ReactProp(name = "statusBarStyle")
override fun setStatusBarStyle(
view: Screen,
statusBarStyle: String?,
) {
view.statusBarStyle = statusBarStyle
}
@Deprecated("For apps targeting SDK 35 or above edge-to-edge is enabled by default and will be enforced in the future.")
@ReactProp(name = "statusBarTranslucent")
override fun setStatusBarTranslucent(
view: Screen,
statusBarTranslucent: Boolean,
) {
view.isStatusBarTranslucent = statusBarTranslucent
}
@ReactProp(name = "statusBarHidden")
override fun setStatusBarHidden(
view: Screen,
statusBarHidden: Boolean,
) {
view.isStatusBarHidden = statusBarHidden
}
@Deprecated("For apps targeting SDK 35 or above this prop has no effect")
@ReactProp(name = "navigationBarColor", customType = "Color")
override fun setNavigationBarColor(
view: Screen,
navigationBarColor: Int?,
) {
view.navigationBarColor = navigationBarColor
}
@Deprecated("For apps targeting SDK 35 or above edge-to-edge is enabled by default")
@ReactProp(name = "navigationBarTranslucent")
override fun setNavigationBarTranslucent(
view: Screen,
navigationBarTranslucent: Boolean,
) {
view.isNavigationBarTranslucent = navigationBarTranslucent
}
@ReactProp(name = "navigationBarHidden")
override fun setNavigationBarHidden(
view: Screen,
navigationBarHidden: Boolean,
) {
view.isNavigationBarHidden = navigationBarHidden
}
@ReactProp(name = "nativeBackButtonDismissalEnabled")
override fun setNativeBackButtonDismissalEnabled(
view: Screen,
nativeBackButtonDismissalEnabled: Boolean,
) {
view.nativeBackButtonDismissalEnabled = nativeBackButtonDismissalEnabled
}
@ReactProp(name = "sheetElevation")
override fun setSheetElevation(
view: Screen?,
value: Int,
) {
view?.sheetElevation = value.toFloat()
}
// these props are not available on Android, however we must override their setters
override fun setFullScreenSwipeEnabled(
view: Screen?,
value: Boolean,
) = Unit
override fun setFullScreenSwipeShadowEnabled(
view: Screen?,
value: Boolean,
) = Unit
override fun setTransitionDuration(
view: Screen?,
value: Int,
) = Unit
override fun setHideKeyboardOnSwipe(
view: Screen?,
value: Boolean,
) = Unit
override fun setCustomAnimationOnSwipe(
view: Screen?,
value: Boolean,
) = Unit
override fun setGestureResponseDistance(
view: Screen?,
value: ReadableMap?,
) = Unit
override fun setHomeIndicatorHidden(
view: Screen?,
value: Boolean,
) = Unit
override fun setPreventNativeDismiss(
view: Screen?,
value: Boolean,
) = Unit
override fun setSwipeDirection(
view: Screen?,
value: String?,
) = Unit
@ReactProp(name = "sheetAllowedDetents")
override fun setSheetAllowedDetents(
view: Screen,
value: ReadableArray?,
) {
view.sheetDetents.clear()
if (value == null || value.size() == 0) {
view.sheetDetents.add(1.0)
return
}
IntProgression
.fromClosedRange(0, value.size() - 1, 1)
.asSequence()
.map { idx -> value.getDouble(idx) }
.toCollection(view.sheetDetents)
}
@ReactProp(name = "sheetLargestUndimmedDetent")
override fun setSheetLargestUndimmedDetent(
view: Screen,
value: Int,
) {
check(value in -1..2) { "[RNScreens] sheetLargestUndimmedDetent on Android supports values between -1 and 2" }
view.sheetLargestUndimmedDetentIndex = value
}
@ReactProp(name = "sheetGrabberVisible")
override fun setSheetGrabberVisible(
view: Screen,
value: Boolean,
) {
view.isSheetGrabberVisible = value
}
@ReactProp(name = "sheetCornerRadius")
override fun setSheetCornerRadius(
view: Screen,
value: Float,
) {
view.sheetCornerRadius = value
}
@ReactProp(name = "sheetExpandsWhenScrolledToEdge")
override fun setSheetExpandsWhenScrolledToEdge(
view: Screen,
value: Boolean,
) {
view.sheetExpandsWhenScrolledToEdge = value
}
@ReactProp(name = "sheetInitialDetent")
override fun setSheetInitialDetent(
view: Screen,
value: Int,
) {
view.sheetInitialDetentIndex = value
}
override fun setScreenId(
view: Screen,
value: String?,
) {
view.screenId = if (value.isNullOrEmpty()) null else value
}
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
ScreenDismissedEvent.EVENT_NAME to hashMapOf("registrationName" to "onDismissed"),
ScreenWillAppearEvent.EVENT_NAME to hashMapOf("registrationName" to "onWillAppear"),
ScreenAppearEvent.EVENT_NAME to hashMapOf("registrationName" to "onAppear"),
ScreenWillDisappearEvent.EVENT_NAME to hashMapOf("registrationName" to "onWillDisappear"),
ScreenDisappearEvent.EVENT_NAME to hashMapOf("registrationName" to "onDisappear"),
HeaderHeightChangeEvent.EVENT_NAME to hashMapOf("registrationName" to "onHeaderHeightChange"),
HeaderBackButtonClickedEvent.EVENT_NAME to hashMapOf("registrationName" to "onHeaderBackButtonClicked"),
ScreenTransitionProgressEvent.EVENT_NAME to hashMapOf("registrationName" to "onTransitionProgress"),
SheetDetentChangedEvent.EVENT_NAME to hashMapOf("registrationName" to "onSheetDetentChanged"),
)
protected override fun getDelegate(): ViewManagerDelegate<Screen> = delegate
companion object {
const val REACT_CLASS = "RNSScreen"
}
}
@@ -0,0 +1,369 @@
package com.swmansion.rnscreens
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.app.Activity
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.os.Build
import android.view.View
import android.view.ViewParent
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.facebook.react.bridge.GuardedRunnable
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.swmansion.rnscreens.Screen.WindowTraits
import com.swmansion.rnscreens.utils.EdgeToEdgeDetector
object ScreenWindowTraits {
// Methods concerning statusBar management were taken from `react-native`'s status bar module:
// https://github.com/facebook/react-native/blob/master/ReactAndroid/src/main/java/com/facebook/react/modules/statusbar/StatusBarModule.java
private var didSetOrientation = false
private var didSetStatusBarAppearance = false
private var didSetNavigationBarAppearance = false
private var defaultStatusBarColor: Int? = null
private var windowInsetsListener =
object : OnApplyWindowInsetsListener {
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat,
): WindowInsetsCompat {
val defaultInsets = ViewCompat.onApplyWindowInsets(v, insets)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val windowInsets =
defaultInsets.getInsets(WindowInsetsCompat.Type.statusBars())
return WindowInsetsCompat
.Builder()
.setInsets(
WindowInsetsCompat.Type.statusBars(),
Insets.of(
windowInsets.left,
0,
windowInsets.right,
windowInsets.bottom,
),
).build()
} else {
return defaultInsets.replaceSystemWindowInsets(
defaultInsets.systemWindowInsetLeft,
0,
defaultInsets.systemWindowInsetRight,
defaultInsets.systemWindowInsetBottom,
)
}
}
}
internal fun applyDidSetOrientation() {
didSetOrientation = true
}
internal fun applyDidSetStatusBarAppearance() {
didSetStatusBarAppearance = true
}
internal fun applyDidSetNavigationBarAppearance() {
didSetNavigationBarAppearance = true
}
internal fun setOrientation(
screen: Screen,
activity: Activity?,
) {
if (activity == null) {
return
}
val screenForOrientation = findScreenForTrait(screen, WindowTraits.ORIENTATION)
val orientation = screenForOrientation?.screenOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
activity.requestedOrientation = orientation
}
@Deprecated(
"For apps targeting SDK 35 or above this prop has no effect because " +
"edge-to-edge is enabled by default and the status bar is always translucent.",
)
internal fun setColor(
screen: Screen,
activity: Activity?,
context: ReactContext?,
) {
if (activity == null || context == null) {
return
}
if (defaultStatusBarColor == null) {
defaultStatusBarColor = activity.window.statusBarColor
}
val screenForColor = findScreenForTrait(screen, WindowTraits.COLOR)
val screenForAnimated = findScreenForTrait(screen, WindowTraits.ANIMATED)
val color = screenForColor?.statusBarColor ?: defaultStatusBarColor
val animated = screenForAnimated?.isStatusBarAnimated ?: false
UiThreadUtil.runOnUiThread(
object : GuardedRunnable(context.exceptionHandler) {
override fun runGuarded() {
val window = activity.window
val curColor: Int = window.statusBarColor
val colorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), curColor, color)
colorAnimation.addUpdateListener { animator ->
window.statusBarColor = animator.animatedValue as Int
}
if (animated) {
colorAnimation.setDuration(300).startDelay = 0
} else {
colorAnimation.setDuration(0).startDelay = 300
}
colorAnimation.start()
}
},
)
}
internal fun setStyle(
screen: Screen,
activity: Activity?,
context: ReactContext?,
) {
if (activity == null || context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return
}
val screenForStyle = findScreenForTrait(screen, WindowTraits.STYLE)
val style = screenForStyle?.statusBarStyle ?: "light"
UiThreadUtil.runOnUiThread {
val decorView = activity.window.decorView
val window = activity.window
val controller = WindowInsetsControllerCompat(window, decorView)
controller.isAppearanceLightStatusBars = style == "dark"
}
}
@Deprecated(
"For apps targeting SDK 35 or above this prop has no effect because " +
"edge-to-edge is enabled by default and the status bar is always translucent.",
)
internal fun setTranslucent(
screen: Screen,
activity: Activity?,
context: ReactContext?,
) {
if (activity == null || context == null || EdgeToEdgeDetector.ENABLED) {
return
}
val screenForTranslucent = findScreenForTrait(screen, WindowTraits.TRANSLUCENT)
val translucent = screenForTranslucent?.isStatusBarTranslucent ?: false
UiThreadUtil.runOnUiThread(
object : GuardedRunnable(context.exceptionHandler) {
override fun runGuarded() {
// If the status bar is translucent hook into the window insets calculations
// and consume all the top insets so no padding will be added under the status bar.
val decorView = activity.window.decorView
if (translucent) {
InsetsObserverProxy.registerOnView(decorView)
InsetsObserverProxy.addOnApplyWindowInsetsListener(windowInsetsListener)
} else {
InsetsObserverProxy.removeOnApplyWindowInsetsListener(windowInsetsListener)
}
ViewCompat.requestApplyInsets(decorView)
}
},
)
}
internal fun setHidden(
screen: Screen,
activity: Activity?,
) {
if (activity == null) {
return
}
val screenForHidden = findScreenForTrait(screen, WindowTraits.HIDDEN)
val hidden = screenForHidden?.isStatusBarHidden ?: false
val window = activity.window
val controller = WindowInsetsControllerCompat(window, window.decorView)
UiThreadUtil.runOnUiThread {
if (hidden) {
controller.hide(WindowInsetsCompat.Type.statusBars())
} else {
controller.show(WindowInsetsCompat.Type.statusBars())
}
}
}
// Methods concerning navigationBar management were taken from `react-native-navigation`'s repo:
// https://github.com/wix/react-native-navigation/blob/9bb70d81700692141a2c505c081c2d86c7f9c66e/lib/android/app/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt
@Deprecated(
"For all apps targeting Android SDK 35 or above edge-to-edge is enabled by default. ",
)
internal fun setNavigationBarColor(
screen: Screen,
activity: Activity?,
) {
if (activity == null) {
return
}
val window = activity.window
val screenForNavBarColor = findScreenForTrait(screen, WindowTraits.NAVIGATION_BAR_COLOR)
val color = screenForNavBarColor?.navigationBarColor ?: window.navigationBarColor
UiThreadUtil.runOnUiThread {
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars =
isColorLight(color)
}
window.navigationBarColor = color
}
@Deprecated(
"For all apps targeting Android SDK 35 or above edge-to-edge is enabled by default. ",
)
internal fun setNavigationBarTranslucent(
screen: Screen,
activity: Activity?,
) {
if (activity == null || EdgeToEdgeDetector.ENABLED) {
return
}
val window = activity.window
val screenForNavBarTranslucent = findScreenForTrait(screen, WindowTraits.NAVIGATION_BAR_TRANSLUCENT)
val translucent = screenForNavBarTranslucent?.isNavigationBarTranslucent ?: return
// Following method controls whether to display edge-to-edge content that draws behind the navigation bar
WindowCompat.setDecorFitsSystemWindows(window, !translucent)
}
internal fun setNavigationBarHidden(
screen: Screen,
activity: Activity?,
) {
if (activity == null) {
return
}
val window = activity.window
val screenForNavBarHidden = findScreenForTrait(screen, WindowTraits.NAVIGATION_BAR_HIDDEN)
val hidden = screenForNavBarHidden?.isNavigationBarHidden ?: false
if (hidden) {
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.hide(WindowInsetsCompat.Type.navigationBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
WindowInsetsControllerCompat(
window,
window.decorView,
).show(WindowInsetsCompat.Type.navigationBars())
}
}
internal fun trySetWindowTraits(
screen: Screen,
activity: Activity?,
context: ReactContext?,
) {
if (didSetOrientation) {
setOrientation(screen, activity)
}
if (didSetStatusBarAppearance) {
setColor(screen, activity, context)
setStyle(screen, activity, context)
setTranslucent(screen, activity, context)
setHidden(screen, activity)
}
if (didSetNavigationBarAppearance) {
setNavigationBarColor(screen, activity)
setNavigationBarTranslucent(screen, activity)
setNavigationBarHidden(screen, activity)
}
}
private fun findScreenForTrait(
screen: Screen,
trait: WindowTraits,
): Screen? {
val childWithTrait = childScreenWithTraitSet(screen, trait)
if (childWithTrait != null) {
return childWithTrait
}
return if (checkTraitForScreen(screen, trait)) {
screen
} else {
// if there is no child with trait set and this screen has no trait set, we look for a parent
// that has the trait set
findParentWithTraitSet(screen, trait)
}
}
private fun findParentWithTraitSet(
screen: Screen,
trait: WindowTraits,
): Screen? {
var parent: ViewParent? = screen.container
while (parent != null) {
if (parent is Screen) {
if (checkTraitForScreen(parent, trait)) {
return parent
}
}
parent = parent.parent
}
return null
}
private fun childScreenWithTraitSet(
screen: Screen?,
trait: WindowTraits,
): Screen? {
screen?.fragmentWrapper?.let {
for (sc in it.childScreenContainers) {
// we check only the top screen for the trait
val topScreen = sc.topScreen
val child = childScreenWithTraitSet(topScreen, trait)
if (child != null) {
return child
}
if (topScreen != null && checkTraitForScreen(topScreen, trait)) {
return topScreen
}
}
}
return null
}
private fun checkTraitForScreen(
screen: Screen,
trait: WindowTraits,
): Boolean =
when (trait) {
WindowTraits.ORIENTATION -> screen.screenOrientation != null
WindowTraits.COLOR -> screen.statusBarColor != null
WindowTraits.STYLE -> screen.statusBarStyle != null
WindowTraits.TRANSLUCENT -> screen.isStatusBarTranslucent != null
WindowTraits.HIDDEN -> screen.isStatusBarHidden != null
WindowTraits.ANIMATED -> screen.isStatusBarAnimated != null
WindowTraits.NAVIGATION_BAR_COLOR -> screen.navigationBarColor != null
WindowTraits.NAVIGATION_BAR_TRANSLUCENT -> screen.isNavigationBarTranslucent != null
WindowTraits.NAVIGATION_BAR_HIDDEN -> screen.isNavigationBarHidden != null
}
private fun isColorLight(color: Int): Boolean {
val darkness: Double =
1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255
return darkness < 0.5
}
}
@@ -0,0 +1,135 @@
package com.swmansion.rnscreens
import android.util.Log
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.fabric.FabricUIManager
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType
import com.swmansion.rnscreens.events.ScreenTransitionProgressEvent
import java.util.concurrent.atomic.AtomicBoolean
@ReactModule(name = ScreensModule.NAME)
class ScreensModule(
private val reactContext: ReactApplicationContext,
) : NativeScreensModuleSpec(reactContext) {
private var topScreenId: Int = -1
private val isActiveTransition = AtomicBoolean(false)
private var proxy: NativeProxy? = null
init {
try {
System.loadLibrary("rnscreens")
val jsContext = reactApplicationContext.javaScriptContextHolder
if (jsContext != null) {
nativeInstall(jsContext.get())
} else {
Log.e("[RNScreens]", "Could not install JSI bindings.")
}
} catch (exception: UnsatisfiedLinkError) {
Log.w("[RNScreens]", "Could not load RNScreens module.")
}
}
private external fun nativeInstall(jsiPtr: Long)
private external fun nativeUninstall()
override fun invalidate() {
super.invalidate()
proxy?.invalidateNative()
nativeUninstall()
}
override fun initialize() {
super.initialize()
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
val fabricUIManager =
UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC) as FabricUIManager
proxy =
NativeProxy().apply {
nativeAddMutationsListener(fabricUIManager)
}
}
}
override fun getName(): String = NAME
@DoNotStrip
private fun startTransition(reactTag: Int?): IntArray {
UiThreadUtil.assertOnUiThread()
if (isActiveTransition.get() || reactTag == null) {
return intArrayOf(-1, -1)
}
topScreenId = -1
val result = intArrayOf(-1, -1)
val uiManager = UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)
val stack = uiManager?.resolveView(reactTag)
if (stack is ScreenStack) {
val fragments = stack.fragments
val screensCount = fragments.size
if (screensCount > 1) {
isActiveTransition.set(true)
stack.attachBelowTop()
topScreenId = fragments[screensCount - 1].screen.id
result[0] = topScreenId
result[1] = fragments[screensCount - 2].screen.id
}
}
return result
}
@DoNotStrip
private fun updateTransition(progress: Double) {
UiThreadUtil.assertOnUiThread()
if (topScreenId == -1) {
return
}
val progressFloat = progress.toFloat()
val coalescingKey = ScreenFragment.getCoalescingKey(progressFloat)
UIManagerHelper
.getEventDispatcherForReactTag(reactContext, topScreenId)
?.dispatchEvent(
ScreenTransitionProgressEvent(
UIManagerHelper.getSurfaceId(reactContext),
topScreenId,
progressFloat,
true,
true,
coalescingKey,
),
)
}
@DoNotStrip
private fun finishTransition(
reactTag: Int?,
canceled: Boolean,
) {
UiThreadUtil.assertOnUiThread()
if (!isActiveTransition.get() || reactTag == null) {
Log.e(
"[RNScreens]",
"Unable to call `finishTransition` method before transition start.",
)
return
}
val uiManager = UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)
val stack = uiManager?.resolveView(reactTag)
if (stack is ScreenStack) {
if (canceled) {
stack.detachBelowTop()
} else {
stack.notifyTopDetached()
}
isActiveTransition.set(false)
}
topScreenId = -1
}
companion object {
const val NAME = "RNSModule"
}
}
@@ -0,0 +1,24 @@
package com.swmansion.rnscreens
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.LayoutShadowNode
import com.facebook.react.uimanager.NativeViewHierarchyManager
import com.facebook.react.uimanager.NativeViewHierarchyOptimizer
import com.facebook.react.uimanager.UIManagerModule
internal class ScreensShadowNode(
private var context: ReactContext,
) : LayoutShadowNode() {
override fun onBeforeLayout(nativeViewHierarchyOptimizer: NativeViewHierarchyOptimizer) {
super.onBeforeLayout(nativeViewHierarchyOptimizer)
(context.getNativeModule(UIManagerModule::class.java))?.addUIBlock { nativeViewHierarchyManager: NativeViewHierarchyManager? ->
if (nativeViewHierarchyManager == null) {
return@addUIBlock
}
val view = nativeViewHierarchyManager.resolveView(reactTag)
if (view is ScreenContainer) {
view.performUpdates()
}
}
}
}
@@ -0,0 +1,240 @@
package com.swmansion.rnscreens
import android.util.Log
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.module.annotations.ReactModule
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.RNSSearchBarManagerDelegate
import com.facebook.react.viewmanagers.RNSSearchBarManagerInterface
import com.swmansion.rnscreens.events.SearchBarBlurEvent
import com.swmansion.rnscreens.events.SearchBarChangeTextEvent
import com.swmansion.rnscreens.events.SearchBarCloseEvent
import com.swmansion.rnscreens.events.SearchBarFocusEvent
import com.swmansion.rnscreens.events.SearchBarOpenEvent
import com.swmansion.rnscreens.events.SearchBarSearchButtonPressEvent
@ReactModule(name = SearchBarManager.REACT_CLASS)
class SearchBarManager :
ViewGroupManager<SearchBarView>(),
RNSSearchBarManagerInterface<SearchBarView> {
private val delegate: ViewManagerDelegate<SearchBarView>
init {
delegate = RNSSearchBarManagerDelegate<SearchBarView, SearchBarManager>(this)
}
protected override fun getDelegate(): ViewManagerDelegate<SearchBarView> = delegate
override fun getName(): String = REACT_CLASS
override fun createViewInstance(context: ThemedReactContext): SearchBarView = SearchBarView(context)
override fun onAfterUpdateTransaction(view: SearchBarView) {
super.onAfterUpdateTransaction(view)
view.onUpdate()
}
@ReactProp(name = "autoCapitalize")
override fun setAutoCapitalize(
view: SearchBarView,
autoCapitalize: String?,
) {
view.autoCapitalize =
when (autoCapitalize) {
null, "none" -> SearchBarView.SearchBarAutoCapitalize.NONE
"words" -> SearchBarView.SearchBarAutoCapitalize.WORDS
"sentences" -> SearchBarView.SearchBarAutoCapitalize.SENTENCES
"characters" -> SearchBarView.SearchBarAutoCapitalize.CHARACTERS
else -> throw JSApplicationIllegalArgumentException(
"Forbidden auto capitalize value passed",
)
}
}
@ReactProp(name = "autoFocus")
fun setAutoFocus(
view: SearchBarView,
autoFocus: Boolean?,
) {
view.autoFocus = autoFocus ?: false
}
@ReactProp(name = "barTintColor", customType = "Color")
override fun setBarTintColor(
view: SearchBarView,
color: Int?,
) {
view.tintColor = color
}
@ReactProp(name = "disableBackButtonOverride")
override fun setDisableBackButtonOverride(
view: SearchBarView,
disableBackButtonOverride: Boolean,
) {
view.shouldOverrideBackButton = disableBackButtonOverride != true
}
@ReactProp(name = "inputType")
override fun setInputType(
view: SearchBarView,
inputType: String?,
) {
view.inputType =
when (inputType) {
null, "text" -> SearchBarView.SearchBarInputTypes.TEXT
"phone" -> SearchBarView.SearchBarInputTypes.PHONE
"number" -> SearchBarView.SearchBarInputTypes.NUMBER
"email" -> SearchBarView.SearchBarInputTypes.EMAIL
else -> throw JSApplicationIllegalArgumentException(
"Forbidden input type value",
)
}
}
@ReactProp(name = "placeholder")
override fun setPlaceholder(
view: SearchBarView,
placeholder: String?,
) {
if (placeholder != null) {
view.placeholder = placeholder
}
}
@ReactProp(name = "textColor", customType = "Color")
override fun setTextColor(
view: SearchBarView,
color: Int?,
) {
view.textColor = color
}
@ReactProp(name = "headerIconColor", customType = "Color")
override fun setHeaderIconColor(
view: SearchBarView,
color: Int?,
) {
view.headerIconColor = color
}
@ReactProp(name = "hintTextColor", customType = "Color")
override fun setHintTextColor(
view: SearchBarView,
color: Int?,
) {
view.hintTextColor = color
}
@ReactProp(name = "shouldShowHintSearchIcon")
override fun setShouldShowHintSearchIcon(
view: SearchBarView,
shouldShowHintSearchIcon: Boolean,
) {
view.shouldShowHintSearchIcon = shouldShowHintSearchIcon ?: true
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
hashMapOf(
SearchBarBlurEvent.EVENT_NAME to hashMapOf("registrationName" to "onSearchBlur"),
SearchBarChangeTextEvent.EVENT_NAME to hashMapOf("registrationName" to "onChangeText"),
SearchBarCloseEvent.EVENT_NAME to hashMapOf("registrationName" to "onClose"),
SearchBarFocusEvent.EVENT_NAME to hashMapOf("registrationName" to "onSearchFocus"),
SearchBarOpenEvent.EVENT_NAME to hashMapOf("registrationName" to "onOpen"),
SearchBarSearchButtonPressEvent.EVENT_NAME to hashMapOf("registrationName" to "onSearchButtonPress"),
)
companion object {
const val REACT_CLASS = "RNSSearchBar"
}
private fun logNotAvailable(propName: String) {
Log.w("[RNScreens]", "$propName prop is not available on Android")
}
// NativeCommands
override fun blur(view: SearchBarView?) {
view?.handleBlurJsRequest()
}
override fun focus(view: SearchBarView?) {
view?.handleFocusJsRequest()
}
override fun clearText(view: SearchBarView?) {
view?.handleClearTextJsRequest()
}
override fun toggleCancelButton(
view: SearchBarView?,
flag: Boolean,
) {
view?.handleToggleCancelButtonJsRequest(flag)
}
override fun setText(
view: SearchBarView?,
text: String?,
) {
view?.handleSetTextJsRequest(text)
}
override fun cancelSearch(view: SearchBarView?) {
view?.handleFocusJsRequest()
}
// iOS only
override fun setPlacement(
view: SearchBarView,
placeholder: String?,
) {
logNotAvailable("setPlacement")
}
override fun setAllowToolbarIntegration(
view: SearchBarView,
value: Boolean,
) {
logNotAvailable("allowToolbarIntegration")
}
override fun setHideWhenScrolling(
view: SearchBarView?,
value: Boolean,
) {
logNotAvailable("hideWhenScrolling")
}
override fun setObscureBackground(
view: SearchBarView?,
value: Boolean,
) {
logNotAvailable("hideNavigationBar")
}
override fun setHideNavigationBar(
view: SearchBarView?,
value: Boolean,
) {
logNotAvailable("hideNavigationBar")
}
override fun setCancelButtonText(
view: SearchBarView?,
value: String?,
) {
logNotAvailable("cancelButtonText")
}
override fun setTintColor(
view: SearchBarView?,
value: Int?,
) {
logNotAvailable("tintColor")
}
}
@@ -0,0 +1,203 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.text.InputType
import androidx.appcompat.widget.SearchView
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.view.ReactViewGroup
import com.swmansion.rnscreens.events.SearchBarBlurEvent
import com.swmansion.rnscreens.events.SearchBarChangeTextEvent
import com.swmansion.rnscreens.events.SearchBarCloseEvent
import com.swmansion.rnscreens.events.SearchBarFocusEvent
import com.swmansion.rnscreens.events.SearchBarOpenEvent
import com.swmansion.rnscreens.events.SearchBarSearchButtonPressEvent
@SuppressLint("ViewConstructor")
class SearchBarView(
reactContext: ReactContext?,
) : ReactViewGroup(reactContext) {
var inputType: SearchBarInputTypes = SearchBarInputTypes.TEXT
var autoCapitalize: SearchBarAutoCapitalize = SearchBarAutoCapitalize.NONE
var textColor: Int? = null
var tintColor: Int? = null
var headerIconColor: Int? = null
var hintTextColor: Int? = null
var placeholder: String = ""
var shouldOverrideBackButton: Boolean = true
var autoFocus: Boolean = false
var shouldShowHintSearchIcon: Boolean = true
private var searchViewFormatter: SearchViewFormatter? = null
private var areListenersSet: Boolean = false
private val headerConfig: ScreenStackHeaderConfig?
get() {
val currentParent = parent
if (currentParent is ScreenStackHeaderSubview) {
return currentParent.config
}
return null
}
private val screenStackFragment: ScreenStackFragment?
get() = headerConfig?.screenFragment
fun onUpdate() {
setSearchViewProps()
}
private fun setSearchViewProps() {
val searchView = screenStackFragment?.searchView
if (searchView != null) {
if (!areListenersSet) {
setSearchViewListeners(searchView)
areListenersSet = true
}
searchView.inputType = inputType.toAndroidInputType(autoCapitalize)
searchViewFormatter?.setTextColor(textColor)
searchViewFormatter?.setTintColor(tintColor)
searchViewFormatter?.setHeaderIconColor(headerIconColor)
searchViewFormatter?.setHintTextColor(hintTextColor)
searchViewFormatter?.setPlaceholder(placeholder, shouldShowHintSearchIcon)
searchView.overrideBackAction = shouldOverrideBackButton
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
screenStackFragment?.onSearchViewCreate = { newSearchView ->
if (searchViewFormatter == null) {
searchViewFormatter =
SearchViewFormatter(newSearchView)
}
setSearchViewProps()
if (autoFocus) {
screenStackFragment?.searchView?.focus()
}
}
}
private fun setSearchViewListeners(searchView: SearchView) {
searchView.setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(newText: String?): Boolean {
handleTextChange(newText)
return true
}
override fun onQueryTextSubmit(query: String?): Boolean {
handleTextSubmit(query)
return true
}
},
)
searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
handleFocusChange(hasFocus)
}
searchView.setOnCloseListener {
handleClose()
false
}
searchView.setOnSearchClickListener {
handleOpen()
}
}
private fun handleTextChange(newText: String?) {
sendEvent(SearchBarChangeTextEvent(surfaceId, id, newText))
}
private fun handleFocusChange(hasFocus: Boolean) {
sendEvent(if (hasFocus) SearchBarFocusEvent(surfaceId, id) else SearchBarBlurEvent(surfaceId, id))
}
private fun handleClose() {
sendEvent(SearchBarCloseEvent(surfaceId, id))
setToolbarElementsVisibility(VISIBLE)
}
private fun handleOpen() {
sendEvent(SearchBarOpenEvent(surfaceId, id))
setToolbarElementsVisibility(GONE)
}
private fun handleTextSubmit(newText: String?) {
sendEvent(SearchBarSearchButtonPressEvent(surfaceId, id, newText))
}
private fun sendEvent(event: Event<*>) {
val eventDispatcher: EventDispatcher? =
UIManagerHelper.getEventDispatcherForReactTag(context as ReactContext, id)
eventDispatcher?.dispatchEvent(event)
}
fun handleClearTextJsRequest() {
screenStackFragment?.searchView?.clearText()
}
fun handleFocusJsRequest() {
screenStackFragment?.searchView?.focus()
}
fun handleBlurJsRequest() {
screenStackFragment?.searchView?.clearFocus()
}
fun handleToggleCancelButtonJsRequest(flag: Boolean) = Unit
fun handleSetTextJsRequest(text: String?) {
text?.let { screenStackFragment?.searchView?.setText(it) }
}
fun handleCancelSearchJsRequest() {
screenStackFragment?.searchView?.cancelSearch()
}
private fun setToolbarElementsVisibility(visibility: Int) {
for (i in 0..(headerConfig?.configSubviewsCount?.minus(1) ?: 0)) {
val subview = headerConfig?.getConfigSubview(i)
if (subview?.type != ScreenStackHeaderSubview.Type.SEARCH_BAR) {
subview?.visibility = visibility
}
}
}
private val surfaceId = UIManagerHelper.getSurfaceId(this)
enum class SearchBarAutoCapitalize {
NONE,
WORDS,
SENTENCES,
CHARACTERS,
}
enum class SearchBarInputTypes {
TEXT {
override fun toAndroidInputType(capitalize: SearchBarAutoCapitalize) =
when (capitalize) {
SearchBarAutoCapitalize.NONE -> InputType.TYPE_CLASS_TEXT
SearchBarAutoCapitalize.WORDS -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
SearchBarAutoCapitalize.SENTENCES -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
SearchBarAutoCapitalize.CHARACTERS -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
}
},
PHONE {
override fun toAndroidInputType(capitalize: SearchBarAutoCapitalize) = InputType.TYPE_CLASS_PHONE
},
NUMBER {
override fun toAndroidInputType(capitalize: SearchBarAutoCapitalize) = InputType.TYPE_CLASS_NUMBER
},
EMAIL {
override fun toAndroidInputType(capitalize: SearchBarAutoCapitalize) = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
}, ;
abstract fun toAndroidInputType(capitalize: SearchBarAutoCapitalize): Int
}
}
@@ -0,0 +1,72 @@
package com.swmansion.rnscreens
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import androidx.appcompat.R
import androidx.appcompat.widget.SearchView
class SearchViewFormatter(
var searchView: SearchView,
) {
private var defaultTextColor: Int? = null
private var defaultTintBackground: Drawable? = null
private val searchEditText
get() = searchView.findViewById<View>(R.id.search_src_text) as? EditText
private val searchTextPlate
get() = searchView.findViewById<View>(R.id.search_plate)
private val searchIcon
get() = searchView.findViewById<ImageView>(R.id.search_button)
private val searchCloseIcon
get() = searchView.findViewById<ImageView>(R.id.search_close_btn)
fun setTextColor(textColor: Int?) {
val currentDefaultTextColor = defaultTextColor
if (textColor != null) {
if (defaultTextColor == null) {
defaultTextColor = searchEditText?.textColors?.defaultColor
}
searchEditText?.setTextColor(textColor)
} else if (currentDefaultTextColor != null) {
searchEditText?.setTextColor(currentDefaultTextColor)
}
}
fun setTintColor(tintColor: Int?) {
val currentDefaultTintColor = defaultTintBackground
if (tintColor != null) {
if (defaultTintBackground == null) {
defaultTintBackground = searchTextPlate.background
}
searchTextPlate.setBackgroundColor(tintColor)
} else if (currentDefaultTintColor != null) {
searchTextPlate.background = currentDefaultTintColor
}
}
fun setHeaderIconColor(headerIconColor: Int?) {
headerIconColor?.let {
searchIcon.setColorFilter(it)
searchCloseIcon.setColorFilter(it)
}
}
fun setHintTextColor(hintTextColor: Int?) {
hintTextColor?.let {
searchEditText?.setHintTextColor(it)
}
}
fun setPlaceholder(
placeholder: String,
shouldShowHintSearchIcon: Boolean,
) {
if (shouldShowHintSearchIcon) {
searchView.queryHint = placeholder
} else {
searchEditText?.hint = placeholder
}
}
}
@@ -0,0 +1,47 @@
package com.swmansion.rnscreens.bottomsheet
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
internal fun <T : View> BottomSheetBehavior<T>.useSingleDetent(
height: Int? = null,
forceExpandedState: Boolean = true,
): BottomSheetBehavior<T> {
this.skipCollapsed = true
this.isFitToContents = true
if (forceExpandedState) {
this.state = BottomSheetBehavior.STATE_EXPANDED
}
height?.let {
maxHeight = height
}
return this
}
internal fun <T : View> BottomSheetBehavior<T>.useTwoDetents(
@BottomSheetBehavior.StableState state: Int? = null,
firstHeight: Int? = null,
secondHeight: Int? = null,
): BottomSheetBehavior<T> {
skipCollapsed = false
isFitToContents = true
state?.let { this.state = state }
firstHeight?.let { peekHeight = firstHeight }
secondHeight?.let { maxHeight = secondHeight }
return this
}
internal fun <T : View> BottomSheetBehavior<T>.useThreeDetents(
@BottomSheetBehavior.StableState state: Int? = null,
firstHeight: Int? = null,
halfExpandedRatio: Float? = null,
expandedOffsetFromTop: Int? = null,
): BottomSheetBehavior<T> {
skipCollapsed = false
isFitToContents = false
state?.let { this.state = state }
firstHeight?.let { this.peekHeight = firstHeight }
halfExpandedRatio?.let { this.halfExpandedRatio = halfExpandedRatio }
expandedOffsetFromTop?.let { this.expandedOffset = expandedOffsetFromTop }
return this
}
@@ -0,0 +1,104 @@
package com.swmansion.rnscreens.bottomsheet
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import com.facebook.react.bridge.ReactContext
import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.uimanager.JSPointerDispatcher
import com.facebook.react.uimanager.JSTouchDispatcher
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.view.ReactViewGroup
@SuppressLint("ViewConstructor")
class BottomSheetDialogRootView(
val reactContext: ReactContext?,
private val eventDispatcher: EventDispatcher,
) : ReactViewGroup(reactContext),
RootView {
private val jsTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this)
private var jsPointerDispatcher: JSPointerDispatcher? = null
init {
// Can we safely use ReactFeatureFlags?
if (ReactFeatureFlags.dispatchPointerEvents) {
jsPointerDispatcher = JSPointerDispatcher(this)
}
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
if (changed) {
// This view is used right now only in ScreenModalFragment, where it is injected
// to view hierarchy as a parent of a Screen.
assert(childCount == 1) { "[RNScreens] Expected only a single child view under $TAG, received: $childCount" }
getChildAt(0).layout(l, t, r, b)
}
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
jsTouchDispatcher.handleTouchEvent(event, eventDispatcher)
jsPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
return super.onInterceptTouchEvent(event)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
jsTouchDispatcher.handleTouchEvent(event, eventDispatcher)
jsPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
super.onTouchEvent(event)
return true
}
override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
jsPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
// This is how DialogRootViewGroup implements this, it might be a copy-paste mistake
// on their side.
return super.onHoverEvent(event)
}
override fun onHoverEvent(event: MotionEvent): Boolean {
jsPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
return super.onHoverEvent(event)
}
override fun onChildStartedNativeGesture(
view: View?,
event: MotionEvent,
) {
jsTouchDispatcher.onChildStartedNativeGesture(event, eventDispatcher)
jsPointerDispatcher?.onChildStartedNativeGesture(view, event, eventDispatcher)
}
@Deprecated("Deprecated by React Native")
override fun onChildStartedNativeGesture(event: MotionEvent): Unit =
throw IllegalStateException("Deprecated onChildStartedNativeGesture was called")
override fun onChildEndedNativeGesture(
view: View,
event: MotionEvent,
) {
jsTouchDispatcher.onChildEndedNativeGesture(event, eventDispatcher)
jsPointerDispatcher?.onChildEndedNativeGesture()
}
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
// We do not pass through request of our child up the view hierarchy, as we
// need to keep receiving events.
}
override fun handleException(throwable: Throwable) {
// TODO: I need ThemedReactContext here.
// TODO: Determine where it is initially created & verify its lifecycle
// reactContext?.reactApplicationContext?.handleException(RuntimeException(throwable))
}
companion object {
const val TAG = "BottomSheetDialogRootView"
}
}
@@ -0,0 +1,26 @@
package com.swmansion.rnscreens.bottomsheet
import android.content.Context
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.swmansion.rnscreens.ScreenModalFragment
import java.lang.ref.WeakReference
class BottomSheetDialogScreen(
context: Context,
fragment: ScreenModalFragment,
) : BottomSheetDialog(context) {
private val fragmentRef: WeakReference<ScreenModalFragment> = WeakReference(fragment)
// There are various code paths leading to this method, however the one I'm concerned with
// is dismissal via swipe-down. If the sheet is dismissed we don't want the native dismiss logic
// to run, as this will lead to inconsistencies in ScreenStack state. Instead we intercept
// dismiss intention and run our logic.
override fun cancel() {
fragmentRef.get()!!.dismissFromContainer()
this.show()
}
companion object {
val TAG = BottomSheetDialogScreen::class.simpleName
}
}
@@ -0,0 +1,82 @@
package com.swmansion.rnscreens.bottomsheet
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.view.MotionEvent
import android.view.ViewGroup
import com.facebook.react.uimanager.ReactCompoundViewGroup
import com.facebook.react.uimanager.ReactPointerEventsView
import com.swmansion.rnscreens.ext.equalWithRespectToEps
/**
* Serves as dimming view that can be used as background for some view that does not fully fill
* the viewport.
*
* This dimming view has one more additional feature: it blocks gestures if its alpha > 0.
*/
@SuppressLint("ViewConstructor") // Only we instantiate this view
internal class DimmingView(
context: Context,
initialAlpha: Float = 0.6F,
private val pointerEventsProxy: DimmingViewPointerEventsProxy,
) : ViewGroup(context),
ReactCompoundViewGroup,
ReactPointerEventsView by pointerEventsProxy {
constructor(context: Context, initialAlpha: Float = 0.6F) : this(
context,
initialAlpha,
DimmingViewPointerEventsProxy(null),
)
init {
pointerEventsProxy.pointerEventsImpl = DimmingViewPointerEventsImpl(this)
}
internal val blockGestures
get() = !alpha.equalWithRespectToEps(0F)
init {
setBackgroundColor(Color.BLACK)
alpha = initialAlpha
}
// This view group is not supposed to have any children, however we need it to be a view group
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) = Unit
// We do not want to have any action defined here. We just want listeners notified that the click happened.
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (blockGestures) {
callOnClick()
}
return blockGestures
}
override fun reactTagForTouch(
x: Float,
y: Float,
): Int = throw IllegalStateException("[RNScreens] $TAG should never be asked for the view tag!")
override fun interceptsTouchEvent(
x: Float,
y: Float,
) = blockGestures
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// Break reference cycle, since the pointerEventsImpl strongly retains this.
pointerEventsProxy.pointerEventsImpl = null
}
companion object {
const val TAG = "DimmingView"
}
}
@@ -0,0 +1,180 @@
package com.swmansion.rnscreens.bottomsheet
import android.animation.ValueAnimator
import android.view.View
import android.view.ViewGroup
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.swmansion.rnscreens.Screen
import com.swmansion.rnscreens.ScreenStackFragment
/**
* Provides bulk of necessary logic for the dimming view accompanying the formSheet.
*/
class DimmingViewManager(
val reactContext: ThemedReactContext,
screen: Screen,
) {
internal val dimmingView: DimmingView = createDimmingView(screen)
internal val maxAlpha: Float = 0.3f
private var dimmingViewCallback: BottomSheetCallback? = null
/**
* Should be called when hosting fragment has its view hierarchy created.
*/
fun onViewHierarchyCreated(
screen: Screen,
root: ViewGroup,
) {
root.addView(dimmingView, 0)
if (!willDimForDetentIndex(screen, screen.sheetInitialDetentIndex)) {
dimmingView.alpha = 0.0f
} else {
dimmingView.alpha = maxAlpha
}
}
/**
* Should be called after screen of hosting fragment has its behaviour attached.
*/
fun onBehaviourAttached(
screen: Screen,
behavior: BottomSheetBehavior<Screen>,
) {
behavior.addBottomSheetCallback(requireBottomSheetCallback(screen, forceCreation = true))
}
/**
* Ask the manager whether it will apply non-zero alpha for sheet at given detent index.
*/
fun willDimForDetentIndex(
screen: Screen,
index: Int,
) = index > screen.sheetLargestUndimmedDetentIndex
fun invalidate(behavior: BottomSheetBehavior<Screen>?) {
dimmingViewCallback?.let { callback -> behavior?.removeBottomSheetCallback(callback) }
}
/**
* This bottom sheet callback is responsible for animating alpha of the dimming view.
*/
private class AnimateDimmingViewCallback(
val screen: Screen,
val viewToAnimate: View,
val maxAlpha: Float,
) : BottomSheetCallback() {
// largest *slide offset* that is yet undimmed
private var largestUndimmedOffset: Float =
computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex)
// first *slide offset* that should be fully dimmed
private var firstDimmedOffset: Float =
computeOffsetFromDetentIndex(
(screen.sheetLargestUndimmedDetentIndex + 1).coerceIn(
0,
screen.sheetDetents.count() - 1,
),
)
// interval that we interpolate the alpha value over
private var intervalLength = firstDimmedOffset - largestUndimmedOffset
private val animator =
ValueAnimator.ofFloat(0F, maxAlpha).apply {
duration = 1 // Driven manually
addUpdateListener {
viewToAnimate.alpha = it.animatedValue as Float
}
}
override fun onStateChanged(
bottomSheet: View,
newState: Int,
) {
if (newState == BottomSheetBehavior.STATE_DRAGGING || newState == BottomSheetBehavior.STATE_SETTLING) {
largestUndimmedOffset =
computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex)
firstDimmedOffset =
computeOffsetFromDetentIndex(
(screen.sheetLargestUndimmedDetentIndex + 1).coerceIn(
0,
screen.sheetDetents.count() - 1,
),
)
assert(firstDimmedOffset >= largestUndimmedOffset) {
"[RNScreens] Invariant violation: firstDimmedOffset ($firstDimmedOffset) < largestDimmedOffset ($largestUndimmedOffset)"
}
intervalLength = firstDimmedOffset - largestUndimmedOffset
}
}
override fun onSlide(
bottomSheet: View,
slideOffset: Float,
) {
if (largestUndimmedOffset < slideOffset && slideOffset < firstDimmedOffset) {
val fraction = (slideOffset - largestUndimmedOffset) / intervalLength
animator.setCurrentFraction(fraction)
}
}
/**
* This method does compute slide offset (see [BottomSheetCallback.onSlide] docs) for detent
* at given index in the detents array.
*/
private fun computeOffsetFromDetentIndex(index: Int): Float =
when (screen.sheetDetents.size) {
1 -> // Only 1 detent present in detents array
when (index) {
-1 -> -1F // hidden
0 -> 1F // fully expanded
else -> -1F // unexpected, default
}
2 ->
when (index) {
-1 -> -1F // hidden
0 -> 0F // collapsed
1 -> 1F // expanded
else -> -1F
}
3 ->
when (index) {
-1 -> -1F // hidden
0 -> 0F // collapsed
1 -> screen.sheetBehavior!!.halfExpandedRatio // half
2 -> 1F // expanded
else -> -1F
}
else -> -1F
}
}
private fun createDimmingView(screen: Screen): DimmingView =
DimmingView(reactContext, maxAlpha).apply {
// These do not guarantee fullscreen width & height, TODO: find a way to guarantee that
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setOnClickListener {
if (screen.sheetClosesOnTouchOutside) {
(screen.fragment as ScreenStackFragment).dismissSelf()
}
}
}
private fun requireBottomSheetCallback(
screen: Screen,
forceCreation: Boolean = false,
): BottomSheetCallback {
if (dimmingViewCallback == null || forceCreation) {
dimmingViewCallback = AnimateDimmingViewCallback(screen, dimmingView, maxAlpha)
}
return dimmingViewCallback!!
}
}
@@ -0,0 +1,369 @@
package com.swmansion.rnscreens.bottomsheet
import android.content.Context
import android.os.Build
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.swmansion.rnscreens.InsetsObserverProxy
import com.swmansion.rnscreens.KeyboardDidHide
import com.swmansion.rnscreens.KeyboardNotVisible
import com.swmansion.rnscreens.KeyboardState
import com.swmansion.rnscreens.KeyboardVisible
import com.swmansion.rnscreens.Screen
import com.swmansion.rnscreens.ScreenStackFragment
class SheetDelegate(
val screen: Screen,
) : LifecycleEventObserver,
OnApplyWindowInsetsListener {
private var isKeyboardVisible: Boolean = false
private var keyboardState: KeyboardState = KeyboardNotVisible
var lastStableDetentIndex: Int = screen.sheetInitialDetentIndex
private set
@BottomSheetBehavior.State
var lastStableState: Int =
SheetUtils.sheetStateFromDetentIndex(
screen.sheetInitialDetentIndex,
screen.sheetDetents.count(),
)
private set
private val sheetStateObserver = SheetStateObserver()
private val keyboardHandlerCallback = KeyboardHandler()
private val sheetBehavior: BottomSheetBehavior<Screen>?
get() = screen.sheetBehavior
private val stackFragment: ScreenStackFragment
get() = screen.fragment as ScreenStackFragment
private fun requireDecorView(): View =
checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" }
.window.decorView
init {
assert(screen.fragment is ScreenStackFragment) { "[RNScreens] Sheets are supported only in native stack" }
screen.fragment!!.lifecycle.addObserver(this)
checkNotNull(sheetBehavior) { "[RNScreens] Sheet delegate accepts screen with initialized sheet behaviour only." }
.addBottomSheetCallback(sheetStateObserver)
}
// LifecycleEventObserver
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event,
) {
when (event) {
Lifecycle.Event.ON_START -> handleHostFragmentOnStart()
Lifecycle.Event.ON_RESUME -> handleHostFragmentOnResume()
Lifecycle.Event.ON_PAUSE -> handleHostFragmentOnPause()
else -> Unit
}
}
private fun handleHostFragmentOnStart() {
InsetsObserverProxy.registerOnView(requireDecorView())
}
private fun handleHostFragmentOnResume() {
InsetsObserverProxy.addOnApplyWindowInsetsListener(this)
}
private fun handleHostFragmentOnPause() {
InsetsObserverProxy.removeOnApplyWindowInsetsListener(this)
}
private fun onSheetStateChanged(newState: Int) {
val isStable = SheetUtils.isStateStable(newState)
if (isStable) {
lastStableState = newState
lastStableDetentIndex =
SheetUtils.detentIndexFromSheetState(
newState,
screen.sheetDetents.count(),
)
}
screen.onSheetDetentChanged(lastStableDetentIndex, isStable)
if (shouldDismissSheetInState(newState)) {
stackFragment.dismissSelf()
}
}
internal fun configureBottomSheetBehaviour(
behavior: BottomSheetBehavior<Screen>,
keyboardState: KeyboardState = KeyboardNotVisible,
selectedDetentIndex: Int = lastStableDetentIndex,
): BottomSheetBehavior<Screen> {
val containerHeight = tryResolveContainerHeight()
check(containerHeight != null) {
"[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
}
behavior.apply {
isHideable = true
isDraggable = true
}
// There is a guard internally that does not allow the callback to be duplicated.
behavior.addBottomSheetCallback(sheetStateObserver)
screen.footer?.registerWithSheetBehavior(behavior)
return when (keyboardState) {
is KeyboardNotVisible -> {
when (screen.sheetDetents.count()) {
1 ->
behavior.apply {
val height =
if (screen.isSheetFitToContents()) {
screen.contentWrapper?.let { contentWrapper ->
contentWrapper.height.takeIf {
// subtree might not be laid out, e.g. after fragment reattachment
// and view recreation, however since it is retained by
// react-native it has its height cached. We want to use it.
// Otherwise we would have to trigger RN layout manually.
contentWrapper.isLaidOutOrHasCachedLayout()
}
}
} else {
(screen.sheetDetents.first() * containerHeight).toInt()
}
useSingleDetent(height = height)
}
2 ->
behavior.useTwoDetents(
state =
SheetUtils.sheetStateFromDetentIndex(
selectedDetentIndex,
screen.sheetDetents.count(),
),
firstHeight = (screen.sheetDetents[0] * containerHeight).toInt(),
secondHeight = (screen.sheetDetents[1] * containerHeight).toInt(),
)
3 ->
behavior.useThreeDetents(
state =
SheetUtils.sheetStateFromDetentIndex(
selectedDetentIndex,
screen.sheetDetents.count(),
),
firstHeight = (screen.sheetDetents[0] * containerHeight).toInt(),
halfExpandedRatio = (screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat(),
expandedOffsetFromTop = ((1 - screen.sheetDetents[2]) * containerHeight).toInt(),
)
else -> throw IllegalStateException(
"[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.",
)
}
}
is KeyboardVisible -> {
when (screen.sheetDetents.count()) {
1 ->
behavior.apply {
addBottomSheetCallback(keyboardHandlerCallback)
}
2 ->
behavior.apply {
useTwoDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
addBottomSheetCallback(keyboardHandlerCallback)
}
3 ->
behavior.apply {
useThreeDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
addBottomSheetCallback(keyboardHandlerCallback)
}
else -> throw IllegalStateException(
"[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.",
)
}
}
is KeyboardDidHide -> {
// Here we assume that the keyboard was either closed explicitly by user,
// or the user dragged the sheet down. In any case the state should
// stay unchanged.
behavior.removeBottomSheetCallback(keyboardHandlerCallback)
when (screen.sheetDetents.count()) {
1 ->
behavior.useSingleDetent(
height = (screen.sheetDetents.first() * containerHeight).toInt(),
forceExpandedState = false,
)
2 ->
behavior.useTwoDetents(
firstHeight = (screen.sheetDetents[0] * containerHeight).toInt(),
secondHeight = (screen.sheetDetents[1] * containerHeight).toInt(),
)
3 ->
behavior.useThreeDetents(
firstHeight = (screen.sheetDetents[0] * containerHeight).toInt(),
halfExpandedRatio = (screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat(),
expandedOffsetFromTop = ((1 - screen.sheetDetents[2]) * containerHeight).toInt(),
)
else -> throw IllegalStateException(
"[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.",
)
}
}
}
}
// This is listener function, not the view's.
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat,
): WindowInsetsCompat {
val isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeInset = insets.getInsets(WindowInsetsCompat.Type.ime())
if (isImeVisible) {
isKeyboardVisible = true
keyboardState = KeyboardVisible(imeInset.bottom)
sheetBehavior?.let {
this.configureBottomSheetBehaviour(it, keyboardState)
}
val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
return WindowInsetsCompat
.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.navigationBars(),
Insets.of(
prevInsets.left,
prevInsets.top,
prevInsets.right,
0,
),
).build()
} else {
sheetBehavior?.let {
if (isKeyboardVisible) {
this.configureBottomSheetBehaviour(it, KeyboardDidHide)
} else if (keyboardState != KeyboardNotVisible) {
this.configureBottomSheetBehaviour(it, KeyboardNotVisible)
} else {
}
}
keyboardState = KeyboardNotVisible
isKeyboardVisible = false
}
val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
return WindowInsetsCompat
.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.navigationBars(),
Insets.of(prevInsets.left, prevInsets.top, prevInsets.right, 0),
).build()
}
private fun shouldDismissSheetInState(
@BottomSheetBehavior.State state: Int,
) = state == BottomSheetBehavior.STATE_HIDDEN
/**
* This method might return slightly different values depending on code path,
* but during testing I've found this effect negligible. For practical purposes
* this is acceptable.
*/
private fun tryResolveContainerHeight(): Int? {
screen.container?.let { return it.height }
val context = screen.reactContext
context
.resources
?.displayMetrics
?.heightPixels
?.let { return it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
(context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)
?.currentWindowMetrics
?.bounds
?.height()
?.let { return it }
}
return null
}
private inner class KeyboardHandler : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(
bottomSheet: View,
newState: Int,
) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
val isImeVisible =
WindowInsetsCompat
.toWindowInsetsCompat(bottomSheet.rootWindowInsets)
.isVisible(WindowInsetsCompat.Type.ime())
if (isImeVisible) {
// Does it not interfere with React Native focus mechanism? In any case I'm not aware
// of different way of hiding the keyboard.
// https://stackoverflow.com/questions/1109022/how-can-i-close-hide-the-android-soft-keyboard-programmatically
// https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility
// I want to be polite here and request focus before dismissing the keyboard,
// however even if it fails I want to try to hide the keyboard. This sometimes works...
bottomSheet.requestFocus()
val imm =
screen.reactContext.getSystemService(InputMethodManager::class.java)
imm.hideSoftInputFromWindow(bottomSheet.windowToken, 0)
}
}
}
override fun onSlide(
bottomSheet: View,
slideOffset: Float,
) = Unit
}
private inner class SheetStateObserver : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(
bottomSheet: View,
newState: Int,
) {
this@SheetDelegate.onSheetStateChanged(newState)
}
override fun onSlide(
bottomSheet: View,
slideOffset: Float,
) = Unit
}
companion object {
const val TAG = "SheetDelegate"
}
}
@@ -0,0 +1,158 @@
package com.swmansion.rnscreens.bottomsheet
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.swmansion.rnscreens.BuildConfig
import com.swmansion.rnscreens.Screen
object SheetUtils {
/**
* Verifies whether BottomSheetBehavior.State is one of stable states. As unstable states
* we consider `STATE_DRAGGING` and `STATE_SETTLING`.
*
* @param state bottom sheet state to verify
*/
fun isStateStable(state: Int): Boolean =
when (state) {
STATE_HIDDEN,
STATE_EXPANDED,
STATE_COLLAPSED,
STATE_HALF_EXPANDED,
-> true
else -> false
}
/**
* This method maps indices from legal detents array (prop) to appropriate values
* recognized by BottomSheetBehaviour. In particular used when setting up the initial behaviour
* of the form sheet.
*
* @param index index from array with detents fractions
* @param detentCount length of array with detents fractions
*
* @throws IllegalArgumentException for invalid index / detentCount combinations
*/
fun sheetStateFromDetentIndex(
index: Int,
detentCount: Int,
): Int =
when (detentCount) {
1 ->
when (index) {
-1 -> STATE_HIDDEN
0 -> STATE_EXPANDED
else -> throw IllegalArgumentException("[RNScreens] Invalid detentCount/index combination $detentCount / $index")
}
2 ->
when (index) {
-1 -> STATE_HIDDEN
0 -> STATE_COLLAPSED
1 -> STATE_EXPANDED
else -> throw IllegalArgumentException("[RNScreens] Invalid detentCount/index combination $detentCount / $index")
}
3 ->
when (index) {
-1 -> STATE_HIDDEN
0 -> STATE_COLLAPSED
1 -> STATE_HALF_EXPANDED
2 -> STATE_EXPANDED
else -> throw IllegalArgumentException("[RNScreens] Invalid detentCount/index combination $detentCount / $index")
}
else -> throw IllegalArgumentException("[RNScreens] Invalid detentCount/index combination $detentCount / $index")
}
/**
* This method maps BottomSheetBehavior.State values to appropriate indices of detents array.
*
* @param state state of the bottom sheet
* @param detentCount length of array with detents fractions
*
* @throws IllegalArgumentException for invalid state / detentCount combinations
*/
fun detentIndexFromSheetState(
@BottomSheetBehavior.State state: Int,
detentCount: Int,
): Int =
when (detentCount) {
1 ->
when (state) {
STATE_HIDDEN -> -1
STATE_EXPANDED -> 0
else -> throw IllegalArgumentException("[RNScreens] Invalid state $state for detentCount $detentCount")
}
2 ->
when (state) {
STATE_HIDDEN -> -1
STATE_COLLAPSED -> 0
STATE_EXPANDED -> 1
else -> throw IllegalArgumentException("[RNScreens] Invalid state $state for detentCount $detentCount")
}
3 ->
when (state) {
STATE_HIDDEN -> -1
STATE_COLLAPSED -> 0
STATE_HALF_EXPANDED -> 1
STATE_EXPANDED -> 2
else -> throw IllegalArgumentException("[RNScreens] Invalid state $state for detentCount $detentCount")
}
else -> throw IllegalArgumentException("[RNScreens] Invalid state $state for detentCount $detentCount")
}
fun isStateLessEqualThan(
state: Int,
otherState: Int,
): Boolean {
if (state == otherState) {
return true
}
if (state != STATE_HALF_EXPANDED && otherState != STATE_HALF_EXPANDED) {
return state > otherState
}
if (state == STATE_HALF_EXPANDED) {
return otherState == BottomSheetBehavior.STATE_EXPANDED
}
if (state == STATE_COLLAPSED) {
return otherState != STATE_HIDDEN
}
return false
}
}
fun Screen.isSheetFitToContents(): Boolean =
stackPresentation === Screen.StackPresentation.FORM_SHEET &&
sheetDetents.count() == 1 &&
sheetDetents.first() == Screen.SHEET_FIT_TO_CONTENTS
fun Screen.usesFormSheetPresentation(): Boolean = stackPresentation === Screen.StackPresentation.FORM_SHEET
fun Screen.requiresEnterTransitionPostponing(): Boolean {
// On old architecture the content wrapper might not have received its frame yet,
// which is required to determine height of the sheet after animation. Therefore
// we delay the transition and trigger it after views receive the layout.
// This is used only for formSheet presentation, because we use value animators
// there. Tween animations have some magic way to make this work (maybe they
// postpone the transition internally, dunno).
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED || !this.usesFormSheetPresentation()) {
return false
}
// Assumes that formSheet uses content wrapper
return !this.isLaidOutOrHasCachedLayout() || this.contentWrapper?.isLaidOutOrHasCachedLayout() != true
}
/**
* The view might not be laid out, but have cached dimensions e.g. when host fragment
* is reattached to container.
*/
fun View.isLaidOutOrHasCachedLayout() = this.isLaidOut || height > 0 || width > 0
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class HeaderAttachedEvent(
surfaceId: Int,
viewId: Int,
) : Event<HeaderAttachedEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topAttached"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class HeaderBackButtonClickedEvent(
surfaceId: Int,
viewId: Int,
) : Event<HeaderBackButtonClickedEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topHeaderBackButtonClicked"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class HeaderDetachedEvent(
surfaceId: Int,
viewId: Int,
) : Event<HeaderDetachedEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topDetached"
}
}
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class HeaderHeightChangeEvent(
surfaceId: Int,
viewId: Int,
private val headerHeight: Int,
) : Event<HeaderHeightChangeEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// As the same header height could appear twice, use header height as a coalescing key.
override fun getCoalescingKey(): Short = headerHeight.toShort()
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putDouble("headerHeight", headerHeight.toDouble())
}
companion object {
const val EVENT_NAME = "topHeaderHeightChange"
}
}
@@ -0,0 +1,87 @@
package com.swmansion.rnscreens.events
import android.animation.Animator
import com.swmansion.rnscreens.ScreenStackFragmentWrapper
// The goal is to make this universal delegate for handling animation progress related logic.
// At this moment this class works only with form sheet presentation.
class ScreenAnimationDelegate(
private val wrapper: ScreenStackFragmentWrapper,
private val eventEmitter: ScreenEventEmitter?,
private val animationType: AnimationType,
) : Animator.AnimatorListener {
enum class AnimationType {
ENTER,
EXIT,
}
private var currentState: LifecycleState = LifecycleState.INITIALIZED
private fun progressState() {
currentState =
when (currentState) {
LifecycleState.INITIALIZED -> LifecycleState.START_DISPATCHED
LifecycleState.START_DISPATCHED -> LifecycleState.END_DISPATCHED
LifecycleState.END_DISPATCHED -> LifecycleState.END_DISPATCHED
}
}
override fun onAnimationStart(animation: Animator) {
if (currentState === LifecycleState.INITIALIZED) {
progressState()
// These callbacks do not work as expected from this call site, TODO: investigate it.
// To fix it quickly we emit required events manually
// wrapper.onViewAnimationStart()
when (animationType) {
AnimationType.ENTER -> eventEmitter?.dispatchOnWillAppear()
AnimationType.EXIT -> eventEmitter?.dispatchOnWillDisappear()
}
val isExitAnimation = animationType === AnimationType.EXIT
eventEmitter?.dispatchTransitionProgress(
0.0f,
isExitAnimation,
isExitAnimation,
)
}
}
override fun onAnimationEnd(animation: Animator) {
if (currentState === LifecycleState.START_DISPATCHED) {
progressState()
animation.removeListener(this)
// wrapper.onViewAnimationEnd()
when (animationType) {
AnimationType.ENTER -> eventEmitter?.dispatchOnAppear()
AnimationType.EXIT -> eventEmitter?.dispatchOnDisappear()
}
val isExitAnimation = animationType === AnimationType.EXIT
eventEmitter?.dispatchTransitionProgress(
1.0f,
isExitAnimation,
isExitAnimation,
)
wrapper.screen.endRemovalTransition()
}
}
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
private enum class LifecycleState {
INITIALIZED,
START_DISPATCHED,
END_DISPATCHED,
}
companion object {
const val TAG = "ScreenEventDelegate"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenAppearEvent(
surfaceId: Int,
viewId: Int,
) : Event<ScreenAppearEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topAppear"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenDisappearEvent(
surfaceId: Int,
viewId: Int,
) : Event<ScreenDisappearEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topDisappear"
}
}
@@ -0,0 +1,24 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenDismissedEvent(
surfaceId: Int,
viewId: Int,
) : Event<ScreenDismissedEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putInt("dismissCount", 1)
}
companion object {
const val EVENT_NAME = "topDismissed"
}
}
@@ -0,0 +1,39 @@
package com.swmansion.rnscreens.events
import com.facebook.react.uimanager.UIManagerHelper
import com.swmansion.rnscreens.Screen
import com.swmansion.rnscreens.ScreenFragment
// TODO: Consider taking weak ref here or accepting screen as argument in every method
// to avoid reference cycle.
class ScreenEventEmitter(
val screen: Screen,
) {
val reactEventDispatcher
get() = screen.reactEventDispatcher
val reactSurfaceId
get() = UIManagerHelper.getSurfaceId(screen)
fun dispatchOnWillAppear() = reactEventDispatcher?.dispatchEvent(ScreenWillAppearEvent(reactSurfaceId, screen.id))
fun dispatchOnAppear() = reactEventDispatcher?.dispatchEvent(ScreenAppearEvent(reactSurfaceId, screen.id))
fun dispatchOnWillDisappear() = reactEventDispatcher?.dispatchEvent(ScreenWillDisappearEvent(reactSurfaceId, screen.id))
fun dispatchOnDisappear() = reactEventDispatcher?.dispatchEvent(ScreenDisappearEvent(reactSurfaceId, screen.id))
fun dispatchOnDismissed() = reactEventDispatcher?.dispatchEvent(ScreenDismissedEvent(reactSurfaceId, screen.id))
fun dispatchTransitionProgress(
progress: Float,
isExitAnimation: Boolean,
isGoingForward: Boolean,
) {
val sanitizedProgress = progress.coerceIn(0.0f, 1.0f)
val coalescingKey = ScreenFragment.getCoalescingKey(sanitizedProgress)
reactEventDispatcher?.dispatchEvent(
ScreenTransitionProgressEvent(reactSurfaceId, screen.id, sanitizedProgress, isExitAnimation, isGoingForward, coalescingKey),
)
}
}
@@ -0,0 +1,29 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenTransitionProgressEvent(
surfaceId: Int,
viewId: Int,
private val progress: Float,
private val isClosing: Boolean,
private val isGoingForward: Boolean,
private val coalescingKey: Short,
) : Event<ScreenTransitionProgressEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getCoalescingKey(): Short = coalescingKey
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putDouble("progress", progress.toDouble())
putInt("closing", if (isClosing) 1 else 0)
putInt("goingForward", if (isGoingForward) 1 else 0)
}
companion object {
const val EVENT_NAME = "topTransitionProgress"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenWillAppearEvent(
surfaceId: Int,
viewId: Int,
) : Event<ScreenWillAppearEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topWillAppear"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class ScreenWillDisappearEvent(
surfaceId: Int,
viewId: Int,
) : Event<ScreenWillDisappearEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topWillDisappear"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarBlurEvent(
surfaceId: Int,
viewId: Int,
) : Event<SearchBarBlurEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topSearchBlur"
}
}
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarChangeTextEvent(
surfaceId: Int,
viewId: Int,
private val text: String?,
) : Event<SearchBarChangeTextEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putString("text", text)
}
companion object {
const val EVENT_NAME = "topChangeText"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarCloseEvent(
surfaceId: Int,
viewId: Int,
) : Event<SearchBarCloseEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topClose"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarFocusEvent(
surfaceId: Int,
viewId: Int,
) : Event<SearchBarFocusEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topSearchFocus"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarOpenEvent(
surfaceId: Int,
viewId: Int,
) : Event<SearchBarOpenEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topOpen"
}
}
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SearchBarSearchButtonPressEvent(
surfaceId: Int,
viewId: Int,
private val text: String?,
) : Event<SearchBarSearchButtonPressEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putString("text", text)
}
companion object {
const val EVENT_NAME = "topSearchButtonPress"
}
}
@@ -0,0 +1,27 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class SheetDetentChangedEvent(
surfaceId: Int,
viewId: Int,
val index: Int,
val isStable: Boolean,
) : Event<SheetDetentChangedEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putInt("index", index)
putBoolean("isStable", isStable)
}
companion object {
const val EVENT_NAME = "topSheetDetentChanged"
}
}
@@ -0,0 +1,21 @@
package com.swmansion.rnscreens.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
class StackFinishTransitioningEvent(
surfaceId: Int,
viewId: Int,
) : Event<StackFinishTransitioningEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object {
const val EVENT_NAME = "topFinishTransitioning"
}
}
@@ -0,0 +1,6 @@
package com.swmansion.rnscreens.ext
import androidx.fragment.app.Fragment
import com.swmansion.rnscreens.ScreenStackFragment
internal fun Fragment.asScreenStackFragment() = this as ScreenStackFragment
@@ -0,0 +1,12 @@
package com.swmansion.rnscreens.ext
import kotlin.math.abs
/**
* 1e-4 should be a reasonable default value for graphic-related use cases.
* You should always make sure that it is feasible in your particular use case.
*/
internal fun Float.equalWithRespectToEps(
other: Float,
eps: Float = 1e-4F,
) = abs(this - other) <= eps
@@ -0,0 +1,39 @@
package com.swmansion.rnscreens.ext
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
internal fun View.parentAsView() = this.parent as? View
internal fun View.parentAsViewGroup() = this.parent as? ViewGroup
internal fun View.recycle(): View {
// screen fragments reuse view instances instead of creating new ones. In order to reuse a given
// view it needs to be detached from the view hierarchy to allow the fragment to attach it back.
this.parentAsViewGroup()?.let { parent ->
parent.endViewTransition(this)
parent.removeView(this)
}
// view detached from fragment manager get their visibility changed to GONE after their state is
// dumped. Since we don't restore the state but want to reuse the view we need to change
// visibility back to VISIBLE in order for the fragment manager to animate in the view.
this.visibility = View.VISIBLE
// Needed for cases where the Screen is is animated by translationY manipulation (e.g. formSheet)
// and then reused (reattached).
this.translationY = 0f
return this
}
internal fun View.maybeBgColor(): Int? {
val bgDrawable = this.background
if (bgDrawable is ColorDrawable) {
return bgDrawable.color
}
return null
}
internal fun View.asViewGroupOrNull(): ViewGroup? = this as? ViewGroup
@@ -0,0 +1,38 @@
package com.swmansion.rnscreens.fragment.restoration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
/**
* This class serves as a workaround to https://github.com/software-mansion/react-native-screens/issues/17.
*
* This fragment, when attached to the fragment manager & its state is progressed
* to `ON_CREATED`, attempts to detach itself from the parent fragment manager
* as soon as possible.
*
* Instances of this type should be created in place of regular screen fragments
* when Android restores fragments after activity / application restart.
* If done so, it's behaviour can prevent duplicated fragment instances,
* as React will render new ones on activity restart.
*/
class AutoRemovingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// This is the first moment where we have access to non-null parent fragment manager,
// so that we can remove the fragment from the hierarchy.
parentFragmentManager
.beginTransaction()
.remove(this)
.commitAllowingStateLoss()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? = null
}
@@ -0,0 +1,13 @@
package com.swmansion.rnscreens.fragment.restoration
class RNScreensFragmentFactory : androidx.fragment.app.FragmentFactory() {
override fun instantiate(
classLoader: ClassLoader,
className: String,
): androidx.fragment.app.Fragment =
if (className.startsWith(com.swmansion.rnscreens.BuildConfig.LIBRARY_PACKAGE_NAME)) {
AutoRemovingFragment()
} else {
super.instantiate(classLoader, className)
}
}
@@ -0,0 +1,22 @@
package com.swmansion.rnscreens.gamma.common
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
internal abstract class BaseEventEmitter(
val reactContext: ReactContext,
val viewTag: Int,
) {
protected val reactEventDispatcher: EventDispatcher =
checkNotNull(UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewTag)) {
"[RNScreens] Nullish event dispatcher for view with tag: $viewTag"
}
protected val surfaceId: Int
get() = UIManagerHelper.getSurfaceId(reactContext)
companion object {
const val TAG = "BaseEventEmitter"
}
}
@@ -0,0 +1,11 @@
package com.swmansion.rnscreens.gamma.common
import androidx.fragment.app.Fragment
/**
* Implementors of this interface indicate that they do have a fragment associated with them, that
* can be used to retrieve child fragment manager for nesting operations.
*/
interface FragmentProviding {
fun getAssociatedFragment(): Fragment?
}
@@ -0,0 +1,13 @@
package com.swmansion.rnscreens.gamma.common
internal interface NamingAwareEventType {
/**
* React event name with `top` prefix
*/
fun getEventName(): String
/**
* Name of the event as expected in Element Tree.
*/
fun getEventRegistrationName(): String
}
@@ -0,0 +1,6 @@
package com.swmansion.rnscreens.gamma.helpers
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
internal fun makeEventRegistrationInfo(event: NamingAwareEventType): Pair<String, HashMap<String, String>> =
event.getEventName() to hashMapOf("registrationName" to event.getEventRegistrationName())
@@ -0,0 +1,76 @@
package com.swmansion.rnscreens.gamma.helpers
import android.content.ContextWrapper
import android.view.ViewGroup
import android.view.ViewParent
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import com.facebook.react.ReactRootView
import com.swmansion.rnscreens.gamma.common.FragmentProviding
object FragmentManagerHelper {
fun findFragmentManagerForView(view: ViewGroup): FragmentManager? {
var parent: ViewParent = view
// We traverse view hierarchy up until we find fragment providing parent or a root view
while (!(parent is ReactRootView || parent is FragmentProviding) &&
parent.parent != null
) {
parent = parent.parent
}
// If parent adheres to FragmentProviding interface it means we are inside a nested fragment structure.
// Otherwise we expect to connect directly with root view and get root fragment manager
if (parent is FragmentProviding) {
return checkNotNull(parent.getAssociatedFragment()) {
"[RNScreens] Parent fragment providing view $parent returned nullish fragment"
}.childFragmentManager
} else {
// we expect top level view to be of type ReactRootView, this isn't really necessary but in
// order to find root view we test if parent is null. This could potentially happen also when
// the view is detached from the hierarchy and that test would not correctly indicate the root
// view. So in order to make sure we indeed reached the root we test if it is of a correct type.
// This allows us to provide a more descriptive error message for the aforementioned case.
check(
parent is ReactRootView,
) { "[RNScreens] Expected parent to be a ReactRootView, instead found: ${parent::class.java.name}" }
return resolveFragmentManagerForReactRootView(parent)
}
}
private fun resolveFragmentManagerForReactRootView(rootView: ReactRootView): FragmentManager? {
var context = rootView.context
// ReactRootView is expected to be initialized with the main React Activity as a context but
// in case of Expo the activity is wrapped in ContextWrapper and we need to unwrap it
while (context !is FragmentActivity && context is ContextWrapper) {
context = context.baseContext
}
check(context is FragmentActivity) {
"[RNScreens] In order to use react-native-screens components your app's activity need to extend ReactActivity"
}
// In case React Native is loaded on a Fragment (not directly in activity) we need to find
// fragment manager whose fragment's view is ReactRootView. As of now, we detect such case by
// checking whether any fragments are attached to activity which hosts ReactRootView.
// See: https://github.com/software-mansion/react-native-screens/issues/1506 on why the cases
// must be treated separately.
return if (context.supportFragmentManager.fragments.isEmpty()) {
// We are in standard React Native application w/o custom native navigation based on fragments.
context.supportFragmentManager
} else {
// We are in some custom setup & we want to use the closest fragment manager in hierarchy.
// `findFragment` method throws IllegalStateException when it fails to resolve appropriate
// fragment. It might happen when e.g. React Native is loaded directly in Activity
// but some custom fragments are still used. Such use case seems highly unlikely
// so, as for now we fallback to activity's FragmentManager in hope for the best.
try {
FragmentManager.findFragment<Fragment>(rootView).childFragmentManager
} catch (ex: IllegalStateException) {
context.supportFragmentManager
}
}
}
}
@@ -0,0 +1,33 @@
package com.swmansion.rnscreens.gamma.helpers
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import com.swmansion.rnscreens.gamma.tabs.TabScreen.Companion.TAG
internal fun getSystemDrawableResource(
context: Context,
iconName: String?,
): Drawable? {
if (iconName == null) {
return null
}
// Try to get resource app scope
val appDrawableId = context.resources.getIdentifier(iconName, "drawable", context.packageName)
if (appDrawableId > 0) {
return AppCompatResources.getDrawable(context, appDrawableId)
}
// Try to get resource from system scope
val systemDrawableId = context.resources.getIdentifier(iconName, "drawable", "android")
if (systemDrawableId > 0) {
return AppCompatResources.getDrawable(context, systemDrawableId)
}
Log.w(TAG, "TabScreen could not resolve drawable resource with the name $iconName")
return null
}
@@ -0,0 +1,58 @@
package com.swmansion.rnscreens.gamma.helpers
import androidx.annotation.UiThread
interface ViewIdProviding {
fun generateViewId(): Int
}
/**
* This class generates view id that should not collide with ids (tags) assigned by renderer to React
* components.
*
* This class relies on internal renderer behaviours, that happened to change over the time, therefore
* it must be revisited & kept up to date with current RN behaviour until better solution is found.
*
* Until RN 0.81 ReactSurfaceView had tag `11`. In later versions it was changed to `1` (new arch only change).
*
* Up to the time of writing this (RN 0.81.0-rc.5) every version incremented tag version by 2,
* starting from `(ReactSurfaceView.tag + 1)`, therefore all tags were even. We start generating all
* odd numbers from 3, except valid root view tags, which are 1, 11, 21, etc. This should work no matter
* exact RN version.
*
* Reference:
*
* * https://github.com/facebook/react-native/blob/739dfd2141015a8126448bda64a559f5bf22672e/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java#L136
* * https://github.com/facebook/react-native/commit/8bcf13407183285bf54b336593357889764de230
* * https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactRootViewTagGenerator.kt
* * https://github.com/facebook/react-native/blob/main/packages/react-native/ReactCommon/react/renderer/core/ReactRootViewTagGenerator.cpp
*/
@UiThread
private class NewArchAwareViewIdGenerator : ViewIdProviding {
private var nextId: Int = 3
override fun generateViewId(): Int = nextId.also { progressViewId() }
private fun progressViewId() {
nextId += 2
if (isValidReactRootTag(nextId)) {
nextId += 2
}
}
private fun isValidReactRootTag(tag: Int): Boolean = tag % 10 == 1
}
@UiThread
object ViewIdGenerator : ViewIdProviding {
/**
* Set this field to customize view ids utilized by the library.
*/
var externalGenerator: ViewIdProviding? = null
private val defaultGenerator: ViewIdProviding = NewArchAwareViewIdGenerator()
override fun generateViewId(): Int {
externalGenerator?.let { return it.generateViewId() }
return defaultGenerator.generateViewId()
}
}
@@ -0,0 +1,129 @@
package com.swmansion.rnscreens.gamma.tabs
import android.content.res.Configuration
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.facebook.react.uimanager.ThemedReactContext
import com.swmansion.rnscreens.gamma.common.FragmentProviding
import com.swmansion.rnscreens.gamma.helpers.getSystemDrawableResource
import com.swmansion.rnscreens.utils.RNSLog
import java.lang.ref.WeakReference
import kotlin.properties.Delegates
/**
* React Component view.
*/
class TabScreen(
val reactContext: ThemedReactContext,
) : ViewGroup(reactContext),
FragmentProviding {
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) = Unit
private var tabScreenDelegate: WeakReference<TabScreenDelegate> = WeakReference(null)
internal lateinit var eventEmitter: TabScreenEventEmitter
var tabKey: String? = null
set(value) {
field =
if (value?.isBlank() == true) {
null
} else {
value
}
}
var tabTitle: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}
// Badge
var badgeValue: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}
var tabBarItemBadgeTextColor: Int? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}
var tabBarItemBadgeBackgroundColor: Int? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}
// Icon
var iconResourceName: String? by Delegates.observable(null) { _, oldValue, newValue ->
if (newValue != oldValue) {
icon = getSystemDrawableResource(reactContext, newValue)
}
}
var icon: Drawable? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}
private fun <T> updateMenuItemAttributesIfNeeded(
oldValue: T,
newValue: T,
) {
if (newValue != oldValue) {
onMenuItemAttributesChange()
}
}
override fun onAttachedToWindow() {
RNSLog.d(TAG, "TabScreen [$id] attached to window")
super.onAttachedToWindow()
}
var isFocusedTab: Boolean = false
set(value) {
if (field != value) {
field = value
onTabFocusChangedFromJS()
}
}
internal fun setTabScreenDelegate(delegate: TabScreenDelegate?) {
tabScreenDelegate = WeakReference(delegate)
}
override fun getAssociatedFragment(): Fragment? = tabScreenDelegate.get()?.getFragmentForTabScreen(this)
private fun onTabFocusChangedFromJS() {
tabScreenDelegate.get()?.onTabFocusChangedFromJS(this, isFocusedTab)
}
private fun onMenuItemAttributesChange() {
tabScreenDelegate.get()?.onMenuItemAttributesChange(this)
}
internal fun onViewManagerAddEventEmitters() {
// When this is called from View Manager the view tag is already set
check(id != NO_ID) { "[RNScreens] TabScreen must have its tag set when registering event emitters" }
eventEmitter = TabScreenEventEmitter(reactContext, id)
}
/**
* Notify the view that it's associated fragment got its config updated.
*
* There are cases where the fragment will receive configuration change, but it's view will not,
* e.g. theme update from JS via Appearance.setColorScheme.
*/
internal fun onFragmentConfigurationChange(
fragment: TabScreenFragment,
config: Configuration,
) {
tabScreenDelegate.get()?.onFragmentConfigurationChange(this, config)
}
companion object {
const val TAG = "TabScreen"
}
}
@@ -0,0 +1,27 @@
package com.swmansion.rnscreens.gamma.tabs
import android.content.res.Configuration
import androidx.fragment.app.Fragment
internal interface TabScreenDelegate {
fun onTabFocusChangedFromJS(
tabScreen: TabScreen,
isFocused: Boolean,
)
fun onMenuItemAttributesChange(tabScreen: TabScreen)
/**
* **If a fragment is associated with the tab screen**, notify the delegate that the fragment
* got configuration update.
*/
fun onFragmentConfigurationChange(
tabScreen: TabScreen,
config: Configuration,
)
/**
* This returns fragment **if one is associated with given tab screen**.
*/
fun getFragmentForTabScreen(tabScreen: TabScreen): Fragment?
}
@@ -0,0 +1,46 @@
package com.swmansion.rnscreens.gamma.tabs
import com.facebook.react.bridge.ReactContext
import com.swmansion.rnscreens.gamma.common.BaseEventEmitter
import com.swmansion.rnscreens.gamma.tabs.TabScreenEventEmitter.Companion.TAG
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenDidAppearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenDidDisappearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenWillAppearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenWillDisappearEvent
import com.swmansion.rnscreens.utils.RNSLog
internal class TabScreenEventEmitter(
reactContext: ReactContext,
viewTag: Int,
) : BaseEventEmitter(reactContext, viewTag) {
fun emitOnWillAppear() {
logEventDispatch(viewTag, TabScreenWillAppearEvent.EVENT_REGISTRATION_NAME)
reactEventDispatcher.dispatchEvent(TabScreenWillAppearEvent(surfaceId, viewTag))
}
fun emitOnDidAppear() {
logEventDispatch(viewTag, TabScreenDidAppearEvent.EVENT_REGISTRATION_NAME)
reactEventDispatcher.dispatchEvent(TabScreenDidAppearEvent(surfaceId, viewTag))
}
fun emitOnWillDisappear() {
logEventDispatch(viewTag, TabScreenWillDisappearEvent.EVENT_REGISTRATION_NAME)
reactEventDispatcher.dispatchEvent(TabScreenWillDisappearEvent(surfaceId, viewTag))
}
fun emitOnDidDisappear() {
logEventDispatch(viewTag, TabScreenDidDisappearEvent.EVENT_REGISTRATION_NAME)
reactEventDispatcher.dispatchEvent(TabScreenDidDisappearEvent(surfaceId, viewTag))
}
companion object {
const val TAG = "TabScreenEventEmitter"
}
}
private fun logEventDispatch(
viewTag: Int,
eventName: String,
) {
RNSLog.d(TAG, "TabScreen [$viewTag] emits event: $eventName")
}
@@ -0,0 +1,45 @@
package com.swmansion.rnscreens.gamma.tabs
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
class TabScreenFragment(
internal val tabScreen: TabScreen,
) : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = tabScreen
override fun onStart() {
tabScreen.eventEmitter.emitOnWillAppear()
super.onStart()
}
override fun onResume() {
tabScreen.eventEmitter.emitOnDidAppear()
super.onResume()
}
override fun onPause() {
tabScreen.eventEmitter.emitOnWillDisappear()
super.onPause()
}
override fun onStop() {
tabScreen.eventEmitter.emitOnDidDisappear()
super.onStop()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Handle theme change through RN's Appearance.setColorScheme
tabScreen.onFragmentConfigurationChange(this, newConfig)
}
}
@@ -0,0 +1,272 @@
package com.swmansion.rnscreens.gamma.tabs
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.Log
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.svg.SvgDecoder
import com.facebook.react.bridge.Dynamic
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.module.annotations.ReactModule
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.RNSBottomTabsScreenManagerDelegate
import com.facebook.react.viewmanagers.RNSBottomTabsScreenManagerInterface
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenDidAppearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenDidDisappearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenWillAppearEvent
import com.swmansion.rnscreens.gamma.tabs.event.TabScreenWillDisappearEvent
import com.swmansion.rnscreens.utils.RNSLog
@ReactModule(name = TabScreenViewManager.REACT_CLASS)
class TabScreenViewManager :
ViewGroupManager<TabScreen>(),
RNSBottomTabsScreenManagerInterface<TabScreen> {
private val delegate: ViewManagerDelegate<TabScreen> = RNSBottomTabsScreenManagerDelegate<TabScreen, TabScreenViewManager>(this)
override fun getName() = REACT_CLASS
var imageLoader: ImageLoader? = null
var context: ThemedReactContext? = null
override fun createViewInstance(reactContext: ThemedReactContext): TabScreen {
imageLoader =
ImageLoader
.Builder(reactContext)
.components {
add(SvgDecoder.Factory())
}.build()
context = reactContext
RNSLog.d(REACT_CLASS, "createViewInstance")
return TabScreen(reactContext)
}
override fun getDelegate() = delegate
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
makeEventRegistrationInfo(TabScreenWillAppearEvent),
makeEventRegistrationInfo(TabScreenDidAppearEvent),
makeEventRegistrationInfo(TabScreenWillDisappearEvent),
makeEventRegistrationInfo(TabScreenDidDisappearEvent),
)
override fun addEventEmitters(
reactContext: ThemedReactContext,
view: TabScreen,
) {
super.addEventEmitters(reactContext, view)
view.onViewManagerAddEventEmitters()
}
// These should be ignored or another component, dedicated for Android should be used
override fun setStandardAppearance(
view: TabScreen,
value: Dynamic,
) = Unit
override fun setScrollEdgeAppearance(
view: TabScreen,
value: Dynamic,
) = Unit
@ReactProp(name = "tabBarItemBadgeBackgroundColor", customType = "Color")
override fun setTabBarItemBadgeBackgroundColor(
view: TabScreen,
value: Int?,
) {
view.tabBarItemBadgeBackgroundColor = value
}
override fun setIconType(
view: TabScreen?,
value: String?,
) = Unit
override fun setIconImageSource(
view: TabScreen?,
value: ReadableMap?,
) = Unit
override fun setIconSfSymbolName(
view: TabScreen?,
value: String?,
) = Unit
override fun setSelectedIconImageSource(
view: TabScreen?,
value: ReadableMap?,
) = Unit
override fun setSelectedIconSfSymbolName(
view: TabScreen?,
value: String?,
) = Unit
// Annotation is Paper only
@ReactProp(name = "isFocused")
override fun setIsFocused(
view: TabScreen,
value: Boolean,
) {
RNSLog.d(REACT_CLASS, "TabScreen [${view.id}] setIsFocused $value")
view.isFocusedTab = value
}
@ReactProp(name = "tabKey")
override fun setTabKey(
view: TabScreen,
value: String?,
) {
view.tabKey = value
}
@ReactProp(name = "badgeValue")
override fun setBadgeValue(
view: TabScreen,
value: String?,
) {
view.badgeValue = value
}
@ReactProp(name = "title")
override fun setTitle(
view: TabScreen,
value: String?,
) {
view.tabTitle = value
}
override fun setSpecialEffects(
view: TabScreen,
value: ReadableMap?,
) = Unit
override fun setOverrideScrollViewContentInsetAdjustmentBehavior(
view: TabScreen,
value: Boolean,
) = Unit
// Android specific
@ReactProp(name = "tabBarItemBadgeTextColor", customType = "Color")
override fun setTabBarItemBadgeTextColor(
view: TabScreen,
value: Int?,
) {
view.tabBarItemBadgeTextColor = value
}
@ReactProp(name = "iconResourceName")
override fun setIconResourceName(
view: TabScreen,
value: String?,
) {
view.iconResourceName = value
}
override fun setOrientation(
view: TabScreen,
value: String?,
) = Unit
override fun setSystemItem(
view: TabScreen,
value: String?,
) = Unit
@ReactProp(name = "iconResource")
override fun setIconResource(
view: TabScreen,
value: ReadableMap?,
) {
val uri = value?.getString("uri")
if (uri != null) {
val context = view.context
val source = resolveSource(context, uri)
if (source != null) {
loadUsingCoil(context, source) {
view.icon = it
}
}
}
}
private fun loadUsingCoil(
context: Context,
source: RNSImageSource,
onLoad: (img: Drawable) -> Unit,
) {
val data =
when (source) {
is RNSImageSource.DrawableRes -> source.resId
is RNSImageSource.UriString -> source.uri
}
val request =
ImageRequest
.Builder(context)
.data(data)
.target { drawable ->
val stateDrawable = drawable.asDrawable(context.resources)
onLoad(stateDrawable)
}.listener(
onError = { _, result ->
Log.e("[RNScreens]", "Error loading image: $data", result.throwable)
},
onCancel = {
Log.w("[RNScreens]", "Image loading request cancelled: $data")
},
).build()
imageLoader?.enqueue(request)
}
private fun resolveSource(
context: Context,
uri: String,
): RNSImageSource? {
// In release builds, assets are coming with bundle and we need to work with resource id.
// In debug, metro is responsible for handling assets via http.
// At the moment, we're supporting images (drawable) and SVG icons (raw).
// For any other type, we may consider adding a support in the future if needed.
if (uri.startsWith("_")) {
val drawableResId = context.resources.getIdentifier(uri, "drawable", context.packageName)
if (drawableResId != 0) {
return RNSImageSource.DrawableRes(drawableResId)
}
val rawResId = context.resources.getIdentifier(uri, "raw", context.packageName)
if (rawResId != 0) {
return RNSImageSource.DrawableRes(rawResId)
}
Log.e("[RNScreens]", "Resource not found in drawable or raw: $uri")
return null
}
// If asset isn't included in android source directories and we're loading it from given path.
return RNSImageSource.UriString(uri)
}
private sealed class RNSImageSource {
data class DrawableRes(
val resId: Int,
) : RNSImageSource()
data class UriString(
val uri: String,
) : RNSImageSource()
}
companion object {
const val REACT_CLASS = "RNSBottomTabsScreen"
}
}
@@ -0,0 +1,430 @@
package com.swmansion.rnscreens.gamma.tabs
import android.content.res.Configuration
import android.view.Choreographer
import android.view.MenuItem
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.view.ContextThemeWrapper
import androidx.fragment.app.FragmentManager
import com.facebook.react.modules.core.ReactChoreographer
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.ViewIdGenerator
import com.swmansion.rnscreens.utils.RNSLog
import kotlin.properties.Delegates
class TabsHost(
val reactContext: ThemedReactContext,
) : LinearLayout(reactContext),
TabScreenDelegate {
/**
* All container updates should go through instance of this class.
* The semantics are as follows:
*
* * `invalidateXXX` methods do mark that some update is required, however **they do not schedule the update**!
* * `postXXX` methods schedule an update
* * `runXXX` methods execute update synchronously
*
* If there is a posted update & before it is executed updates are flushed synchronously, then
* the posted update becomes a noop.
*/
private inner class ContainerUpdateCoordinator {
private var isUpdatePending: Boolean = false
private var isSelectedTabInvalidated: Boolean = false
private var isBottomNavigationMenuInvalidated: Boolean = false
fun invalidateSelectedTab() {
isSelectedTabInvalidated = true
}
fun invalidateNavigationMenu() {
isBottomNavigationMenuInvalidated = true
}
fun invalidateAll() {
invalidateSelectedTab()
invalidateNavigationMenu()
}
fun postContainerUpdateIfNeeded() {
if (isUpdatePending) {
return
}
postContainerUpdate()
}
fun postContainerUpdate() {
isUpdatePending = true
post {
runContainerUpdateIfNeeded()
}
}
private fun runContainerUpdateIfNeeded() {
if (isUpdatePending) {
runContainerUpdate()
}
}
fun runContainerUpdate() {
isUpdatePending = false
if (isSelectedTabInvalidated) {
isSelectedTabInvalidated = false
this@TabsHost.updateSelectedTab()
}
if (isBottomNavigationMenuInvalidated) {
isBottomNavigationMenuInvalidated = false
this@TabsHost.updateBottomNavigationViewAppearance()
}
}
}
private val containerUpdateCoordinator = ContainerUpdateCoordinator()
private val wrappedContext =
ContextThemeWrapper(
reactContext,
com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar,
)
private val bottomNavigationView: BottomNavigationView =
BottomNavigationView(wrappedContext).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
private val contentView: FrameLayout =
FrameLayout(reactContext).apply {
layoutParams =
LinearLayout
.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT,
).apply {
weight = 1f
}
id = ViewIdGenerator.generateViewId()
}
internal lateinit var eventEmitter: TabsHostEventEmitter
private var fragmentManager: FragmentManager? = null
private val requireFragmentManager
get() = checkNotNull(fragmentManager) { "[RNScreens] Nullish fragment manager" }
private val tabScreenFragments: MutableList<TabScreenFragment> = arrayListOf()
private var lastAppliedUiMode: Int? = null
private var isLayoutEnqueued: Boolean = false
private val appearanceCoordinator =
TabsHostAppearanceCoordinator(wrappedContext, bottomNavigationView, tabScreenFragments)
var tabBarBackgroundColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemActiveIndicatorColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var isTabBarItemActiveIndicatorEnabled: Boolean by Delegates.observable(true) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemIconColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontFamily: String? by Delegates.observable<String?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemIconColorActive: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontColor: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontColorActive: Int? by Delegates.observable<Int?>(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontSize: Float? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontSizeActive: Float? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontWeight: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemTitleFontStyle: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemRippleColor: Int? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
var tabBarItemLabelVisibilityMode: String? by Delegates.observable(null) { _, oldValue, newValue ->
updateNavigationMenuIfNeeded(oldValue, newValue)
}
private fun <T> updateNavigationMenuIfNeeded(
oldValue: T,
newValue: T,
) {
if (newValue != oldValue) {
containerUpdateCoordinator.let {
it.invalidateNavigationMenu()
it.postContainerUpdateIfNeeded()
}
}
}
init {
orientation = VERTICAL
addView(contentView)
addView(bottomNavigationView)
bottomNavigationView.addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
RNSLog.d(
TAG,
"BottomNavigationView layout changed {$left, $top} {${right - left}, ${bottom - top}}",
)
}
bottomNavigationView.setOnItemSelectedListener { item ->
RNSLog.d(TAG, "Item selected $item")
val fragment = getFragmentForMenuItemId(item.itemId)
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
eventEmitter.emitOnNativeFocusChange(tabKey)
true
}
}
override fun onAttachedToWindow() {
RNSLog.d(TAG, "TabsHost [$id] attached to window")
super.onAttachedToWindow()
fragmentManager =
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
"[RNScreens] Nullish fragment manager - can't run container operations"
}
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// On Paper the children are not yet attached here.
containerUpdateCoordinator.let {
it.invalidateAll()
it.runContainerUpdate()
}
}
}
internal fun mountReactSubviewAt(
tabScreen: TabScreen,
index: Int,
) {
require(index < bottomNavigationView.maxItemCount) {
"[RNScreens] Attempt to insert TabScreen at index $index; BottomNavigationView supports at most ${bottomNavigationView.maxItemCount} items"
}
val tabScreenFragment = TabScreenFragment(tabScreen)
tabScreenFragments.add(index, tabScreenFragment)
tabScreen.setTabScreenDelegate(this)
containerUpdateCoordinator.let {
it.invalidateAll()
it.postContainerUpdateIfNeeded()
}
}
internal fun unmountReactSubviewAt(index: Int) {
tabScreenFragments.removeAt(index).also { fragment ->
fragment.tabScreen.setTabScreenDelegate(null)
containerUpdateCoordinator.let {
it.invalidateAll()
it.postContainerUpdateIfNeeded()
}
}
}
internal fun unmountReactSubview(reactSubview: TabScreen) {
tabScreenFragments.removeIf { it.tabScreen === reactSubview }.takeIf { it }?.let {
reactSubview.setTabScreenDelegate(null)
containerUpdateCoordinator.let {
it.invalidateAll()
it.postContainerUpdateIfNeeded()
}
}
}
internal fun unmountAllReactSubviews() {
tabScreenFragments.forEach { it.tabScreen.setTabScreenDelegate(null) }
tabScreenFragments.clear()
containerUpdateCoordinator.let {
it.invalidateAll()
it.postContainerUpdateIfNeeded()
}
}
override fun onTabFocusChangedFromJS(
tabScreen: TabScreen,
isFocused: Boolean,
) {
containerUpdateCoordinator.let {
it.invalidateAll()
it.postContainerUpdateIfNeeded()
}
}
override fun onMenuItemAttributesChange(tabScreen: TabScreen) {
getMenuItemForTabScreen(tabScreen)?.let { menuItem ->
appearanceCoordinator.updateMenuItemAppearance(menuItem, tabScreen)
}
}
override fun getFragmentForTabScreen(tabScreen: TabScreen): TabScreenFragment? = tabScreenFragments.find { it.tabScreen === tabScreen }
override fun onFragmentConfigurationChange(
tabScreen: TabScreen,
config: Configuration,
) {
this.onConfigurationChanged(config)
}
private fun updateBottomNavigationViewAppearance() {
RNSLog.d(TAG, "updateBottomNavigationViewAppearance")
appearanceCoordinator.updateTabAppearance(this)
bottomNavigationView.selectedItemId =
checkNotNull(getSelectedTabScreenFragmentId()) { "[RNScreens] A single selected tab must be present" }
post {
refreshLayout()
RNSLog.d(TAG, "BottomNavigationView request layout")
}
}
private fun updateSelectedTab() {
val newFocusedTab =
checkNotNull(tabScreenFragments.find { it.tabScreen.isFocusedTab }) { "[RNScreens] No focused tab present" }
check(requireFragmentManager.fragments.size <= 1) { "[RNScreens] There can be only a single focused tab" }
val oldFocusedTab = requireFragmentManager.fragments.firstOrNull()
if (newFocusedTab === oldFocusedTab) {
return
}
requireFragmentManager
.beginTransaction()
.setReorderingAllowed(true)
.apply {
if (oldFocusedTab != null) {
this.remove(oldFocusedTab)
}
this.add(contentView.id, newFocusedTab)
}.commitNowAllowingStateLoss()
}
private val layoutCallback =
Choreographer.FrameCallback {
isLayoutEnqueued = false
forceSubtreeMeasureAndLayoutPass()
}
private fun refreshLayout() {
@Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
if (!isLayoutEnqueued && layoutCallback != null) {
isLayoutEnqueued = true
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
ReactChoreographer
.getInstance()
.postFrameCallback(
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
layoutCallback,
)
}
}
override fun requestLayout() {
super.requestLayout()
refreshLayout()
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
newConfig?.let {
applyDayNightUiModeIfNeeded(it.uiMode and Configuration.UI_MODE_NIGHT_MASK)
}
}
private fun applyDayNightUiModeIfNeeded(uiMode: Int) {
if (uiMode != lastAppliedUiMode) {
// update the appearance when user toggles between dark/light mode
when (uiMode) {
Configuration.UI_MODE_NIGHT_YES -> {
wrappedContext.setTheme(com.google.android.material.R.style.Theme_Material3_Dark_NoActionBar)
}
Configuration.UI_MODE_NIGHT_NO -> {
wrappedContext.setTheme(com.google.android.material.R.style.Theme_Material3_Light_NoActionBar)
}
else -> {
wrappedContext.setTheme(com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar)
}
}
appearanceCoordinator.updateTabAppearance(this)
lastAppliedUiMode = uiMode
}
}
private fun forceSubtreeMeasureAndLayoutPass() {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
)
layout(left, top, right, bottom)
}
private fun getFragmentForMenuItemId(itemId: Int): TabScreenFragment? = tabScreenFragments.getOrNull(itemId)
private fun getSelectedTabScreenFragmentId(): Int? {
if (tabScreenFragments.isEmpty()) {
return null
}
return checkNotNull(tabScreenFragments.indexOfFirst { it.tabScreen.isFocusedTab }) { "[RNScreens] There must be a focused tab" }
}
private fun getMenuItemForTabScreen(tabScreen: TabScreen): MenuItem? =
tabScreenFragments
.indexOfFirst { it.tabScreen === tabScreen }
.takeIf { it != -1 }
?.let { index ->
bottomNavigationView.menu.findItem(index)
}
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)
}
companion object {
const val TAG = "TabsHost"
}
}
@@ -0,0 +1,198 @@
package com.swmansion.rnscreens.gamma.tabs
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.util.TypedValue
import android.view.MenuItem
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.children
import androidx.core.view.isVisible
import com.facebook.react.common.assets.ReactFontManager
import com.facebook.react.uimanager.PixelUtil
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.navigation.NavigationBarView
@SuppressLint("PrivateResource") // We want to use variables from material design for default values
class TabsHostAppearanceApplicator(
private val context: ContextThemeWrapper,
private val bottomNavigationView: BottomNavigationView,
) {
private fun resolveColorAttr(attr: Int): Int {
val typedValue = TypedValue()
context.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
}
fun updateSharedAppearance(tabsHost: TabsHost) {
bottomNavigationView.isVisible = true
bottomNavigationView.setBackgroundColor(
tabsHost.tabBarBackgroundColor
?: resolveColorAttr(com.google.android.material.R.attr.colorSurfaceContainer),
)
val states =
arrayOf(
intArrayOf(-android.R.attr.state_checked),
intArrayOf(android.R.attr.state_checked),
)
// Font color
val fontInactiveColor =
tabsHost.tabBarItemTitleFontColor
?: resolveColorAttr(com.google.android.material.R.attr.colorOnSurfaceVariant)
val fontActiveColor =
tabsHost.tabBarItemTitleFontColorActive
?: tabsHost.tabBarItemTitleFontColor
?: resolveColorAttr(com.google.android.material.R.attr.colorSecondary)
val fontColors = intArrayOf(fontInactiveColor, fontActiveColor)
bottomNavigationView.itemTextColor = ColorStateList(states, fontColors)
// Icon color
val iconInactiveColor =
tabsHost.tabBarItemIconColor
?: resolveColorAttr(com.google.android.material.R.attr.colorOnSurfaceVariant)
val iconActiveColor =
tabsHost.tabBarItemIconColorActive
?: tabsHost.tabBarItemIconColor
?: resolveColorAttr(com.google.android.material.R.attr.colorOnSecondaryContainer)
val iconColors = intArrayOf(iconInactiveColor, iconActiveColor)
bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors)
// LabelVisibilityMode
// From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED
val visibilityMode =
when (tabsHost.tabBarItemLabelVisibilityMode) {
"selected" -> NavigationBarView.LABEL_VISIBILITY_SELECTED
"labeled" -> NavigationBarView.LABEL_VISIBILITY_LABELED
"unlabeled" -> NavigationBarView.LABEL_VISIBILITY_UNLABELED
else -> NavigationBarView.LABEL_VISIBILITY_AUTO
}
bottomNavigationView.labelVisibilityMode = visibilityMode
// Ripple color
val rippleColor =
tabsHost.tabBarItemRippleColor
?: resolveColorAttr(com.google.android.material.R.attr.itemRippleColor)
bottomNavigationView.itemRippleColor = ColorStateList.valueOf(rippleColor)
// Active Indicator
val activeIndicatorColor =
tabsHost.tabBarItemActiveIndicatorColor
?: resolveColorAttr(com.google.android.material.R.attr.colorSecondaryContainer)
bottomNavigationView.isItemActiveIndicatorEnabled =
tabsHost.isTabBarItemActiveIndicatorEnabled
bottomNavigationView.itemActiveIndicatorColor = ColorStateList.valueOf(activeIndicatorColor)
}
fun updateFontStyles(tabsHost: TabsHost) {
val bottomNavigationMenuView = bottomNavigationView.getChildAt(0) as ViewGroup
for (menuItem in bottomNavigationMenuView.children) {
val largeLabel =
menuItem.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_large_label_view)
val smallLabel =
menuItem.findViewById<TextView>(com.google.android.material.R.id.navigation_bar_item_small_label_view)
val isFontStyleItalic = tabsHost.tabBarItemTitleFontStyle == "italic"
// Bold is 700, normal is 400 -> https://github.com/facebook/react-native/blob/e0efd3eb5b637bd00fb7528ab4d129f6b3e13d03/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.kt#L150
// It can be any other int -> https://reactnative.dev/docs/text-style-props#fontweight
// Default is 400 -> https://github.com/facebook/react-native/blob/e0efd3eb5b637bd00fb7528ab4d129f6b3e13d03/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.kt#L117
val fontWeight =
if (tabsHost.tabBarItemTitleFontWeight ==
"bold"
) {
700
} else {
tabsHost.tabBarItemTitleFontWeight?.toIntOrNull() ?: 400
}
val fontFamily =
ReactFontManager.getInstance().getTypeface(
tabsHost.tabBarItemTitleFontFamily ?: "",
fontWeight,
isFontStyleItalic,
context.assets,
)
/*
Short explanation about computations we're doing below.
R.dimen, has defined value in SP, getDimension converts it to pixels, and by default
TextView.setTextSize accepts SP, so the size is multiplied by density twice. Thus we need
to convert both values to pixels and make sure that setTextSizes is about that.
The Text tag in RN uses SP or DP based on `allowFontScaling` prop. For now we're going
with SP, if there will be a need for skipping scale, the we should introduce similar
`allowFontScaling` prop.
*/
val smallFontSize =
tabsHost.tabBarItemTitleFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) }
?: context.resources.getDimension(com.google.android.material.R.dimen.design_bottom_navigation_text_size)
val largeFontSize =
tabsHost.tabBarItemTitleFontSizeActive?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) }
?: context.resources.getDimension(com.google.android.material.R.dimen.design_bottom_navigation_text_size)
// Inactive
smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize)
smallLabel.typeface = fontFamily
// Active
largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize)
largeLabel.typeface = fontFamily
}
}
fun updateMenuItemAppearance(
menuItem: MenuItem,
tabScreen: TabScreen,
) {
menuItem.title = tabScreen.tabTitle
menuItem.icon = tabScreen.icon
}
fun updateBadgeAppearance(
menuItem: MenuItem,
tabScreen: TabScreen,
) {
val menuItemIndex = bottomNavigationView.menu.children.indexOf(menuItem)
val badgeValue = tabScreen.badgeValue
if (badgeValue == null) {
val badge = bottomNavigationView.getBadge(menuItemIndex)
badge?.isVisible = false
return
}
val badgeValueNumber = badgeValue.toIntOrNull()
val badge = bottomNavigationView.getOrCreateBadge(menuItemIndex)
badge.isVisible = true
badge.clearText()
badge.clearNumber()
if (badgeValueNumber != null) {
badge.number = badgeValueNumber
} else if (badgeValue != "") {
badge.text = badgeValue
}
// Styling
badge.badgeTextColor =
tabScreen.tabBarItemBadgeTextColor
?: resolveColorAttr(com.google.android.material.R.attr.colorOnError)
badge.backgroundColor =
tabScreen.tabBarItemBadgeBackgroundColor
?: resolveColorAttr(com.google.android.material.R.attr.colorError)
}
}
@@ -0,0 +1,46 @@
package com.swmansion.rnscreens.gamma.tabs
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.size
import com.google.android.material.bottomnavigation.BottomNavigationView
class TabsHostAppearanceCoordinator(
context: ContextThemeWrapper,
private val bottomNavigationView: BottomNavigationView,
private val tabScreenFragments: MutableList<TabScreenFragment>,
) {
private val appearanceApplicator = TabsHostAppearanceApplicator(context, bottomNavigationView)
fun updateTabAppearance(tabsHost: TabsHost) {
appearanceApplicator.updateSharedAppearance(tabsHost)
updateMenuItems()
appearanceApplicator.updateFontStyles(tabsHost) // It needs to be updated after updateMenuItems
}
private fun updateMenuItems() {
if (bottomNavigationView.menu.size != tabScreenFragments.size) {
// Most likely first render or some tab has been removed. Let's nuke the menu (easiest option).
bottomNavigationView.menu.clear()
}
tabScreenFragments.forEachIndexed { index, fragment ->
val menuItem = bottomNavigationView.menu.getOrCreateMenuItem(index, fragment.tabScreen)
check(menuItem.itemId == index) { "[RNScreens] Illegal state: menu items are shuffled" }
updateMenuItemAppearance(menuItem, fragment.tabScreen)
}
}
fun updateMenuItemAppearance(
menuItem: MenuItem,
tabScreen: TabScreen,
) {
appearanceApplicator.updateMenuItemAppearance(menuItem, tabScreen)
appearanceApplicator.updateBadgeAppearance(menuItem, tabScreen)
}
}
private fun Menu.getOrCreateMenuItem(
index: Int,
tabScreen: TabScreen,
): MenuItem = this.findItem(index) ?: this.add(Menu.NONE, index, Menu.NONE, tabScreen.tabTitle)
@@ -0,0 +1,14 @@
package com.swmansion.rnscreens.gamma.tabs
import com.facebook.react.bridge.ReactContext
import com.swmansion.rnscreens.gamma.common.BaseEventEmitter
import com.swmansion.rnscreens.gamma.tabs.event.TabsHostNativeFocusChangeEvent
internal class TabsHostEventEmitter(
reactContext: ReactContext,
viewTag: Int,
) : BaseEventEmitter(reactContext, viewTag) {
fun emitOnNativeFocusChange(tabKey: String) {
reactEventDispatcher.dispatchEvent(TabsHostNativeFocusChangeEvent(surfaceId, viewTag, tabKey))
}
}
@@ -0,0 +1,201 @@
package com.swmansion.rnscreens.gamma.tabs
import android.view.View
import com.facebook.react.module.annotations.ReactModule
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.RNSBottomTabsManagerDelegate
import com.facebook.react.viewmanagers.RNSBottomTabsManagerInterface
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
import com.swmansion.rnscreens.gamma.tabs.event.TabsHostNativeFocusChangeEvent
@ReactModule(name = TabsHostViewManager.REACT_CLASS)
class TabsHostViewManager :
ViewGroupManager<TabsHost>(),
RNSBottomTabsManagerInterface<TabsHost> {
private val delegate: ViewManagerDelegate<TabsHost> = RNSBottomTabsManagerDelegate<TabsHost, TabsHostViewManager>(this)
override fun getName() = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) = TabsHost(reactContext)
protected override fun getDelegate(): ViewManagerDelegate<TabsHost> = delegate
override fun addView(
parent: TabsHost,
child: View,
index: Int,
) {
require(child is TabScreen) { "[RNScreens] Attempt to attach child that is not of type ${TabScreen::javaClass.name}" }
parent.mountReactSubviewAt(child, index)
}
override fun removeView(
parent: TabsHost,
child: View,
) {
require(child is TabScreen) { "[RNScreens] Attempt to detach child that is not of type ${TabScreen::javaClass.name}" }
parent.unmountReactSubview(child)
}
override fun removeViewAt(
parent: TabsHost,
index: Int,
) {
parent.unmountReactSubviewAt(index)
}
override fun removeAllViews(parent: TabsHost) {
parent.unmountAllReactSubviews()
}
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
makeEventRegistrationInfo(TabsHostNativeFocusChangeEvent),
)
override fun addEventEmitters(
reactContext: ThemedReactContext,
view: TabsHost,
) {
super.addEventEmitters(reactContext, view)
view.onViewManagerAddEventEmitters()
}
// These should be ignored or another component, dedicated for Android should be used
@ReactProp(name = "tabBarBackgroundColor", customType = "Color")
override fun setTabBarBackgroundColor(
view: TabsHost,
value: Int?,
) {
view.tabBarBackgroundColor = value
}
override fun setTabBarTintColor(
view: TabsHost,
value: Int?,
) = Unit
@ReactProp(name = "tabBarItemTitleFontSize")
override fun setTabBarItemTitleFontSize(
view: TabsHost?,
value: Float,
) {
view?.tabBarItemTitleFontSize = value
}
override fun setControlNavigationStateInJS(
view: TabsHost?,
value: Boolean,
) = Unit
@ReactProp(name = "tabBarItemTitleFontFamily")
override fun setTabBarItemTitleFontFamily(
view: TabsHost,
value: String?,
) {
view.tabBarItemTitleFontFamily = value
}
@ReactProp(name = "tabBarItemTitleFontWeight")
override fun setTabBarItemTitleFontWeight(
view: TabsHost,
value: String?,
) {
view.tabBarItemTitleFontWeight = value
}
@ReactProp(name = "tabBarItemTitleFontStyle")
override fun setTabBarItemTitleFontStyle(
view: TabsHost,
value: String?,
) {
view.tabBarItemTitleFontStyle = value
}
@ReactProp(name = "tabBarItemTitleFontColor", customType = "Color")
override fun setTabBarItemTitleFontColor(
view: TabsHost,
value: Int?,
) {
view.tabBarItemTitleFontColor = value
}
@ReactProp(name = "tabBarItemIconColor", customType = "Color")
override fun setTabBarItemIconColor(
view: TabsHost,
value: Int?,
) {
view.tabBarItemIconColor = value
}
override fun setTabBarMinimizeBehavior(
view: TabsHost,
value: String?,
) = Unit
// Android additional
@ReactProp(name = "tabBarItemTitleFontColorActive", customType = "Color")
override fun setTabBarItemTitleFontColorActive(
view: TabsHost,
value: Int?,
) {
view.tabBarItemTitleFontColorActive = value
}
@ReactProp(name = "tabBarItemActiveIndicatorColor", customType = "Color")
override fun setTabBarItemActiveIndicatorColor(
view: TabsHost,
value: Int?,
) {
view.tabBarItemActiveIndicatorColor = value
}
@ReactProp(name = "tabBarItemActiveIndicatorEnabled")
override fun setTabBarItemActiveIndicatorEnabled(
view: TabsHost,
value: Boolean,
) {
view.isTabBarItemActiveIndicatorEnabled = value
}
@ReactProp(name = "tabBarItemIconColorActive", customType = "Color")
override fun setTabBarItemIconColorActive(
view: TabsHost,
value: Int?,
) {
view.tabBarItemIconColorActive = value
}
@ReactProp(name = "tabBarItemTitleFontSizeActive")
override fun setTabBarItemTitleFontSizeActive(
view: TabsHost?,
value: Float,
) {
view?.tabBarItemTitleFontSizeActive = value
}
@ReactProp(name = "tabBarItemRippleColor", customType = "Color")
override fun setTabBarItemRippleColor(
view: TabsHost,
value: Int?,
) {
view.tabBarItemRippleColor = value
}
@ReactProp(name = "tabBarItemLabelVisibilityMode")
override fun setTabBarItemLabelVisibilityMode(
view: TabsHost,
value: String?,
) {
view.tabBarItemLabelVisibilityMode = value
}
companion object {
const val REACT_CLASS = "RNSBottomTabs"
}
}
@@ -0,0 +1,30 @@
package com.swmansion.rnscreens.gamma.tabs.event
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
class TabScreenDidAppearEvent(
surfaceId: Int,
viewId: Int,
) : Event<TabScreenDidAppearEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object : NamingAwareEventType {
const val EVENT_NAME = "topDidAppear"
const val EVENT_REGISTRATION_NAME = "onDidAppear"
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
@@ -0,0 +1,30 @@
package com.swmansion.rnscreens.gamma.tabs.event
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
class TabScreenDidDisappearEvent(
surfaceId: Int,
viewId: Int,
) : Event<TabScreenDidDisappearEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object : NamingAwareEventType {
const val EVENT_NAME = "topDidDisappear"
const val EVENT_REGISTRATION_NAME = "onDidDisappear"
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
@@ -0,0 +1,30 @@
package com.swmansion.rnscreens.gamma.tabs.event
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
class TabScreenWillAppearEvent(
surfaceId: Int,
viewId: Int,
) : Event<TabScreenWillAppearEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object : NamingAwareEventType {
const val EVENT_NAME = "topWillAppear"
const val EVENT_REGISTRATION_NAME = "onWillAppear"
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
@@ -0,0 +1,30 @@
package com.swmansion.rnscreens.gamma.tabs.event
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
class TabScreenWillDisappearEvent(
surfaceId: Int,
viewId: Int,
) : Event<TabScreenWillDisappearEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? = Arguments.createMap()
companion object : NamingAwareEventType {
const val EVENT_NAME = "topWillDisappear"
const val EVENT_REGISTRATION_NAME = "onWillDisappear"
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
@@ -0,0 +1,36 @@
package com.swmansion.rnscreens.gamma.tabs.event
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
class TabsHostNativeFocusChangeEvent(
surfaceId: Int,
viewId: Int,
val tabKey: String,
) : Event<TabScreenDidAppearEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
// All events for a given view can be coalesced.
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putString(EVENT_KEY_TAB_KEY, tabKey)
}
companion object : NamingAwareEventType {
const val EVENT_NAME = "topNativeFocusChange"
const val EVENT_REGISTRATION_NAME = "onNativeFocusChange"
private const val EVENT_KEY_TAB_KEY = "tabKey"
override fun getEventName() = EVENT_NAME
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
@@ -0,0 +1,18 @@
package com.swmansion.rnscreens.stack.anim
import android.view.animation.Animation
import android.view.animation.Transformation
import com.swmansion.rnscreens.ScreenFragment
internal class ScreensAnimation(
private val mFragment: ScreenFragment,
) : Animation() {
override fun applyTransformation(
interpolatedTime: Float,
t: Transformation,
) {
super.applyTransformation(interpolatedTime, t)
// interpolated time should be the progress of the current transition
mFragment.dispatchTransitionProgressEvent(interpolatedTime, !mFragment.isResumed)
}
}

Some files were not shown because too many files have changed in this diff Show More