first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

21
node_modules/react-native-screens/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018 Software Mansion <swmansion.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

258
node_modules/react-native-screens/README.md generated vendored Normal file
View File

@@ -0,0 +1,258 @@
<img src="https://user-images.githubusercontent.com/16062886/117443651-c13d9500-af38-11eb-888d-b6a0b580760c.png" width="100%" alt="React Native Screens by Software Mansion" >
This project aims to expose native navigation container components to React Native. It is not designed to be used as a standalone library but rather as a dependency of a [full-featured navigation library](https://github.com/react-navigation/react-navigation).
## Fabric
To learn about how to use `react-native-screens` with Fabric architecture, head over to [Fabric README](README-Fabric.md). Instructions on how to run Fabric Example within this repo can be found in the [FabricExample README](FabricExample/README.md).
## Supported platforms
- [x] iOS
- [x] Android
- [x] tvOS
- [x] visionOS
- [x] Windows
- [x] Web
## Installation
### iOS
Installation on iOS is completely handled with auto-linking, if you have ensured pods are installed after adding this module, no other actions are necessary.
### Android
On Android the View state is not persisted consistently across Activity restarts, which can lead to crashes in those cases. It is recommended to override the native Android method called on Activity restarts in your main Activity, to avoid these crashes.
For most people using an app built from the react-native template, that means editing `MainActivity.java`, likely located in `android/app/src/main/java/<your package name>/MainActivity.java`
You should add this code, which specifically discards any Activity state persisted during the Activity restart process, to avoid inconsistencies that lead to crashes.
Please note that the override code should not be placed inside `MainActivityDelegate`, but rather directly in `MainActivity`.
<details open>
<summary>Java</summary>
```java
import android.os.Bundle;
import com.swmansion.rnscreens.fragment.restoration.RNScreensFragmentFactory;
public class MainActivity extends ReactActivity {
//...code
//react-native-screens override
@Override
protected void onCreate(Bundle savedInstanceState) {
getSupportFragmentManager().setFragmentFactory(new RNScreensFragmentFactory());
super.onCreate(savedInstanceState);
}
public static class MainActivityDelegate extends ReactActivityDelegate {
//...code
}
}
```
</details>
<details>
<summary>Kotlin</summary>
```kotlin
import android.os.Bundle;
import com.swmansion.rnscreens.fragment.restoration.RNScreensFragmentFactory;
class MainActivity: ReactActivity() {
//...code
//react-native-screens override
override fun onCreate(savedInstanceState: Bundle?) {
supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()
super.onCreate(savedInstanceState);
}
}
```
</details>
For people that must handle cases like this, there is [a more detailed discussion of the difficulties in a series of related comments](https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704633).
<details>
<summary>Need to use a custom Kotlin version?</summary>
<br>
Since `v3.6.0` `react-native-screens` has been rewritten with Kotlin. Kotlin version used in this library defaults to `1.4.10`.
If you need to use a different Kotlin version, set `kotlinVersion` ext property in your project's `android/build.gradle` and the library will use this version accordingly:
```
buildscript {
ext {
...
kotlinVersion = "1.4.10"
}
}
```
**Disclaimer**: `react-native-screens` requires Kotlin `1.3.50` or higher.
</details>
### Windows
Installation on Windows should be completely handled with auto-linking when using React Native Windows 0.63+. For earlier versions, you must [manually link](https://microsoft.github.io/react-native-windows/docs/native-modules-using) the native module.
## How can I take advantage of that?
Screens are already integrated with the React Native's most popular navigation library [react-navigation](https://github.com/react-navigation/react-navigation) and [Expo](https://expo.io).
## Supported react-native version
Below we present tables with mapping of the library version to the last supported react-native version. These tables are for the `4.x` line of the library. For compat tables
of `3.x` line please see [readme on the `3.x` branch](https://github.com/software-mansion/react-native-screens/tree/3.x?tab=readme-ov-file#supported-react-native-version).
### Support for Fabric
[Fabric](https://reactnative.dev/architecture/fabric-renderer) is React Native's default rendering system since 0.76.
Here's a table with summary of supported `react-native` versions:
| library version | react-native version |
| --------------- | -------------------- |
| 4.19.0+ | 0.81.0+ |
| 4.14.0+ | 0.79.0+ |
| 4.5.0+ | 0.77.0+ |
| 4.0.0+ | 0.76.0+ |
### Support for Paper
Paper is the legacy rendering system.
Here's a table with summary of supported `react-native` versions with old architecture turned on:
| library version | react-native version |
| --------------- | -------------------- |
| 4.19.0+ | 0.80.0+ |
| 4.14.0+ | 0.79.0+ |
| 4.9.0+ | 0.76.0+ |
| 4.5.0+ | 0.74.0+ |
| 4.0.0+ | 0.72.0+ |
## Usage with [react-navigation](https://github.com/react-navigation/react-navigation)
> [!CAUTION]
> JS API of the native stack has been moved from `react-native-screens/native-stack` to `@react-navigation/native-stack` since version v6. Currently, native stack v5 (imported from `react-native-screens/native-stack`) is deprecated and will be removed in the upcoming **minor** release. `react-native-screens` v4 will support only `@react-navigation/native-stack` v7.
Screens support is built into [react-navigation](https://github.com/react-navigation/react-navigation) starting from version [2.14.0](https://github.com/react-navigation/react-navigation/releases/tag/2.14.0) for all the different navigator types (stack, tab, drawer, etc).
To configure react-navigation to use screens instead of plain RN Views for rendering screen views, simply add this library as a dependency to your project:
```bash
# bare React Native project
yarn add react-native-screens
# if you use Expo managed workflow
npx expo install react-native-screens
```
Just make sure that the version of [react-navigation](https://github.com/react-navigation/react-navigation) you are using is 2.14.0 or higher.
You are all set 🎉 when screens are enabled in your application code react-navigation will automatically use them instead of relying on plain React Native Views.
### Experimental support for `react-freeze`
> You have to use React Native 0.68 or higher, react-navigation 5.x or 6.x and react-native-screens >= v3.9.0
Since `v3.9.0`, `react-native-screens` comes with experimental support for [`react-freeze`](https://github.com/software-mansion-labs/react-freeze). It uses the React `Suspense` mechanism to prevent parts of the React component tree from rendering, while keeping its state untouched.
To benefit from this feature, enable it in your entry file (e.g. `App.js`) with this snippet:
```js
import { enableFreeze } from 'react-native-screens';
enableFreeze(true);
```
Want to know more? Check out [react-freeze README](https://github.com/software-mansion-labs/react-freeze#readme)
Found a bug? File an issue [here](https://github.com/software-mansion/react-native-screens/issues) or directly in [react-freeze repository](https://github.com/software-mansion-labs/react-freeze/issues).
### Disabling `react-native-screens`
If, for whatever reason, you'd like to disable native screens support and use plain React Native Views add the following code in your entry file (e.g. `App.js`):
```js
import { enableScreens } from 'react-native-screens';
enableScreens(false);
```
You can also disable the usage of native screens per navigator with [`detachInactiveScreens`](https://reactnavigation.org/docs/stack-navigator#detachinactivescreens).
### Using `createNativeStackNavigator` with React Navigation
To take advantage of the native stack navigator primitive for React Navigation that leverages `UINavigationController` on iOS and `Fragment` on Android, please refer:
- for React Navigation >= v6 to the [Native Stack Navigator part of React Navigation documentation](https://reactnavigation.org/docs/native-stack-navigator)
## `FullWindowOverlay`
Native `iOS` component for rendering views straight under the `Window`. Based on `RCTPerfMonitor`. You should treat it as a wrapper, providing full-screen, transparent view which receives no props and should ideally render one child `View`, being the root of its view hierarchy. For the example usage, see https://github.com/software-mansion/react-native-screens/blob/main/apps/src/tests/Test1096.tsx
## Interop with [react-native-navigation](https://github.com/wix/react-native-navigation)
React-native-navigation library already uses native containers for rendering navigation scenes so wrapping these scenes with `<ScreenContainer>` or `<Screen>` component does not provide any benefits. Yet if you would like to build a component that uses screens primitives under the hood (for example a view pager component) it is safe to use `<ScreenContainer>` and `<Screen>` components for that as these work out of the box when rendered on react-native-navigation scenes.
## Interop with other libraries
This library should work out of the box with all existing react-native libraries. If you experience problems with interoperability please [report an issue](https://github.com/software-mansion/react-native-screens/issues).
## Guide for navigation library authors
If you are building a navigation library you may want to use `react-native-screens` to have control over which parts of the React component tree are attached to the native view hierarchy.
To do that, `react-native-screens` provides you with the components documented [here](https://github.com/software-mansion/react-native-screens/tree/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md).
## Common problems
### Problems with header on iOS
- [Focused search bar causes new screens to have incorrect header](https://github.com/software-mansion/react-native-screens/issues/996)
- [Scrollable content gets cut off by the header with a search bar](https://github.com/software-mansion/react-native-screens/issues/1120)
- [RefreshControl does not work properly with NativeStackNavigator and largeTitle](https://github.com/software-mansion/react-native-screens/issues/395)
#### Solution
Use `ScrollView` with prop `contentInsetAdjustmentBehavior=“automatic”` as a main container of the screen and set `headerTranslucent: true` in screen options.
### Other problems
| Problem | Solution |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| [SVG component becomes transparent when goBack](https://github.com/software-mansion/react-native-screens/issues/773) | [related PRs](https://github.com/software-mansion/react-native-screens/issues/773#issuecomment-783469792) |
| [Memory leak while moving from one screen to another in the same stack](https://github.com/software-mansion/react-native-screens/issues/843) | [explanation](https://github.com/software-mansion/react-native-screens/issues/843#issuecomment-832034119) |
| [LargeHeader stays small after pop/goBack/swipe gesture on iOS 14+](https://github.com/software-mansion/react-native-screens/issues/649) | [potential fix](https://github.com/software-mansion/react-native-screens/issues/649#issuecomment-712199895) |
| [`onScroll` and `onMomentumScrollEnd` of previous screen triggered in bottom tabs](https://github.com/software-mansion/react-native-screens/issues/1183) | [explanation](https://github.com/software-mansion/react-native-screens/issues/1183#issuecomment-949313111) |
| [SplitView doesn't clear the blur under primary column after switching to color with `opacity: 0`](https://github.com/software-mansion/react-native-screens/issues/3327) | [workarounds](https://github.com/software-mansion/react-native-screens/issues/3327#issuecomment-3432488799) |
## Contributing
There are many ways to contribute to this project. See [CONTRIBUTING](https://github.com/software-mansion/react-native-screens/tree/main/guides/CONTRIBUTING.md) guide for more information. Thank you for your interest in contributing!
## License
React native screens library is licensed under [The MIT License](LICENSE).
## Credits
This project has been build and is maintained thanks to the support from [Shopify](https://shopify.com), [Expo.io](https://expo.io), and [Software Mansion](https://swmansion.com).
[![shopify](https://avatars1.githubusercontent.com/u/8085?v=3&s=100 'Shopify.com')](https://shopify.com)
[![expo](https://avatars2.githubusercontent.com/u/12504344?v=3&s=100 'Expo.io')](https://expo.io)
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=react-native-reanimated-github 'Software Mansion')](https://swmansion.com)
## React Native Screens is created by Software Mansion
Since 2012 [Software Mansion](https://swmansion.com) is a software agency with experience in building web and mobile apps. We are Core React Native Contributors and experts in dealing with all kinds of React Native issues. We can help you build your next dream product [Hire us](https://swmansion.com/contact/projects?utm_source=screens&utm_medium=readme).

75
node_modules/react-native-screens/RNScreens.podspec generated vendored Normal file
View File

@@ -0,0 +1,75 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
gamma_project_enabled = ENV['RNS_GAMMA_ENABLED'] == '1'
new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
debug_logging = ENV['RNS_DEBUG_LOGGING'] == '1'
source_files_exts = new_arch_enabled ? '{h,m,mm,cpp,swift}' : '{h,m,mm,swift}'
source_files = ["ios/**/*.#{source_files_exts}"]
if !new_arch_enabled
source_files.push("cpp/RNScreensTurboModule.cpp", "cpp/RNScreensTurboModule.h")
end
min_supported_ios_version = new_arch_enabled ? "15.1" : "15.1"
min_supported_tvos_version = "15.1"
min_supported_visionos_version = "1.0"
rnscreens_cpp_flags = []
rnscreens_cpp_flags << "-DRNS_DEBUG_LOGGING=1" if debug_logging
rnscreens_cpp_flags << "-DRNS_GAMMA_ENABLED=1" if gamma_project_enabled
rnscreens_config = {
'OTHER_CPLUSPLUSFLAGS' => rnscreens_cpp_flags.join(" ")
}
if gamma_project_enabled
# This setting is required to make Swift code build. However we have
# dependency on `React-RCTImage` pod, which does not set `DEFINES_MODULE`
# and therefore it fails to build. Currently we do patch react-native source
# code to make it work & the fix is already merged, however it'll be most likely released
# with 0.81. We can not expect users to patch the react-native sources, thus
# we can not have Swift code in stable package.
rnscreens_config['DEFINES_MODULE'] = 'YES'
end
Pod::Spec.new do |s|
s.name = "RNScreens"
s.version = package["version"]
s.summary = package["description"]
s.description = <<-DESC
RNScreens - first incomplete navigation solution for your React Native app
DESC
s.homepage = "https://github.com/software-mansion/react-native-screens"
s.license = "MIT"
s.author = { "author" => "author@domain.cn" }
s.platforms = { :ios => min_supported_ios_version, :tvos => min_supported_tvos_version, :visionos => min_supported_visionos_version }
s.source = { :git => "https://github.com/software-mansion/react-native-screens.git", :tag => "#{s.version}" }
s.source_files = source_files
s.project_header_files = "ios/bridging/Swift-Bridging.h"
s.requires_arc = true
if !gamma_project_enabled
s.exclude_files = "ios/gamma/**/*.#{source_files_exts}"
else
s.exclude_files = "ios/stubs/**/*.#{source_files_exts}"
Pod::UI.puts "[RNScreens] Gamma project enabled. Including source files."
end
s.pod_target_xcconfig = rnscreens_config
install_modules_dependencies(s)
if new_arch_enabled
s.subspec "common" do |ss|
ss.source_files = ["common/cpp/**/*.{cpp,h}", "cpp/**/*.{cpp,h}"]
ss.project_header_files = "common/cpp/**/*.h", "cpp/**/*.h" # Don't expose C++ headers publicly to allow importing framework into Swift files
ss.header_dir = "rnscreens"
ss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/common/cpp\"" }
end
end
s.dependency "React-RCTImage"
end

View File

@@ -0,0 +1,54 @@
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)
target_link_libraries(rnscreens
ReactAndroid::reactnative
ReactAndroid::jsi
fbjni::fbjni
android
)
else()
target_link_libraries(rnscreens
ReactAndroid::jsi
android
)
endif()

231
node_modules/react-native-screens/android/build.gradle generated vendored Normal file
View File

@@ -0,0 +1,231 @@
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",
]
}
}
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.13.0'
implementation "androidx.core:core-ktx:1.8.0"
constraints {
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") {
because("on older React Native versions this dependency conflicts with react-native-screens")
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,77 @@
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 cleanupExpiredMountingCoordinators()
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) {
val isScheduled =
screen.post {
screen.startRemovalTransition()
}
if (!isScheduled) {
Log.w("[RNScreens]", "Failed to schedule removal transition start for screen with tag $screenTag")
}
} else {
Log.w("[RNScreens]", "Reference stored in NativeProxy for tag $screenTag no longer points to valid object.")
}
}
}

View File

@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,96 @@
#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(
"cleanupExpiredMountingCoordinators",
NativeProxy::cleanupExpiredMountingCoordinators),
makeNativeMethod("invalidateNative", NativeProxy::invalidateNative)});
}
void NativeProxy::nativeAddMutationsListener(
jni::alias_ref<facebook::react::JFabricUIManager::javaobject>
fabricUIManager) {
auto uiManager =
fabricUIManager->getBinding()->getScheduler()->getUIManager();
if (!screenRemovalListener_) {
screenRemovalListener_ =
std::make_shared<RNSScreenRemovalListener>([this](int tag) {
static const auto method =
javaPart_->getClass()->getMethod<void(jint)>(
"notifyScreenRemoved");
method(javaPart_, tag);
});
}
cleanupExpiredMountingCoordinators();
uiManager->getShadowTreeRegistry().enumerate(
[this](const facebook::react::ShadowTree &shadowTree, bool &stop) {
if (auto coordinator = shadowTree.getMountingCoordinator()) {
addMountingCoordinatorIfNeeded(coordinator);
}
});
}
void NativeProxy::cleanupExpiredMountingCoordinators() {
std::lock_guard<std::mutex> lock(coordinatorsMutex_);
coordinatorsWithMountingOverrides_.erase(
std::remove_if(
coordinatorsWithMountingOverrides_.begin(),
coordinatorsWithMountingOverrides_.end(),
[](const std::weak_ptr<const facebook::react::MountingCoordinator>
&weakPtr) { return weakPtr.expired(); }),
coordinatorsWithMountingOverrides_.end());
}
void NativeProxy::addMountingCoordinatorIfNeeded(
const std::shared_ptr<const facebook::react::MountingCoordinator>
&coordinator) {
std::lock_guard<std::mutex> lock(coordinatorsMutex_);
bool wasRegistered = std::ranges::any_of(
coordinatorsWithMountingOverrides_,
[&coordinator](
const std::weak_ptr<const facebook::react::MountingCoordinator>
&weakPtr) {
auto existing = weakPtr.lock();
return existing && existing.get() == coordinator.get();
});
if (!wasRegistered) {
coordinator->setMountingOverrideDelegate(screenRemovalListener_);
coordinatorsWithMountingOverrides_.push_back(coordinator);
}
}
jni::local_ref<NativeProxy::jhybriddata> NativeProxy::initHybrid(
jni::alias_ref<jhybridobject> jThis) {
return makeCxxInstance(jThis);
}
void NativeProxy::invalidateNative() {
javaPart_ = nullptr;
}
} // namespace rnscreens

View File

@@ -0,0 +1,45 @@
#pragma once
#include <fbjni/fbjni.h>
#include <react/fabric/JFabricUIManager.h>
#include "RNSScreenRemovalListener.h"
#include <mutex>
#include <string>
namespace rnscreens {
using namespace facebook;
using namespace facebook::jni;
class NativeProxy : public jni::HybridClass<NativeProxy> {
public:
std::shared_ptr<RNSScreenRemovalListener> screenRemovalListener_;
std::vector<std::weak_ptr<const facebook::react::MountingCoordinator>>
coordinatorsWithMountingOverrides_;
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_;
std::mutex coordinatorsMutex_;
explicit NativeProxy(jni::alias_ref<NativeProxy::javaobject> jThis);
void nativeAddMutationsListener(
jni::alias_ref<facebook::react::JFabricUIManager::javaobject>
fabricUIManager);
void invalidateNative();
void cleanupExpiredMountingCoordinators();
void addMountingCoordinatorIfNeeded(
const std::shared_ptr<const facebook::react::MountingCoordinator>
&coordinator);
};
} // namespace rnscreens

View File

@@ -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(); });
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,25 @@
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.content.Context
import com.google.android.material.appbar.AppBarLayout
@SuppressLint("ViewConstructor")
class CustomAppBarLayout(
context: Context,
) : AppBarLayout(context) {
/**
* Handles the layout correction from the child Toolbar.
*/
internal fun applyToolbarLayoutCorrection(toolbarPaddingTop: Int) {
applyFrameCorrectionByTopInset(toolbarPaddingTop)
}
private fun applyFrameCorrectionByTopInset(topInset: Int) {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height + topInset, MeasureSpec.EXACTLY),
)
layout(left, top, right, bottom + topInset)
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,203 @@
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) {
// Due to edge-to-edge enforcement starting from Android SDK 35, isTopInsetEnabled prop has been
// removed. Previously, shouldAvoidDisplayCutout, shouldApplyTopInset would directly return the
// value of isTopInsetEnabled. Now, the values of shouldAvoidDisplayCutout, shouldApplyTopInse
// are hard-coded to true (which was the value used previously for isTopInsetEnabled when
// edge-to-edge was enabled: https://github.com/software-mansion/react-native-screens/pull/2464/files#diff-bd1164595b04f44490738b8183f84a625c0e7552a4ae70bfefcdf3bca4d37fc7R34).
private val shouldAvoidDisplayCutout = true
private val shouldApplyTopInset = true
private var shouldApplyLayoutCorrectionForTopInset = false
private var lastInsets = InsetsCompat.NONE
private var isForceShadowStateUpdateOnLayoutRequested = false
private var isLayoutEnqueued = false
init {
// Ensure ActionMenuView is initialized as soon as the Toolbar is created.
//
// Android measures Toolbar height based on the tallest child view.
// During the first measurement:
// 1. The Toolbar is created but not yet added to the action bar via `activity.setSupportActionBar(toolbar)`
// (typically called in `onUpdate` method from `ScreenStackHeaderConfig`).
// 2. At this moment, the title view may exist, but ActionMenuView (which may be taller) hasn't been added yet.
// 3. This causes the initial height calculation to be based on the title view, potentially too small.
// 4. When ActionMenuView is eventually attached, the Toolbar might need to re-layout due to the size change.
//
// By referencing the menu here, we trigger `ensureMenu`, which creates and attaches ActionMenuView early.
// This guarantees that all size-dependent children are present during the first layout pass,
// resulting in correct height determination from the beginning.
menu
}
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 maybeAppBarLayout = parent as? CustomAppBarLayout
maybeAppBarLayout?.let {
if (shouldApplyLayoutCorrectionForTopInset && !it.isInLayout) {
// In `applyToolbarLayoutCorrection`, we call and immediate layout on AppBarLayout
// to update it right away and avoid showing a potentially wrong UI state.
it.applyToolbarLayoutCorrection(paddingTop)
shouldApplyLayoutCorrectionForTopInset = false
}
}
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.
val cutoutInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.displayCutout(), unhandledInsets)
val systemBarInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars(), unhandledInsets)
// 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) systemBarInsets.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,
) {
shouldApplyLayoutCorrectionForTopInset = true
requestForceShadowStateUpdateOnLayout()
setPadding(left, top, right, bottom)
}
private fun requestForceShadowStateUpdateOnLayout() {
isForceShadowStateUpdateOnLayoutRequested = shouldAvoidDisplayCutout
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,7 @@
package com.swmansion.rnscreens
import androidx.fragment.app.Fragment
interface FragmentHolder {
val fragment: Fragment
}

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,11 @@
package com.swmansion.rnscreens
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactPointerEventsView
internal class PointerEventsBoxNoneImpl : ReactPointerEventsView {
// We set pointer events to BOX_NONE, because we don't want the ScreensCoordinatorLayout
// to be target of react gestures and effectively prevent interaction with screens
// underneath the current screen (useful in `modal` & `formSheet` presentation).
override val pointerEvents: PointerEvents = PointerEvents.BOX_NONE
}

View File

@@ -0,0 +1,91 @@
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.stack.host.StackHostViewManager
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager
import com.swmansion.rnscreens.gamma.tabs.TabScreenViewManager
import com.swmansion.rnscreens.gamma.tabs.TabsHostViewManager
import com.swmansion.rnscreens.safearea.SafeAreaViewManager
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(),
SafeAreaViewManager(),
StackHostViewManager(),
StackScreenViewManager(),
)
}
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"
}
}

View File

@@ -0,0 +1,792 @@
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.WindowInsets
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.SheetDetents
import com.swmansion.rnscreens.bottomsheet.fitToContentsSheetHeight
import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents
import com.swmansion.rnscreens.bottomsheet.updateMetrics
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
import com.swmansion.rnscreens.utils.getDecorViewTopInset
import kotlin.math.max
@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 insetsApplied = false
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
var sheetDetents: SheetDetents = SheetDetents(listOf(1.0))
var sheetLargestUndimmedDetentIndex: Int = -1
var sheetInitialDetentIndex: Int = 0
var sheetClosesOnTouchOutside = true
var sheetElevation: Float = 24F
var sheetShouldOverflowTopInset = false
var sheetDefaultResizeAnimationEnabled = true
/**
* On Paper, when using form sheet presentation we want to delay enter transition in order
* to wait for initial layout from React, otherwise the animator-based animation will look
* glitchy.
*
* On Fabric, the view layout is completed before window insets are applied.
* To ensure the BottomSheet correctly respects insets during its enter transition,
* we delay the transition until both layout and insets have been applied.
*/
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
val sheetBehavior = sheetBehavior
if (usesFormSheetPresentation()) {
if (isSheetFitToContents() && sheetBehavior != null) {
val oldHeight = sheetBehavior.fitToContentsSheetHeight()
val isInitial = oldHeight == 0
val heightChanged = oldHeight != height
if (!heightChanged) {
return
}
if (isInitial) {
setupInitialSheetContentHeight(sheetBehavior, height)
} else if (sheetDefaultResizeAnimationEnabled) {
updateSheetContentHeightWithAnimation(sheetBehavior, oldHeight, height)
} else {
updateSheetContentHeightWithoutAnimation(sheetBehavior, 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()
}
}
}
}
/**
* This should be used only with sheet in `fitToContents` mode.
*/
private fun updateSheetContentHeightWithAnimation(
behavior: BottomSheetBehavior<Screen>,
oldHeight: Int,
newHeight: Int,
) {
val currentTranslationY = this.translationY
/*
* WHY OVERFLOW MATTERS:
* BottomSheetBehavior has a physical limit (maxHeight) defined by the parent container.
* If the new content height exceeds this limit (by its size or keyboard offset), simply
* animating translationY back to 'currentTranslationY' would attempt to render the sheet
* larger than the screen.
*
* We need to have constraint height inside the container's bounds.
* By including this overflow to our animation, we ensure the sheet stops
* expanding exactly at the maxHeight, preventing from being pushed
* off-screen or causing layout synchronization issues with the CoordinatorLayout.
*/
val clampedOldHeight = resolveClampedHeight(oldHeight, currentTranslationY)
val clampedNewHeight = resolveClampedHeight(newHeight, currentTranslationY)
val visibleDelta = (clampedNewHeight - clampedOldHeight).toFloat()
if (visibleDelta == 0f) return
val isContentExpanding = visibleDelta > 0
if (isContentExpanding) {
/*
* Expanding content animation:
*
* Before animation, we're updating the SheetBehavior - the maximum height is the new
* content height, then we're forcing a layout pass. This ensures the view calculates
* with its new bounds when the animation starts.
*
* In the animation, we're translating the Screen back to it's (newly calculated) origin
* position, providing an impression that FormSheet expands. It already has the final size,
* but some content is not yet visible on the screen.
*
* After animation, we just need to send a notification that ShadowTree state should be updated,
* as the positioning of pressables has changed due to the Y translation manipulation.
*/
this.translationY += visibleDelta
this
.animate()
.translationY(currentTranslationY)
.withStartAction {
behavior.updateMetrics(clampedNewHeight)
layout(this.left, this.bottom - clampedNewHeight, this.right, this.bottom)
}.withEndAction {
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
onSheetYTranslationChanged()
}.start()
} else {
/*
* Shrinking content animation:
*
* Before the animation, our Screen translationY is 0 - because its actual layout and visual position are equal.
*
* Before the animation, I'm updating sheet metrics to the target value - it won't update until the next layout pass,
* which is controlled by end action. This is done deliberately, to allow catching the case when quick combination
* of shrink & expand animation is detected.
*
* In the animation, we're translating the Screen down by the calculated height delta to the position (which will
* be new absolute 0 for the Screen, after ending the transition), providing an impression that FormSheet shrinks.
* FormSheet's size remains unchanged during the whole animation, therefore there is no view clipping.
*
* After animation, we can update the layout: the maximum FormSheet height is updated and we're forcing
* another layout pass. Additionally, since the actual layout and the target position are equal,
* we can reset translationY to 0.
*
* After animation, we need to send a notification that ShadowTree state should be updated,
* as the FormSheet size has changed and the positioning of pressables has changed due to the Y translation manipulation.
*/
val targetTranslationY = currentTranslationY - visibleDelta
this
.animate()
.translationY(targetTranslationY)
.withStartAction {
behavior.updateMetrics(clampedNewHeight)
}.withEndAction {
layout(this.left, this.bottom - clampedNewHeight, this.right, this.bottom)
this.translationY = currentTranslationY
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
onSheetYTranslationChanged()
}.start()
}
}
private fun updateSheetContentHeightWithoutAnimation(
behavior: BottomSheetBehavior<Screen>,
height: Int,
) {
/*
* We're just updating sheets height and forcing Screen layout to be updated immediately.
* This allows custom animators in RN to work, as we do not interfere with these animations
* and we're just reacting to the sheet's content size changes.
*/
val clampedHeight = resolveClampedHeight(height, this.translationY)
behavior.updateMetrics(clampedHeight)
layout(this.left, this.bottom - clampedHeight, this.right, this.bottom)
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
updateScreenSizeFabric(width, clampedHeight, top + translationY.toInt())
}
}
private fun setupInitialSheetContentHeight(
behavior: BottomSheetBehavior<Screen>,
height: Int,
) {
behavior.useSingleDetent(height)
// During the initial call in `onCreateView`, insets are not yet available,
// so we need to request an additional layout pass later to account for them.
requestLayout()
}
private fun resolveClampedHeight(
targetHeight: Int,
currentTranslationY: Float,
): Int {
val maxAvailableVerticalSpace =
this.fragment
?.asScreenStackFragment()
?.sheetDelegate
?.tryResolveMaxFormSheetHeight() ?: return targetHeight
// Please note that currentTranslationY is rather < 0 here.
// The translation is included in constraining the available space, because the FormSheet can have some offset, e.g. to
// avoid the keyboard.
return targetHeight.coerceAtMost((maxAvailableVerticalSpace + currentTranslationY).toInt())
}
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
if (!insetsApplied && headerConfig?.isHeaderHidden == false && headerConfig?.isHeaderTranslucent == false) {
val topLevelDecorView =
requireNotNull(
reactContext.currentActivity?.window?.decorView,
) { "[RNScreens] DecorView is required for applying inset correction, but was null." }
val topInset = getDecorViewTopInset(topLevelDecorView)
val correctedHeight = height - topInset
val correctedOffsetY = t + topInset
dispatchShadowStateUpdate(width, correctedHeight, correctedOffsetY)
} else {
dispatchShadowStateUpdate(width, height, t)
}
}
}
internal fun onBottomSheetBehaviorDidLayout(coordinatorLayoutDidChange: Boolean) {
if (!usesFormSheetPresentation() || !isNativeStackScreen) {
return
}
if (isSheetFitToContents()) {
// The maxHeight may have changed due to incoming top inset.
// Force a layout pass to sync BottomSheetBehavior's internal offsets with the new value.
requestLayout()
}
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()
}
}
// On Fabric, the view layout is completed before window insets are applied.
// To ensure the BottomSheet correctly respects insets during its enter transition,
// we delay the transition until both layout and insets have been applied.
internal fun requestTriggeringPostponedEnterTransition() {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED && !sheetShouldOverflowTopInset) {
shouldTriggerPostponedTransitionAfterLayout = true
}
}
internal 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()) }
}
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)
}
internal fun notifyHeaderHeightChange(headerHeight: Int) {
val screenContext = context as ReactContext
val surfaceId = UIManagerHelper.getSurfaceId(screenContext)
UIManagerHelper
.getEventDispatcherForReactTag(screenContext, id)
?.dispatchEvent(
HeaderHeightChangeEvent(
surfaceId,
id,
PixelUtil.toDIPFromPixel(headerHeight.toFloat()).toDouble(),
),
)
}
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) {
onSheetYTranslationChanged()
}
}
internal fun onSheetYTranslationChanged() {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// Translation is relative to the bottom edge, therefore it returns negative values.
updateScreenSizeFabric(width, height, top + translationY.toInt())
}
}
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
insetsApplied = true
return super.onApplyWindowInsets(insets)
}
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 = max(PixelUtil.toDIPFromPixel(sheetCornerRadius), 0f)
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,
STYLE,
HIDDEN,
ANIMATED,
NAVIGATION_BAR_HIDDEN,
}
companion object {
const val TAG = "Screen"
}
}
internal fun View.asScreen() = this as Screen

View File

@@ -0,0 +1,448 @@
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 notifyScreenDetached(screen: Screen) {
if (context is ReactContext) {
val surfaceId = UIManagerHelper.getSurfaceId(context)
UIManagerHelper
.getEventDispatcherForReactTag(context as ReactContext, screen.id)
?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id))
}
}
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) {
fragmentWrapper.screen.setTransitioning(transitioning)
val activityState = getActivityState(fragmentWrapper)
if (activityState == ActivityState.INACTIVE) {
continue
}
if (fragmentWrapper.fragment.isAdded) {
if (addedBefore) {
detachScreen(it, fragmentWrapper.fragment)
pendingFront.add(fragmentWrapper)
}
} else {
if (addedBefore) {
pendingFront.add(fragmentWrapper)
} else {
addedBefore = true
attachScreen(it, fragmentWrapper.fragment)
}
}
}
for (screenFragment in pendingFront) {
attachScreen(it, screenFragment.fragment)
}
it.commitNowAllowingStateLoss()
}
}
protected open fun notifyContainerUpdate() {
topScreen?.fragmentWrapper?.onContainerUpdate()
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}
}
}

View File

@@ -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?
}

View File

@@ -0,0 +1,282 @@
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.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.maxAllowedHeight(containerHeight)
}
2 ->
behavior.apply {
state =
screen.sheetDetents.sheetStateFromIndex(
screen.sheetInitialDetentIndex,
)
skipCollapsed = false
isFitToContents = true
peekHeight = screen.sheetDetents.peekHeight(containerHeight)
maxHeight = screen.sheetDetents.maxAllowedHeight(containerHeight)
}
3 ->
behavior.apply {
state =
screen.sheetDetents.sheetStateFromIndex(
screen.sheetInitialDetentIndex,
)
skipCollapsed = false
isFitToContents = false
peekHeight = screen.sheetDetents.peekHeight(containerHeight)
expandedOffset = screen.sheetDetents.expandedOffsetFromTop(containerHeight)
halfExpandedRatio = screen.sheetDetents.halfExpandedRatio()
}
else -> throw IllegalStateException("[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.")
}
}
companion object {
const val TAG = "ScreenModalFragment"
}
}

View File

@@ -0,0 +1,411 @@
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)
}
// When there is more then one active screen on stack,
// pops the screen, so that only one remains
// Returns true when any screen was popped
// When there was only one screen on stack returns false
fun popToRoot(): Boolean {
val rootIndex = screenWrappers.indexOfFirst { it.screen.activityState != Screen.ActivityState.INACTIVE }
val lastActiveIndex = screenWrappers.indexOfLast { it.screen.activityState != Screen.ActivityState.INACTIVE }
if (rootIndex >= 0 && lastActiveIndex > rootIndex) {
for (screenIndex in (rootIndex + 1)..lastActiveIndex) {
notifyScreenDetached(screenWrappers[screenIndex].screen)
}
return true
}
return false
}
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
}
}
}

View File

@@ -0,0 +1,563 @@
package com.swmansion.rnscreens
import android.animation.Animator
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build
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.BottomSheetTransitionCoordinator
import com.swmansion.rnscreens.bottomsheet.BottomSheetWindowInsetListenerChain
import com.swmansion.rnscreens.bottomsheet.DimmingViewManager
import com.swmansion.rnscreens.bottomsheet.SheetDelegate
import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
import com.swmansion.rnscreens.events.ScreenDismissedEvent
import com.swmansion.rnscreens.ext.recycle
import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
import com.swmansion.rnscreens.utils.DeviceUtils
import com.swmansion.rnscreens.utils.resolveBackgroundColor
import kotlin.math.max
sealed class KeyboardState
object KeyboardNotVisible : KeyboardState()
object KeyboardDidHide : KeyboardState()
class KeyboardVisible(
val height: Int,
) : KeyboardState()
class ScreenStackFragment :
ScreenFragment,
ScreenStackFragmentWrapper {
private var appBarLayout: CustomAppBarLayout? = null
private var toolbar: Toolbar? = null
private var isToolbarShadowHidden = false
private var isToolbarTranslucent = false
private lateinit var sheetTransitionCoordinator: BottomSheetTransitionCoordinator
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
internal var bottomSheetWindowInsetListenerChain: BottomSheetWindowInsetListenerChain? = null
private var lastInsetsCompat: WindowInsetsCompat? = 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 { CustomAppBarLayout(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!!)
if (!screen.sheetShouldOverflowTopInset) {
sheetTransitionCoordinator = BottomSheetTransitionCoordinator()
attachInsetsAndLayoutListenersToBottomSheet(
sheetTransitionCoordinator,
)
}
// 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)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val bottomSheetWindowInsetListenerChain = requireBottomSheetWindowInsetsListenerChain()
bottomSheetWindowInsetListenerChain.addListener { _, windowInsets ->
sheetDelegate.handleKeyboardInsetsProgress(windowInsets)
windowInsets
}
ViewCompat.setOnApplyWindowInsetsListener(screen, bottomSheetWindowInsetListenerChain)
}
val insetsAnimationCallback =
object : WindowInsetsAnimationCompat.Callback(
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
) {
// Replace InsetsAnimationCallback created by BottomSheetBehavior
// to avoid interfering with custom animations.
// See: https://github.com/software-mansion/react-native-screens/pull/2909
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
): WindowInsetsCompat {
// On API 30+, we handle keyboard inset animation progress here.
// On lower APIs, we rely on ViewCompat.setOnApplyWindowInsetsListener instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
sheetDelegate.handleKeyboardInsetsProgress(insets)
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
screen.onSheetYTranslationChanged()
}
}
ViewCompat.setWindowInsetsAnimationCallback(screen, insetsAnimationCallback)
}
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
}
return if (enter) createSheetEnterAnimator() else createSheetExitAnimator()
}
private fun createSheetEnterAnimator(): Animator {
val sheetDelegate = requireSheetDelegate()
val dimmingDelegate = requireDimmingDelegate()
val sheetAnimationContext =
SheetDelegate.SheetAnimationContext(
this,
this.screen,
this.coordinatorLayout,
dimmingDelegate,
)
return sheetDelegate.createSheetEnterAnimator(sheetAnimationContext)
}
private fun createSheetExitAnimator(): Animator {
val sheetDelegate = requireSheetDelegate()
val dimmingDelegate = requireDimmingDelegate()
val sheetAnimationContext =
SheetDelegate.SheetAnimationContext(
this,
this.screen,
this.coordinatorLayout,
dimmingDelegate,
)
return sheetDelegate.createSheetExitAnimator(sheetAnimationContext)
}
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 = max(PixelUtil.toPixelFromDIP(screen.sheetCornerRadius), 0f)
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)
}
// Mark: Avoiding top inset by BottomSheet
private fun attachInsetsAndLayoutListenersToBottomSheet(sheetTransitionCoordinator: BottomSheetTransitionCoordinator) {
screen.container?.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { _, insets ->
val insetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
handleInsetsUpdateAndNotifyTransition(
insetsCompat,
)
insets
}
} else {
val bottomSheetWindowInsetListenerChain = requireBottomSheetWindowInsetsListenerChain()
bottomSheetWindowInsetListenerChain.addListener { _, windowInsets ->
handleInsetsUpdateAndNotifyTransition(
windowInsets,
)
windowInsets
}
}
}
screen.container?.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
sheetTransitionCoordinator.onScreenContainerLayoutChanged(screen)
}
}
private fun handleInsetsUpdateAndNotifyTransition(insetsCompat: WindowInsetsCompat) {
if (lastInsetsCompat == insetsCompat) {
return
}
lastInsetsCompat = insetsCompat
// Reconfigure BottomSheetBehavior with the same state and updated maxHeight.
// When insets are available, we can factor them in to update the maximum height accordingly.
val sheetDelegate = requireSheetDelegate()
sheetDelegate.updateBottomSheetMetrics(screen.sheetBehavior!!)
screen.container?.let { container ->
// Needs to be highlighted that nothing changes at the container level.
// However, calling additional measure will trigger BottomSheetBehavior's `onMeasureChild` logic.
// This method ensures that the bottom sheet respects the maxHeight we update in `configureBottomSheetBehavior`.
coordinatorLayout.forceLayout()
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)
}
// Although the layout of the screen container and CoordinatorLayout hasn't changed,
// the BottomSheetBehavior has updated the maximum height.
// We manually trigger the callback to notify that the bottom sheet layout has been applied.
screen.onBottomSheetBehaviorDidLayout(true)
sheetTransitionCoordinator.onScreenContainerInsetsApplied(screen)
}
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!!
}
internal fun requireBottomSheetWindowInsetsListenerChain(): BottomSheetWindowInsetListenerChain {
if (bottomSheetWindowInsetListenerChain == null) {
bottomSheetWindowInsetListenerChain = BottomSheetWindowInsetListenerChain()
}
return bottomSheetWindowInsetListenerChain!!
}
}

View File

@@ -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()
}

View File

@@ -0,0 +1,477 @@
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
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
}
val headerHeightUpdateProxy = ScreenStackHeaderHeightUpdateProxy()
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
headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen)
// 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()
}
headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen)
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++
}
headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen)
}
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 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 {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
(context.applicationContext as ReactApplication)
.reactHost
?.devSupportManager
?.showDevOptionsDialog()
} else {
(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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,350 @@
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.bridge.ReadableArray
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)
}
@ReactProp(name = "topInsetEnabled")
override fun setTopInsetEnabled(
config: ScreenStackHeaderConfig,
topInsetEnabled: Boolean,
) {
logNotAvailable("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)
}
// synchronousShadowStateUpdatesEnabled is not available on Android atm,
// however we must override their setters
override fun setSynchronousShadowStateUpdatesEnabled(
config: ScreenStackHeaderConfig?,
value: Boolean,
) = Unit
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")
}
override fun setHeaderLeftBarButtonItems(
view: ScreenStackHeaderConfig?,
value: ReadableArray?,
) {
logNotAvailable("headerLeftBarButtonItems")
}
override fun setHeaderRightBarButtonItems(
view: ScreenStackHeaderConfig?,
value: ReadableArray?,
) {
logNotAvailable("headerRightBarButtonItems")
}
override fun setUserInterfaceStyle(
view: ScreenStackHeaderConfig?,
value: String?,
) {
logNotAvailable("userInterfaceStyle")
}
}

View File

@@ -0,0 +1,17 @@
package com.swmansion.rnscreens
class ScreenStackHeaderHeightUpdateProxy {
var previousHeaderHeightInPx: Int? = null
fun updateHeaderHeightIfNeeded(
config: ScreenStackHeaderConfig,
screen: Screen?,
) {
val currentHeaderHeightInPx = if (config.isHeaderHidden) 0 else config.toolbar.height
if (currentHeaderHeightInPx != previousHeaderHeightInPx) {
previousHeaderHeightInPx = currentHeaderHeightInPx
screen?.notifyHeaderHeightChange(currentHeaderHeightInPx)
}
}
}

View File

@@ -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.LEFT
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,
}
}

View File

@@ -0,0 +1,76 @@
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.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")
}
}
@ReactProp(name = "hidesSharedBackground")
override fun setHidesSharedBackground(
view: ScreenStackHeaderSubview,
hidesSharedBackground: Boolean,
) {
Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android")
}
// synchronousShadowStateUpdatesEnabled is not available on Android atm,
// however we must override their setters
override fun setSynchronousShadowStateUpdatesEnabled(
view: ScreenStackHeaderSubview?,
value: Boolean,
) = Unit
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"
}
}

View File

@@ -0,0 +1,86 @@
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"),
)
// iosPreventReattachmentOfDismissedScreens is not available on Android,
// however we must override their setters
override fun setIosPreventReattachmentOfDismissedScreens(
view: ScreenStack?,
value: Boolean,
) = Unit
companion object {
const val REACT_CLASS = "RNSScreenStack"
}
}

View File

@@ -0,0 +1,448 @@
package com.swmansion.rnscreens
import android.util.Log
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.bottomsheet.SheetDetents
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()
}
private fun logNotAvailable(propName: String) {
Log.w("[RNScreens]", "$propName prop is not available on Android")
}
@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
}
@ReactProp(name = "statusBarColor", customType = "Color")
override fun setStatusBarColor(
view: Screen,
statusBarColor: Int?,
) {
logNotAvailable("statusBarColor")
}
@ReactProp(name = "statusBarStyle")
override fun setStatusBarStyle(
view: Screen,
statusBarStyle: String?,
) {
view.statusBarStyle = statusBarStyle
}
@ReactProp(name = "statusBarTranslucent")
override fun setStatusBarTranslucent(
view: Screen,
statusBarTranslucent: Boolean,
) {
logNotAvailable("statusBarTranslucent")
}
@ReactProp(name = "statusBarHidden")
override fun setStatusBarHidden(
view: Screen,
statusBarHidden: Boolean,
) {
view.isStatusBarHidden = statusBarHidden
}
@ReactProp(name = "navigationBarColor", customType = "Color")
override fun setNavigationBarColor(
view: Screen,
navigationBarColor: Int?,
) {
logNotAvailable("navigationBarColor")
}
@ReactProp(name = "navigationBarTranslucent")
override fun setNavigationBarTranslucent(
view: Screen,
navigationBarTranslucent: Boolean,
) {
logNotAvailable("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()
}
@ReactProp(name = "sheetShouldOverflowTopInset")
override fun setSheetShouldOverflowTopInset(
view: Screen?,
sheetShouldOverflowTopInset: Boolean,
) {
view?.sheetShouldOverflowTopInset = sheetShouldOverflowTopInset
}
@ReactProp(name = "sheetDefaultResizeAnimationEnabled")
override fun setSheetDefaultResizeAnimationEnabled(
view: Screen?,
sheetDefaultResizeAnimationEnabled: Boolean,
) {
view?.sheetDefaultResizeAnimationEnabled = sheetDefaultResizeAnimationEnabled
}
// mark: iOS-only
// these props are not available on Android, however we must override their setters
override fun setFullScreenSwipeEnabled(
view: Screen?,
value: String?,
) = 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
override fun setBottomScrollEdgeEffect(
view: Screen?,
value: String?,
) = Unit
override fun setLeftScrollEdgeEffect(
view: Screen?,
value: String?,
) = Unit
override fun setRightScrollEdgeEffect(
view: Screen?,
value: String?,
) = Unit
override fun setTopScrollEdgeEffect(
view: Screen?,
value: String?,
) = Unit
override fun setSynchronousShadowStateUpdatesEnabled(
view: Screen?,
value: Boolean,
) = Unit
// END mark: iOS-only
override fun setAndroidResetScreenShadowStateOnOrientationChangeEnabled(
view: Screen?,
value: Boolean,
) = Unit // represents a feature flag and is checked via getProps() in RNSScreenComponentDescriptor.h
@ReactProp(name = "sheetAllowedDetents")
override fun setSheetAllowedDetents(
view: Screen,
value: ReadableArray?,
) {
val parsedDetents =
if (value != null && value.size() > 0) {
List(value.size()) { index -> value.getDouble(index) }
} else {
listOf(1.0)
}
view.sheetDetents = SheetDetents(parsedDetents)
}
@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"
}
}

View File

@@ -0,0 +1,231 @@
package com.swmansion.rnscreens
import android.app.Activity
import android.content.pm.ActivityInfo
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.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.swmansion.rnscreens.Screen.WindowTraits
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 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
}
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"
}
}
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())
}
}
}
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) {
setStyle(screen, activity, context)
setHidden(screen, activity)
}
if (didSetNavigationBarAppearance) {
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.STYLE -> screen.statusBarStyle != null
WindowTraits.HIDDEN -> screen.isStatusBarHidden != null
WindowTraits.ANIMATED -> screen.isStatusBarAnimated != null
WindowTraits.NAVIGATION_BAR_HIDDEN -> screen.isNavigationBarHidden != null
}
}

View File

@@ -0,0 +1,169 @@
package com.swmansion.rnscreens
import android.util.Log
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.bridge.LifecycleEventListener
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),
LifecycleEventListener {
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()
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
proxy?.invalidateNative()
proxy = null
reactContext.removeLifecycleEventListener(this)
}
nativeUninstall()
}
override fun initialize() {
super.initialize()
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
proxy = NativeProxy()
reactContext.addLifecycleEventListener(this)
setupFabric()
}
}
private fun setupFabric() {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
val fabricUIManager =
UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC) as FabricUIManager
proxy?.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
}
// LifecycleEventListener
override fun onHostResume() {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
setupFabric()
}
}
override fun onHostPause() = Unit
override fun onHostDestroy() {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
proxy?.apply {
cleanupExpiredMountingCoordinators()
}
}
}
companion object {
const val NAME = "RNSModule"
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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, "systemDefault", "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")
override fun setAutoFocus(
view: SearchBarView,
autoFocus: Boolean,
) {
view.autoFocus = autoFocus
}
@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?.handleCancelSearchJsRequest()
}
// 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: String?,
) {
logNotAvailable("obscureBackground")
}
override fun setHideNavigationBar(
view: SearchBarView?,
value: String?,
) {
logNotAvailable("hideNavigationBar")
}
override fun setCancelButtonText(
view: SearchBarView?,
value: String?,
) {
logNotAvailable("cancelButtonText")
}
override fun setTintColor(
view: SearchBarView?,
value: Int?,
) {
logNotAvailable("tintColor")
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,68 @@
package com.swmansion.rnscreens.bottomsheet
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
internal fun <T : View> BottomSheetBehavior<T>.updateMetrics(
maxAllowedHeight: Int? = null,
expandedOffsetFromTop: Int? = null,
): BottomSheetBehavior<T> {
maxAllowedHeight?.let {
this.maxHeight = maxAllowedHeight
}
expandedOffsetFromTop?.let {
this.expandedOffset = expandedOffsetFromTop
}
return this
}
internal fun <T : View> BottomSheetBehavior<T>.useSingleDetent(
maxAllowedHeight: Int? = null,
forceExpandedState: Boolean = true,
): BottomSheetBehavior<T> {
this.skipCollapsed = true
this.isFitToContents = true
if (forceExpandedState) {
this.state = BottomSheetBehavior.STATE_EXPANDED
}
maxAllowedHeight?.let {
this.maxHeight = maxAllowedHeight
}
return this
}
internal fun <T : View> BottomSheetBehavior<T>.useTwoDetents(
@BottomSheetBehavior.StableState state: Int? = null,
firstHeight: Int? = null,
maxAllowedHeight: Int? = null,
): BottomSheetBehavior<T> {
this.skipCollapsed = false
this.isFitToContents = true
state?.let { this.state = state }
firstHeight?.let { this.peekHeight = firstHeight }
maxAllowedHeight?.let { this.maxHeight = maxAllowedHeight }
return this
}
internal fun <T : View> BottomSheetBehavior<T>.useThreeDetents(
@BottomSheetBehavior.StableState state: Int? = null,
firstHeight: Int? = null,
maxAllowedHeight: Int? = null,
halfExpandedRatio: Float? = null,
expandedOffsetFromTop: Int? = null,
): BottomSheetBehavior<T> {
this.skipCollapsed = false
this.isFitToContents = false
state?.let { this.state = state }
firstHeight?.let { this.peekHeight = firstHeight }
halfExpandedRatio?.let { this.halfExpandedRatio = halfExpandedRatio }
expandedOffsetFromTop?.let { this.expandedOffset = expandedOffsetFromTop }
maxAllowedHeight?.let { this.maxHeight = maxAllowedHeight }
return this
}
internal fun <T : View> BottomSheetBehavior<T>.fitToContentsSheetHeight(): Int {
// In fitToContents only a single detent is allowed, and the actual
// sheet height is stored in this field.
return this.maxHeight
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,25 @@
package com.swmansion.rnscreens.bottomsheet
import com.swmansion.rnscreens.Screen
class BottomSheetTransitionCoordinator {
private var isLayoutComplete = false
private var areInsetsApplied = false
internal fun onScreenContainerLayoutChanged(screen: Screen) {
isLayoutComplete = true
triggerSheetEnterTransitionIfReady(screen)
}
internal fun onScreenContainerInsetsApplied(screen: Screen) {
areInsetsApplied = true
triggerSheetEnterTransitionIfReady(screen)
}
private fun triggerSheetEnterTransitionIfReady(screen: Screen) {
if (!isLayoutComplete || !areInsetsApplied) return
screen.requestTriggeringPostponedEnterTransition()
screen.triggerPostponedEnterTransitionIfNeeded()
}
}

View File

@@ -0,0 +1,36 @@
package com.swmansion.rnscreens.bottomsheet
import android.view.View
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
/**
* Aggregates and sequentially invokes many instances of OnApplyWindowInsetsListener
*
* In Android, only a single ViewCompat.setOnApplyWindowInsetsListener can be set on a view,
* which leads to listeners overwriting each other. This class solves the listener conflict
* by allowing different components to a common chain. As we do not consume or modify insets, the
* order is not important and the chain may work.
*
* In our case it's crucial, because we need to react on insets for both:
* - avoiding bottom/top insets by BottomSheet
* - keyboard handling
*/
class BottomSheetWindowInsetListenerChain : OnApplyWindowInsetsListener {
private val listeners = mutableListOf<OnApplyWindowInsetsListener>()
fun addListener(listener: OnApplyWindowInsetsListener) {
listeners.add(listener)
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat,
): WindowInsetsCompat {
for (listener in listeners) {
listener.onApplyWindowInsets(v, insets)
}
return insets
}
}

View File

@@ -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"
}
}

View File

@@ -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.count) {
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!!
}
}

View File

@@ -0,0 +1,18 @@
package com.swmansion.rnscreens.bottomsheet
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactPointerEventsView
internal class DimmingViewPointerEventsImpl(
val dimmingView: DimmingView,
) : ReactPointerEventsView {
override val pointerEvents: PointerEvents
get() = if (dimmingView.blockGestures) PointerEvents.AUTO else PointerEvents.NONE
}
internal class DimmingViewPointerEventsProxy(
var pointerEventsImpl: DimmingViewPointerEventsImpl?,
) : ReactPointerEventsView {
override val pointerEvents: PointerEvents
get() = pointerEventsImpl?.pointerEvents ?: PointerEvents.NONE
}

View File

@@ -0,0 +1,636 @@
package com.swmansion.rnscreens.bottomsheet
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.coordinatorlayout.widget.CoordinatorLayout
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
import com.swmansion.rnscreens.events.ScreenAnimationDelegate
import com.swmansion.rnscreens.events.ScreenEventEmitter
import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
import com.swmansion.rnscreens.utils.isSoftKeyboardVisibleOrNull
class SheetDelegate(
val screen: Screen,
) : LifecycleEventObserver,
OnApplyWindowInsetsListener {
private var isKeyboardVisible: Boolean = false
private var keyboardState: KeyboardState = KeyboardNotVisible
private var isSheetAnimationInProgress: Boolean = false
private var lastTopInset: Int = 0
private var lastKeyboardBottomOffset: Int = 0
var lastStableDetentIndex: Int = screen.sheetInitialDetentIndex
private set
@BottomSheetBehavior.State
var lastStableState: Int =
screen.sheetDetents.sheetStateFromIndex(
screen.sheetInitialDetentIndex,
)
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
private var viewToRestoreFocus: View? = null
private val inputMethodManager: InputMethodManager?
get() = screen.reactContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
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_CREATE -> handleHostFragmentOnCreate()
Lifecycle.Event.ON_START -> handleHostFragmentOnStart()
Lifecycle.Event.ON_RESUME -> handleHostFragmentOnResume()
Lifecycle.Event.ON_PAUSE -> handleHostFragmentOnPause()
Lifecycle.Event.ON_DESTROY -> handleHostFragmentOnDestroy()
else -> Unit
}
}
private fun handleHostFragmentOnCreate() {
preserveBackgroundFocus()
}
private fun handleHostFragmentOnStart() {
InsetsObserverProxy.registerOnView(requireDecorView())
}
private fun handleHostFragmentOnResume() {
InsetsObserverProxy.addOnApplyWindowInsetsListener(this)
}
private fun handleHostFragmentOnPause() {
InsetsObserverProxy.removeOnApplyWindowInsetsListener(this)
}
private fun handleHostFragmentOnDestroy() {
restoreBackgroundFocus()
}
private fun onSheetStateChanged(newState: Int) {
val isStable = SheetUtils.isStateStable(newState)
if (isStable) {
lastStableState = newState
lastStableDetentIndex =
screen.sheetDetents.indexFromSheetState(
newState,
)
}
screen.onSheetDetentChanged(lastStableDetentIndex, isStable)
if (shouldDismissSheetInState(newState)) {
stackFragment.dismissSelf()
}
}
private fun preserveBackgroundFocus() {
val activity = screen.reactContext.currentActivity ?: return
activity.currentFocus?.let { focusedView ->
activity.window?.decorView?.let { decorView ->
if (isSoftKeyboardVisibleOrNull(decorView) == true) {
viewToRestoreFocus = focusedView
}
}
// Note: There's no good reason that Screen should be direct target for focus, we're rather
// prefer its children to gain it.
screen.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS
screen.requestFocus()
inputMethodManager?.hideSoftInputFromWindow(focusedView.windowToken, 0)
}
}
private fun restoreBackgroundFocus() {
viewToRestoreFocus?.let { view ->
view.requestFocus()
inputMethodManager?.showSoftInput(view, 0)
}
viewToRestoreFocus = null
}
internal fun updateBottomSheetMetrics(behavior: BottomSheetBehavior<Screen>) {
val containerHeight = tryResolveMaxFormSheetHeight()
check(containerHeight != null) {
"[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
}
val maxAllowedHeight =
when (screen.isSheetFitToContents()) {
true ->
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()
}
}
false -> (screen.sheetDetents.highest() * containerHeight).toInt()
}
// For 3 detents, we need to add the top inset back here because we are calculating the offset
// from the absolute top of the view, but our calculated max height (containerHeight)
// has been reduced by this inset.
val expandedOffsetFromTop =
when (screen.sheetDetents.count) {
3 -> screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset)
else -> null
}
behavior.updateMetrics(maxAllowedHeight, expandedOffsetFromTop)
}
internal fun configureBottomSheetBehaviour(
behavior: BottomSheetBehavior<Screen>,
keyboardState: KeyboardState = KeyboardNotVisible,
selectedDetentIndex: Int = lastStableDetentIndex,
): BottomSheetBehavior<Screen> {
val containerHeight = tryResolveMaxFormSheetHeight()
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.sheetDetents.maxAllowedHeightForFitToContents(screen)
} else {
screen.sheetDetents.maxAllowedHeight(containerHeight)
}
useSingleDetent(maxAllowedHeight = height)
}
2 ->
behavior.useTwoDetents(
state =
screen.sheetDetents.sheetStateFromIndex(
selectedDetentIndex,
),
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
)
3 ->
behavior.useThreeDetents(
state =
screen.sheetDetents.sheetStateFromIndex(
selectedDetentIndex,
),
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(),
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset),
)
else -> throw IllegalStateException(
"[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.",
)
}
}
is KeyboardVisible -> {
val isOnScreenKeyboardVisible = keyboardState.height != 0
when (screen.sheetDetents.count) {
1 ->
behavior.apply {
addBottomSheetCallback(keyboardHandlerCallback)
}
2 ->
behavior.apply {
if (isOnScreenKeyboardVisible) {
useTwoDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
} else {
useTwoDetents()
}
addBottomSheetCallback(keyboardHandlerCallback)
}
3 ->
behavior.apply {
if (isOnScreenKeyboardVisible) {
useThreeDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
} else {
useThreeDetents()
}
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.apply {
val height =
if (screen.isSheetFitToContents()) {
screen.sheetDetents.maxAllowedHeightForFitToContents(screen)
} else {
screen.sheetDetents.maxAllowedHeight(containerHeight)
}
useSingleDetent(maxAllowedHeight = height, forceExpandedState = false)
}
2 ->
behavior.useTwoDetents(
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
)
3 ->
behavior.useThreeDetents(
firstHeight = screen.sheetDetents.firstHeight(containerHeight),
halfExpandedRatio = screen.sheetDetents.halfExpandedRatio(),
maxAllowedHeight = screen.sheetDetents.maxAllowedHeight(containerHeight),
expandedOffsetFromTop = screen.sheetDetents.expandedOffsetFromTop(containerHeight, lastTopInset),
)
else -> throw IllegalStateException(
"[RNScreens] Invalid detent count ${screen.sheetDetents.count}. Expected at most 3.",
)
}
}
}
}
// This function calculates the Y offset to which the FormSheet should animate
// when appearing (entering) or disappearing (exiting) with the on-screen keyboard (IME) present.
// Its purpose is to ensure the FormSheet does not exceed the top edge of the screen.
// It tries to display the FormSheet fully above the keyboard when there's enough space.
// Otherwise, it shifts the sheet as high as possible, even if it means part of its content
// will remain hidden behind the keyboard.
internal fun computeSheetOffsetYWithIMEPresent(keyboardHeight: Int): Int {
val containerHeight = tryResolveMaxFormSheetHeight()
check(containerHeight != null) {
"[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
}
if (screen.isSheetFitToContents()) {
val contentHeight = screen.contentWrapper?.height ?: 0
val offsetFromTop = maxOf(containerHeight - contentHeight, 0)
// If the content is higher than the Screen, offsetFromTop becomes negative.
// In such cases, we return 0 because a negative translation would shift the Screen
// to the bottom, which is not intended.
return minOf(offsetFromTop, keyboardHeight)
}
val detents = screen.sheetDetents
val detentValue = detents.highest().coerceIn(0.0, 1.0)
val sheetHeight = (detentValue * containerHeight).toInt()
val offsetFromTop = containerHeight - sheetHeight
return minOf(offsetFromTop, keyboardHeight)
}
// 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())
val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val displayCutoutInsets = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
// We save the top inset (status bar height or display cutout) to later
// subtract it from the window height during sheet size calculations.
// This ensures the sheet respects the safe area.
lastTopInset = maxOf(systemBarsInsets.top, displayCutoutInsets.top)
if (isImeVisible) {
isKeyboardVisible = true
keyboardState = KeyboardVisible(imeInset.bottom)
sheetBehavior?.let {
this.configureBottomSheetBehaviour(it, keyboardState)
}
} else {
sheetBehavior?.let {
if (isKeyboardVisible) {
this.configureBottomSheetBehaviour(it, KeyboardDidHide)
} else if (keyboardState != KeyboardNotVisible) {
this.configureBottomSheetBehaviour(it, KeyboardNotVisible)
}
}
keyboardState = KeyboardNotVisible
isKeyboardVisible = false
}
val newBottomInset = if (!isImeVisible) systemBarsInsets.bottom else 0
// Note: We do not manipulate the top inset manually. Therefore, if SafeAreaView has top insets enabled,
// we must retain the top inset even if the formSheet does not currently overflow into the status bar.
// This is important because in some specific edge cases - for example, when the keyboard slides in -
// the formSheet might overlap the status bar. If we ignored the top inset and it suddenly became necessary,
// it would result in a noticeable visual content jump. To ensure consistency and avoid layout shifts,
// we always include the top inset upfront, which can be disabled from the application perspective.
return WindowInsetsCompat
.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.systemBars(),
Insets.of(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, newBottomInset),
).build()
}
private fun shouldDismissSheetInState(
@BottomSheetBehavior.State state: Int,
) = state == BottomSheetBehavior.STATE_HIDDEN
internal fun tryResolveMaxFormSheetHeight(): Int? =
if (screen.sheetShouldOverflowTopInset) {
tryResolveContainerHeight()
} else {
tryResolveSafeAreaSpaceForSheet()
}
/**
* This method tries to resolve the maximum height available for the sheet content,
* accounting for the system top inset.
*/
private fun tryResolveSafeAreaSpaceForSheet(): Int? = tryResolveContainerHeight()?.let { it - lastTopInset }
/**
* 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
}
// Sheet entering/exiting animations
internal fun createSheetEnterAnimator(sheetAnimationContext: SheetAnimationContext): Animator {
val animatorSet = AnimatorSet()
val dimmingDelegate = sheetAnimationContext.dimmingDelegate
val screenStackFragment = sheetAnimationContext.fragment
val alphaAnimator = createDimmingViewAlphaAnimator(0f, dimmingDelegate.maxAlpha, dimmingDelegate)
val slideAnimator = createSheetSlideInAnimator()
animatorSet
.play(slideAnimator)
.takeIf {
dimmingDelegate.willDimForDetentIndex(screen, screen.sheetInitialDetentIndex)
}?.with(alphaAnimator)
attachCommonListeners(animatorSet, isEnter = true, screenStackFragment)
return animatorSet
}
internal fun createSheetExitAnimator(sheetAnimationContext: SheetAnimationContext): Animator {
val animatorSet = AnimatorSet()
val coordinatorLayout = sheetAnimationContext.coordinatorLayout
val dimmingDelegate = sheetAnimationContext.dimmingDelegate
val screenStackFragment = sheetAnimationContext.fragment
val alphaAnimator =
createDimmingViewAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f, dimmingDelegate)
val slideAnimator = createSheetSlideOutAnimator(coordinatorLayout)
animatorSet.play(alphaAnimator).with(slideAnimator)
attachCommonListeners(animatorSet, isEnter = false, screenStackFragment)
return animatorSet
}
private fun createDimmingViewAlphaAnimator(
from: Float,
to: Float,
dimmingDelegate: DimmingViewManager,
): ValueAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { animator ->
(animator.animatedValue as? Float)?.let {
dimmingDelegate.dimmingView.alpha = it
}
}
}
private fun createSheetSlideInAnimator(): ValueAnimator {
val startValueCallback = { _: Number? -> screen.height.toFloat() }
val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })
return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
addUpdateListener { updateSheetTranslationY(it.animatedValue as Float) }
}
}
private fun createSheetSlideOutAnimator(coordinatorLayout: CoordinatorLayout): ValueAnimator {
val endValue = (coordinatorLayout.bottom - screen.top - screen.translationY)
return ValueAnimator.ofFloat(0f, endValue).apply {
addUpdateListener {
updateSheetTranslationY(it.animatedValue as Float)
}
}
}
private fun updateSheetTranslationY(baseTranslationY: Float) {
val keyboardCorrection = lastKeyboardBottomOffset
val bottomOffset = computeSheetOffsetYWithIMEPresent(keyboardCorrection).toFloat()
screen.translationY = baseTranslationY - bottomOffset
}
internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) {
lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
// Prioritize enter/exit animations over direct keyboard inset reactions.
// We store the latest keyboard offset in `lastKeyboardBottomOffset`
// so that it can always be respected when applying translations in `updateSheetTranslationY`.
//
// This approach allows screen translation to be triggered from two sources, but without messing them together:
// - During enter/exit animations, while accounting for the keyboard height.
// - While interacting with a TextInput inside the bottom sheet, to handle keyboard show/hide events.
if (!isSheetAnimationInProgress) {
updateSheetTranslationY(0f)
}
}
private fun attachCommonListeners(
animatorSet: AnimatorSet,
isEnter: Boolean,
screenStackFragment: ScreenStackFragment,
) {
animatorSet.addListener(
ScreenAnimationDelegate(
screenStackFragment,
ScreenEventEmitter(screen),
if (isEnter) {
ScreenAnimationDelegate.AnimationType.ENTER
} else {
ScreenAnimationDelegate.AnimationType.EXIT
},
),
)
animatorSet.addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
isSheetAnimationInProgress = true
}
override fun onAnimationEnd(animation: Animator) {
isSheetAnimationInProgress = false
screen.onSheetYTranslationChanged()
}
},
)
}
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()
inputMethodManager?.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
}
internal data class SheetAnimationContext(
val fragment: ScreenStackFragment,
val screen: Screen,
val coordinatorLayout: CoordinatorLayout,
val dimmingDelegate: DimmingViewManager,
)
companion object {
const val TAG = "SheetDelegate"
}
}

View File

@@ -0,0 +1,93 @@
package com.swmansion.rnscreens.bottomsheet
import com.swmansion.rnscreens.Screen
class SheetDetents(
rawDetents: List<Double>,
) {
private val rawDetents: List<Double> = rawDetents.toList()
init {
require(rawDetents.isNotEmpty()) { "[RNScreens] At least one detent must be provided." }
require(rawDetents.size <= 3) { "[RNScreens] Maximum of 3 detents supported." }
if (rawDetents.size == 1) {
rawDetents[0].let {
require(it in 0.0..1.0 || it == SHEET_FIT_TO_CONTENTS) {
"[RNScreens] Detent value must be within 0.0 and 1.0, or SHEET_FIT_TO_CONTENTS should be defined, got $it."
}
}
} else {
rawDetents.forEach {
require(it in 0.0..1.0) {
"[RNScreens] Detent values must be within 0.0 and 1.0, got $it."
}
}
require(rawDetents == rawDetents.sorted()) {
"[RNScreens] Detents must be sorted in ascending order."
}
}
}
internal val count: Int get() = rawDetents.size
internal fun at(index: Int): Double = rawDetents[index]
internal fun shortest(): Double = rawDetents.first()
internal fun highest(): Double = rawDetents.last()
internal fun heightAt(
index: Int,
containerHeight: Int,
): Int {
val detent = at(index)
require(detent != SHEET_FIT_TO_CONTENTS) {
"[RNScreens] FIT_TO_CONTENTS is not supported by heightAt."
}
return (detent * containerHeight).toInt()
}
internal fun firstHeight(containerHeight: Int): Int = heightAt(0, containerHeight)
internal fun maxAllowedHeight(containerHeight: Int): Int = heightAt(count - 1, containerHeight)
internal fun maxAllowedHeightForFitToContents(screen: Screen): Int =
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()
}
} ?: 0
internal fun halfExpandedRatio(): Float {
if (count < 3) throw IllegalStateException("[RNScreens] At least 3 detents required for halfExpandedRatio.")
return (at(1) / at(2)).toFloat()
}
internal fun expandedOffsetFromTop(
containerHeight: Int,
topInset: Int = 0,
): Int {
if (count < 3) throw IllegalStateException("[RNScreens] At least 3 detents required for expandedOffsetFromTop.")
return ((1 - at(2)) * containerHeight).toInt() + topInset
}
internal fun peekHeight(containerHeight: Int): Int = heightAt(0, containerHeight)
internal fun sheetStateFromIndex(index: Int): Int = SheetUtils.sheetStateFromDetentIndex(index, count)
internal fun indexFromSheetState(state: Int): Int = SheetUtils.detentIndexFromSheetState(state, count)
companion object {
/**
* This value describes value in sheet detents array that will be treated as `fitToContents` option.
*/
const val SHEET_FIT_TO_CONTENTS = -1.0
}
}

View File

@@ -0,0 +1,168 @@
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.shortest() == SheetDetents.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).
//
// On Fabric, system insets are applied after the initial layout pass. However,
// the BottomSheet height might be measured earlier due to internal BottomSheet logic
// or layout callbacks, before those insets are applied.
// To ensure the BottomSheet height respects the top inset we delay starting the enter
// transition until both layout and insets are fully applied.
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED && !this.sheetShouldOverflowTopInset && this.usesFormSheetPresentation()) {
return true
}
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

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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 headerHeightInDp: Double,
) : 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 = headerHeightInDp.toInt().toShort()
override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putDouble("headerHeight", headerHeightInDp)
}
companion object {
const val EVENT_NAME = "topHeaderHeightChange"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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),
)
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,48 @@
package com.swmansion.rnscreens.ext
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.findFragment
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
internal fun View.findFragmentOrNull(): Fragment? =
try {
this.findFragment()
} catch (_: IllegalStateException) {
null
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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?
}

View File

@@ -0,0 +1,22 @@
package com.swmansion.rnscreens.gamma.common.event
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"
}
}

View File

@@ -0,0 +1,13 @@
package com.swmansion.rnscreens.gamma.common.event
internal interface NamingAwareEventType {
/**
* React event name with `top` prefix
*/
fun getEventName(): String
/**
* Name of the event as expected in Element Tree.
*/
fun getEventRegistrationName(): String
}

View File

@@ -0,0 +1,11 @@
package com.swmansion.rnscreens.gamma.common.event
internal interface ViewAppearanceEventEmitter {
fun emitOnWillAppear()
fun emitOnDidAppear()
fun emitOnWillDisappear()
fun emitOnDidDisappear()
}

View File

@@ -0,0 +1,6 @@
package com.swmansion.rnscreens.gamma.helpers
import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType
internal fun makeEventRegistrationInfo(event: NamingAwareEventType): Pair<String, HashMap<String, String>> =
event.getEventName() to hashMapOf("registrationName" to event.getEventRegistrationName())

View File

@@ -0,0 +1,79 @@
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 androidx.fragment.app.FragmentTransaction
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
}
}
}
}
internal fun FragmentManager.createTransactionWithReordering(): FragmentTransaction = this.beginTransaction().setReorderingAllowed(true)

View File

@@ -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
}

View File

@@ -0,0 +1,41 @@
package com.swmansion.rnscreens.gamma.helpers
import android.view.View
import android.view.ViewGroup
import android.widget.ScrollView
import androidx.core.view.isNotEmpty
import com.swmansion.rnscreens.ScreenStack
object ViewFinder {
fun findScrollViewInFirstDescendantChain(view: View): ScrollView? {
var currentView: View? = view
while (currentView != null) {
if (currentView is ScrollView) {
return currentView
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
currentView = currentView.getChildAt(0)
} else {
break
}
}
return null
}
fun findScreenStackInFirstDescendantChain(view: View): ScreenStack? {
var currentView: View? = view
while (currentView != null) {
if (currentView is ScreenStack) {
return currentView
} else if (currentView is ViewGroup && currentView.isNotEmpty()) {
currentView = currentView.getChildAt(0)
} else {
break
}
}
return null
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,69 @@
package com.swmansion.rnscreens.gamma.stack.host
import androidx.fragment.app.FragmentManager
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenFragment
internal sealed class FragmentOperation {
internal abstract fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
)
}
internal class AddOp(
val fragment: StackScreenFragment,
val containerViewId: Int,
val addToBackStack: Boolean,
val allowStateLoss: Boolean = true,
) : FragmentOperation() {
override fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
) {
executor.executeAddOp(fragmentManager, this)
}
}
internal class PopBackStackOp(
val fragment: StackScreenFragment,
) : FragmentOperation() {
override fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
) {
executor.executePopBackStackOp(fragmentManager, this)
}
}
internal class RemoveOp(
val fragment: StackScreenFragment,
val allowStateLoss: Boolean = true,
val flushSync: Boolean = false,
) : FragmentOperation() {
override fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
) {
executor.executeRemoveOp(fragmentManager, this)
}
}
internal class FlushNowOp : FragmentOperation() {
override fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
) {
executor.executeFlushOp(fragmentManager, this)
}
}
internal class SetPrimaryNavFragmentOp(
val fragment: StackScreenFragment,
) : FragmentOperation() {
override fun execute(
fragmentManager: FragmentManager,
executor: FragmentOperationExecutor,
) {
executor.executeSetPrimaryNavFragmentOp(fragmentManager, this)
}
}

View File

@@ -0,0 +1,103 @@
package com.swmansion.rnscreens.gamma.stack.host
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import com.swmansion.rnscreens.gamma.helpers.createTransactionWithReordering
internal class FragmentOperationExecutor {
internal fun executeOperations(
fragmentManager: FragmentManager,
ops: List<FragmentOperation>,
flushSync: Boolean = false,
) {
ops.forEach { it.execute(fragmentManager, this) }
if (flushSync) {
fragmentManager.executePendingTransactions()
}
}
internal fun executeAddOp(
fragmentManager: FragmentManager,
op: AddOp,
) {
fragmentManager.createTransactionWithReordering().let { tx ->
tx.add(op.containerViewId, op.fragment)
if (op.addToBackStack) {
tx.addToBackStack(op.fragment.stackScreen.screenKey)
}
commitTransaction(tx, op.allowStateLoss)
}
}
internal fun executePopBackStackOp(
fragmentManager: FragmentManager,
op: PopBackStackOp,
) {
fragmentManager.popBackStack(
op.fragment.stackScreen.screenKey,
FragmentManager.POP_BACK_STACK_INCLUSIVE,
)
}
internal fun executeRemoveOp(
fragmentManager: FragmentManager,
op: RemoveOp,
) {
fragmentManager.createTransactionWithReordering().let { tx ->
tx.remove(op.fragment)
commitTransaction(tx, op.allowStateLoss, op.flushSync)
}
}
internal fun executeFlushOp(
fragmentManager: FragmentManager,
op: FlushNowOp,
) {
fragmentManager.executePendingTransactions()
}
internal fun executeSetPrimaryNavFragmentOp(
fragmentManager: FragmentManager,
op: SetPrimaryNavFragmentOp,
) {
fragmentManager.createTransactionWithReordering().let { tx ->
tx.setPrimaryNavigationFragment(op.fragment)
commitTransaction(tx, allowStateLoss = true, flushSync = false)
}
}
private fun commitTransaction(
tx: FragmentTransaction,
allowStateLoss: Boolean,
flushSync: Boolean = false,
) {
if (flushSync) {
commitSync(tx, allowStateLoss)
} else {
commitAsync(tx, allowStateLoss)
}
}
private fun commitAsync(
tx: FragmentTransaction,
allowStateLoss: Boolean,
) {
if (allowStateLoss) {
tx.commitAllowingStateLoss()
} else {
tx.commit()
}
}
private fun commitSync(
tx: FragmentTransaction,
allowStateLoss: Boolean,
) {
if (allowStateLoss) {
tx.commitNowAllowingStateLoss()
} else {
tx.commitNow()
}
}
}

View File

@@ -0,0 +1,198 @@
package com.swmansion.rnscreens.gamma.stack.host
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenFragment
import com.swmansion.rnscreens.utils.RNSLog
import java.lang.ref.WeakReference
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
internal class StackContainer(
context: Context,
private val delegate: WeakReference<StackContainerDelegate>,
) : CoordinatorLayout(context),
FragmentManager.OnBackStackChangedListener {
private var fragmentManager: FragmentManager? = null
private fun requireFragmentManager(): FragmentManager =
checkNotNull(fragmentManager) { "[RNScreens] Attempt to use nullish FragmentManager" }
/**
* Describes most up-to-date view of the stack. It might be different from
* state kept by FragmentManager as this data structure is updated immediately,
* while operations on fragment manager are scheduled.
*/
private val stackModel: MutableList<StackScreenFragment> = arrayListOf()
private val pendingPopOperations: MutableList<PopOperation> = arrayListOf()
private val pendingPushOperations: MutableList<PushOperation> = arrayListOf()
private val hasPendingOperations: Boolean
get() = pendingPushOperations.isNotEmpty() || pendingPopOperations.isNotEmpty()
private val fragmentOpExecutor: FragmentOperationExecutor = FragmentOperationExecutor()
init {
id = ViewIdGenerator.generateViewId()
}
override fun onAttachedToWindow() {
RNSLog.d(TAG, "StackContainer [$id] attached to window")
super.onAttachedToWindow()
setupFragmentManger()
// We run container update to handle any pending updates requested before container was
// attached to window.
performContainerUpdateIfNeeded()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
requireFragmentManager().removeOnBackStackChangedListener(this)
fragmentManager = null
}
internal fun setupFragmentManger() {
fragmentManager =
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
"[RNScreens] Nullish fragment manager - can't run container operations"
}.also {
it.addOnBackStackChangedListener(this)
}
}
/**
* Call this function to trigger container update
*/
internal fun performContainerUpdateIfNeeded() {
// If container update is requested before container is attached to window, we ignore
// the call because we don't have valid fragmentManager yet.
// Update will be eventually executed in onAttachedToWindow().
if (hasPendingOperations && isAttachedToWindow) {
performOperations(requireFragmentManager())
}
}
internal fun enqueuePushOperation(stackScreen: StackScreen) {
pendingPushOperations.add(PushOperation(stackScreen))
}
internal fun enqueuePopOperation(stackScreen: StackScreen) {
pendingPopOperations.add(PopOperation(stackScreen))
}
private fun performOperations(fragmentManager: FragmentManager) {
val fragmentOps = applyOperationsAndComputeFragmentManagerOperations()
fragmentOpExecutor.executeOperations(fragmentManager, fragmentOps, flushSync = false)
dumpStackModel()
}
private fun applyOperationsAndComputeFragmentManagerOperations(): List<FragmentOperation> {
val fragmentOps = mutableListOf<FragmentOperation>()
// Handle pop operations first.
// We don't care about pop/push duplicates, as long as we don't let the main loop progress
// before we commit all the transactions, FragmentManager will handle that for us.
pendingPopOperations.forEach { operation ->
val fragment =
checkNotNull(stackModel.find { it.stackScreen === operation.screen }) {
"[RNScreens] Unable to find a fragment to pop"
}
check(stackModel.size > 1) {
"[RNScreens] Attempt to pop last screen from the stack"
}
fragmentOps.add(PopBackStackOp(fragment))
check(stackModel.removeAt(stackModel.lastIndex) === fragment) {
"[RNScreens] Attempt to pop non-top screen"
}
}
pendingPushOperations.forEach { operation ->
val newFragment = createFragmentForScreen(operation.screen)
fragmentOps.add(
AddOp(
newFragment,
containerViewId = this.id,
addToBackStack = stackModel.isNotEmpty(),
),
)
stackModel.add(newFragment)
}
check(stackModel.isNotEmpty()) { "[RNScreens] Stack should never be empty after updates" }
// Top fragment is the primary navigation fragment.
fragmentOps.add(SetPrimaryNavFragmentOp(stackModel.last()))
pendingPopOperations.clear()
pendingPushOperations.clear()
return fragmentOps
}
private fun onNativeFragmentPop(fragment: StackScreenFragment) {
Log.d(TAG, "StackContainer [$id] natively removed fragment ${fragment.stackScreen.screenKey}")
require(stackModel.remove(fragment)) { "[RNScreens] onNativeFragmentPop must be called with the fragment present in stack model" }
check(stackModel.isNotEmpty()) { "[RNScreens] Stack model should not be empty after a native pop" }
val fragmentManager = requireFragmentManager()
if (fragmentManager.primaryNavigationFragment === fragment) {
// We need to update the primary navigation fragment, otherwise the fragment manager
// will have invalid state, pointing to the dismissed fragment.
fragmentOpExecutor.executeOperations(
fragmentManager,
listOf(SetPrimaryNavFragmentOp(stackModel.last())),
)
}
}
private fun dumpStackModel() {
Log.d(TAG, "StackContainer [$id] MODEL BEGIN")
stackModel.forEach {
Log.d(TAG, "${it.stackScreen.screenKey}")
}
}
private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment =
StackScreenFragment(screen).also {
Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}")
}
// This is called after special effects (animations) are dispatched
override fun onBackStackChanged() = Unit
// This is called before the special effects (animations) are dispatched, however mid transaction!
// Therefore make sure to not execute any action that might cause synchronous transaction synchronously
// from this callback.
override fun onBackStackChangeCommitted(
fragment: Fragment,
pop: Boolean,
) {
if (fragment !is StackScreenFragment) {
Log.w(TAG, "[RNScreens] Unexpected type of fragment: ${fragment.javaClass.simpleName}")
return
}
if (pop) {
delegate.get()?.onScreenDismiss(fragment.stackScreen)
if (stackModel.contains(fragment)) {
onNativeFragmentPop(fragment)
}
}
}
companion object {
const val TAG = "StackContainer"
}
}

View File

@@ -0,0 +1,7 @@
package com.swmansion.rnscreens.gamma.stack.host
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
internal interface StackContainerDelegate {
fun onScreenDismiss(stackScreen: StackScreen)
}

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