From 40a54227c3a7131c5556774e94ce7b031bcc7ed3 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Fri, 9 Aug 2024 09:03:51 +0200 Subject: [PATCH 01/11] fix(Android,Fabric): add missing `DoNotStrip` annotation to JNI-accessed methods (#2290) ## Description Due to missing `DoNotStrip` annotations proguard could treat the class & methods as unused (as reported in #2286) and remove them completely. Possibly fixes #2286. ## Changes Annotate `ScreenDummyLayoutHelper` class & all method accessed via JNI. ## Test code and steps to reproduce Haven't triggerred the error localy, relying on report in #2286 ## Checklist - [ ] Ensured that CI passes --- .../com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt index 7714ef7d30..d5214d832b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt @@ -5,6 +5,7 @@ import android.util.Log import android.view.View import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.facebook.jni.annotations.DoNotStrip import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.PixelUtil @@ -18,6 +19,7 @@ import java.lang.ref.WeakReference * See https://github.com/software-mansion/react-native-screens/pull/2169 * for more detailed description of the issue this code solves. */ +@DoNotStrip internal class ScreenDummyLayoutHelper( reactContext: ReactApplicationContext, ) : LifecycleEventListener { @@ -129,6 +131,7 @@ internal class ScreenDummyLayoutHelper( * @param fontSize font size value as passed from JS * @return header height in dp as consumed by Yoga */ + @DoNotStrip private fun computeDummyLayout( fontSize: Int, isTitleEmpty: Boolean, @@ -210,6 +213,7 @@ internal class ScreenDummyLayoutHelper( // dummy view hierarchy. private var weakInstance = WeakReference(null) + @DoNotStrip @JvmStatic fun getInstance(): ScreenDummyLayoutHelper? = weakInstance.get() } From 4c22ef1adb23478d0118a17ed55967a4cd010a60 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Fri, 9 Aug 2024 09:06:38 +0200 Subject: [PATCH 02/11] fix(Android,Fabric,bridgeless): crash on RN hot reload in dev mode when redbox in presentation (#2289) ## Description There was a crash on Android + Fabric + bridgeless: 1. trigger a redbox by triggering some JS error (e.g. call a method that does not exist), 2. fast refresh react-native, 3. see the crash in `onHostResume`. The application crashed because we had a check for not null activity in reactContext. However after such reload ReactContext is recreated with null activity!!!. In such case, when we don't have access to the activity, we're currently unable to create the dummy layout, so the best we can do is not fail. The fix for jumping layout won't work, however. This PR also adds synchornization on `synchornied` block in `maybeInitDummyLayoutWithHeaderMethod`, which should prevent a possible data race, where both threads could try to create dummy layout simmultaneously. ## Changes * Do not fail in `onHostResume` when there is no activity present in the provided context, * only log a warning in the described case, * synchronize layout initialisation, so that there is no data race <- I'm not sure it was real due to JVM atomicity guarantees, however this time I prefer the "better safe than sorry" approach, * mark `isLayoutInitialized` variable as `Volatile`, to ensure that updates from one thread are visible on the other thread. ## Test code and steps to reproduce Desribed above ^ in the "Description" section. ## Checklist - [ ] Ensured that CI passes --- .../utils/ScreenDummyLayoutHelper.kt | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt index d5214d832b..81a73cf767 100644 --- a/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt @@ -1,6 +1,7 @@ package com.swmansion.rnscreens.utils import android.app.Activity +import android.content.Context import android.util.Log import android.view.View import androidx.appcompat.widget.Toolbar @@ -48,7 +49,7 @@ internal class ScreenDummyLayoutHelper( try { System.loadLibrary(LIBRARY_NAME) } catch (e: UnsatisfiedLinkError) { - Log.w(TAG, "Failed to load $LIBRARY_NAME") + Log.w(TAG, "[RNScreens] Failed to load $LIBRARY_NAME library.") } weakInstance = WeakReference(this) @@ -59,9 +60,13 @@ internal class ScreenDummyLayoutHelper( } /** - * Initializes dummy view hierarchy with CoordinatorLayout, AppBarLayout and dummy View. + * Tries to initialize dummy view hierarchy with CoordinatorLayout, AppBarLayout and dummy View. * We utilize this to compute header height (app bar layout height) from C++ layer when its needed. * + * This method might fail in case there is activity attached to the react context. + * + * This method is called from various threads! + * * @return boolean whether the layout was initialised or not */ private fun maybeInitDummyLayoutWithHeader(reactContext: ReactApplicationContext): Boolean { @@ -69,6 +74,7 @@ internal class ScreenDummyLayoutHelper( return true } + // Possible data race here - activity is injected into context on UI thread. if (!reactContext.hasCurrentActivity()) { return false } @@ -76,8 +82,25 @@ internal class ScreenDummyLayoutHelper( // We need to use activity here, as react context does not have theme attributes required by // AppBarLayout attached leading to crash. val contextWithTheme = - requireNotNull(reactContext.currentActivity) { "[RNScreens] Attempt to use context detached from activity" } + requireNotNull(reactContext.currentActivity) { "[RNScreens] Attempt to use context detached from activity. This could happen only due to race-condition." } + + synchronized(this) { + // The layout could have been initialised when this thread waited for access to critical section. + if (isLayoutInitialized) { + return true + } + initDummyLayoutWithHeader(contextWithTheme) + } + return true + } + /** + * Initialises the dummy layout. This method is **not** thread-safe. + * + * @param contextWithTheme this function expects the context to have theme attributes required + * to initialize the AppBarLayout. + */ + private fun initDummyLayoutWithHeader(contextWithTheme: Context) { coordinatorLayout = CoordinatorLayout(contextWithTheme) appBarLayout = @@ -121,7 +144,6 @@ internal class ScreenDummyLayoutHelper( } isLayoutInitialized = true - return true } /** @@ -144,7 +166,7 @@ internal class ScreenDummyLayoutHelper( // is still null at this execution point. We don't wanna crash in such case, thus returning zeroed height. Log.e( TAG, - "[RNScreens] Failed to late-init layout while computing header height. This is a race-condition-bug in react-native-screens, please file an issue at https://github.com/software-mansion/react-native-screens/issues" + "[RNScreens] Failed to late-init layout while computing header height. This is most likely a race-condition-bug in react-native-screens, please file an issue at https://github.com/software-mansion/react-native-screens/issues" ) return 0.0f } @@ -218,18 +240,29 @@ internal class ScreenDummyLayoutHelper( fun getInstance(): ScreenDummyLayoutHelper? = weakInstance.get() } + // This value is fetched / stored from UI and background thread. Volatile here ensures + // that updates are visible to the other thread. + @Volatile private var isLayoutInitialized = false override fun onHostResume() { // This is the earliest we have guarantee that the context has a reference to an activity. val reactContext = requireReactContext { "[RNScreens] ReactContext missing in onHostResume! This should not happen." } - check(maybeInitDummyLayoutWithHeader(reactContext)) { "[RNScreens] Failed to initialise dummy layout in onHostResume. This is not expected."} - reactContext.removeLifecycleEventListener(this) + + // There are some exotic edge cases where activity might not be present in context + // at this point, e.g. when reloading RN in development after an error was reported with redbox. + if (maybeInitDummyLayoutWithHeader(reactContext)) { + reactContext.removeLifecycleEventListener(this) + } else { + Log.w(TAG, "[RNScreens] Failed to initialise dummy layout in onHostResume.") + } } override fun onHostPause() = Unit - override fun onHostDestroy() = Unit + override fun onHostDestroy() { + reactContextRef.get()?.removeLifecycleEventListener(this) + } } private data class CacheKey( From 6cd118504095fe4c3b8722009672ec895dfe65c5 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Sat, 10 Aug 2024 20:52:54 +0200 Subject: [PATCH 03/11] Release 3.35.0-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d80d5224a1..5fa577c22c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-screens", - "version": "3.34.0", + "version": "3.35.0-rc.1", "description": "Native navigation primitives for your React Native app.", "scripts": { "submodules": "git submodule update --init --recursive && (cd react-navigation && yarn)", From 85a4196ec0194c7b647fe86c16841bb52e4eb665 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Sat, 10 Aug 2024 21:02:25 +0200 Subject: [PATCH 04/11] feat!: iOS custom detents & Android form sheets (#2045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR introduces series of features & changes: 1. possibility of specifying custom detents for form sheets on devices with iOS 16 or newer, 2. changes existing form sheet API of `Screen` component (namely types of values accepted), 3. Android form sheets (bottom sheets presented in current presentation context (in iOS terms) with dimming view with configurable interaction. The form sheet supports up to three detent levels with additional option of `fitToContents` 4. Android Footer component that works together with `formSheet` presentation style 5. 🚧 Android modal bottom sheet - similar to `formSheet`, however the sheet is mounted under separate window. 6. 🚧 iOS Footer component - similar to Android 7. Usage of Material 3 8. series of new props allowing for: a. controlling style of the `Screen` component (necessary workaround for issue with flickering on iOS, b. controlling whether the screen fragment of particular screen should be unmounted or not on Android when the screen is on JS stack but not visible (necessary to achieve "staying form sheet" when navigating back to a screen with presented form sheet), c. listening for `sheetDetentChange` events, in case of Android stable & dragging states are reported, in case of iOS only stable states d. todo: describe rest ## Changes ## Known issues 1. [x] ~After recent commits - iOS compilation on Fabric~ 2. [ ] Android: issue with nested scrollview - invalid behaviour when there is not enough content for scrollview to scroll (viewport is >= content size). Solvable by patching react-native: https://github.com/facebook/react-native/pull/44099, no other workaround found. There is one approach [suggested by grahammendick](https://github.com/grahammendick/navigation/blob/916688d267bd3fc520e2e22328b6aa66124f52ed/NavigationReactNative/src/android/src/main/java/com/navigation/reactnative/CoordinatorLayoutView.java#L96-L148), however yet untested. 3. [ ] Android 'modal' presentation can crash randomly (unknown reason yet, can be deffered) ## Test code and steps to reproduce I've used & extended `Test1649` to present all capabilities of new API. ## Checklist - [ ] Included code example that can be used to test this change - [ ] Updated TS types - [ ] Updated documentation: - [ ] https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx - [ ] Ensured that CI passes --- Example/package.json | 1 + Example/yarn.lock | 16 + FabricExample/package.json | 1 + FabricExample/yarn.lock | 16 + android/build.gradle | 4 +- .../rnscreens/InsetsObserverProxy.kt | 67 +++ .../swmansion/rnscreens/RNScreensPackage.kt | 2 + .../java/com/swmansion/rnscreens/Screen.kt | 105 +++- .../rnscreens/ScreenContentWrapper.kt | 38 ++ .../rnscreens/ScreenContentWrapperManager.kt | 25 + .../com/swmansion/rnscreens/ScreenFooter.kt | 287 ++++++++++ .../rnscreens/ScreenFooterManager.kt | 25 + .../com/swmansion/rnscreens/ScreenFragment.kt | 30 +- .../rnscreens/ScreenFragmentWrapper.kt | 4 + .../rnscreens/ScreenModalFragment.kt | 281 ++++++++++ .../com/swmansion/rnscreens/ScreenStack.kt | 81 ++- .../rnscreens/ScreenStackFragment.kt | 444 ++++++++++++++-- .../rnscreens/ScreenStackFragmentWrapper.kt | 5 +- .../rnscreens/ScreenStackHeaderConfig.kt | 4 +- .../swmansion/rnscreens/ScreenViewManager.kt | 106 +++- .../swmansion/rnscreens/ScreenWindowTraits.kt | 67 ++- .../bottomsheet/BottomSheetDialogRootView.kt | 104 ++++ .../bottomsheet/BottomSheetDialogScreen.kt | 26 + .../rnscreens/bottomsheet/DimmingFragment.kt | 488 ++++++++++++++++++ .../rnscreens/bottomsheet/DimmingView.kt | 66 +++ .../GestureTransparentViewGroup.kt | 24 + .../rnscreens/bottomsheet/SheetUtils.kt | 127 +++++ .../events/SheetDetentChangedEvent.kt | 27 + .../com/swmansion/rnscreens/ext/NumericExt.kt | 12 + .../com/swmansion/rnscreens/ext/ViewExt.kt | 32 ++ .../rns_rounder_top_corners_shape.xml | 8 + ...NSScreenContentWrapperManagerDelegate.java | 25 + ...SScreenContentWrapperManagerInterface.java | 16 + .../RNSScreenFooterManagerDelegate.java | 25 + .../RNSScreenFooterManagerInterface.java | 16 + .../RNSScreenManagerDelegate.java | 11 +- .../RNSScreenManagerInterface.java | 7 +- apps/src/tests/Test1649.tsx | 216 -------- .../components/CommonSheetContent.tsx | 163 ++++++ apps/src/tests/Test1649/components/Footer.tsx | 36 ++ .../components/GestureHandlerButton.tsx | 27 + apps/src/tests/Test1649/index.tsx | 140 +++++ apps/src/tests/Test1649/routes.tsx | 89 ++++ apps/src/tests/Test1649/screens/Home.tsx | 31 ++ .../tests/Test1649/screens/ModalScreen.tsx | 8 + .../Test1649/screens/PushWithScrollView.tsx | 51 ++ apps/src/tests/Test1649/screens/Second.tsx | 65 +++ .../tests/Test1649/screens/SheetScreen.tsx | 19 + .../screens/SheetScreenWithScrollView.tsx | 42 ++ .../screens/SheetScreenWithTextInput.tsx | 66 +++ apps/src/tests/Test1649/screens/Third.tsx | 41 ++ apps/src/tests/Test1649/state.tsx | 33 ++ apps/src/tests/Test1649/types.tsx | 26 + apps/src/tests/index.ts | 1 + guides/GUIDE_FOR_LIBRARY_AUTHORS.md | 21 +- ios/RNSConvert.h | 8 +- ios/RNSConvert.mm | 34 +- ios/RNSScreen.h | 5 +- ios/RNSScreen.mm | 482 +++++++++++++++-- ios/RNSScreenContentWrapper.h | 44 ++ ios/RNSScreenContentWrapper.mm | 61 +++ ios/RNSScreenFooter.h | 30 ++ ios/RNSScreenFooter.mm | 137 +++++ native-stack/README.md | 30 +- react-native.config.js | 34 +- react-navigation | 2 +- src/components/Screen.tsx | 8 +- src/components/ScreenContentWrapper.tsx | 12 + src/components/ScreenFooter.tsx | 18 + src/fabric/ModalScreenNativeComponent.ts | 6 +- .../ScreenContentWrapperNativeComponent.ts | 9 + src/fabric/ScreenFooterNativeComponent.ts | 6 + src/fabric/ScreenNativeComponent.ts | 14 +- src/index.tsx | 10 + src/native-stack/types.tsx | 80 ++- src/native-stack/views/FooterComponent.tsx | 10 + src/native-stack/views/NativeStackView.tsx | 85 ++- src/types.tsx | 57 +- 78 files changed, 4363 insertions(+), 517 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/InsetsObserverProxy.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapperManager.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenFooterManager.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenModalFragment.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogRootView.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogScreen.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/GestureTransparentViewGroup.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/events/SheetDetentChangedEvent.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ext/NumericExt.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt create mode 100644 android/src/main/res/base/drawable/rns_rounder_top_corners_shape.xml create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerDelegate.java create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerInterface.java create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerDelegate.java create mode 100644 android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerInterface.java delete mode 100644 apps/src/tests/Test1649.tsx create mode 100644 apps/src/tests/Test1649/components/CommonSheetContent.tsx create mode 100644 apps/src/tests/Test1649/components/Footer.tsx create mode 100644 apps/src/tests/Test1649/components/GestureHandlerButton.tsx create mode 100644 apps/src/tests/Test1649/index.tsx create mode 100644 apps/src/tests/Test1649/routes.tsx create mode 100644 apps/src/tests/Test1649/screens/Home.tsx create mode 100644 apps/src/tests/Test1649/screens/ModalScreen.tsx create mode 100644 apps/src/tests/Test1649/screens/PushWithScrollView.tsx create mode 100644 apps/src/tests/Test1649/screens/Second.tsx create mode 100644 apps/src/tests/Test1649/screens/SheetScreen.tsx create mode 100644 apps/src/tests/Test1649/screens/SheetScreenWithScrollView.tsx create mode 100644 apps/src/tests/Test1649/screens/SheetScreenWithTextInput.tsx create mode 100644 apps/src/tests/Test1649/screens/Third.tsx create mode 100644 apps/src/tests/Test1649/state.tsx create mode 100644 apps/src/tests/Test1649/types.tsx create mode 100644 ios/RNSScreenContentWrapper.h create mode 100644 ios/RNSScreenContentWrapper.mm create mode 100644 ios/RNSScreenFooter.h create mode 100644 ios/RNSScreenFooter.mm create mode 100644 src/components/ScreenContentWrapper.tsx create mode 100644 src/components/ScreenFooter.tsx create mode 100644 src/fabric/ScreenContentWrapperNativeComponent.ts create mode 100644 src/fabric/ScreenFooterNativeComponent.ts create mode 100644 src/native-stack/views/FooterComponent.tsx diff --git a/Example/package.json b/Example/package.json index 15fd412c5d..1a2ccad7e1 100644 --- a/Example/package.json +++ b/Example/package.json @@ -24,6 +24,7 @@ "@react-navigation/native-stack": "link:../react-navigation/packages/native-stack/", "@react-navigation/routers": "link:../react-navigation/packages/routers/", "@react-navigation/stack": "link:../react-navigation/packages/stack/", + "jotai": "^2.9.0", "nanoid": "^4.0.2", "react": "18.3.1", "react-native": "0.75.0-rc.6", diff --git a/Example/yarn.lock b/Example/yarn.lock index 00fa229443..eb58dfbda5 100644 --- a/Example/yarn.lock +++ b/Example/yarn.lock @@ -3806,6 +3806,7 @@ __metadata: eslint: "npm:^8.19.0" glob-to-regexp: "npm:^0.4.1" jest: "npm:^29.6.3" + jotai: "npm:^2.9.0" metro-react-native-babel-preset: "npm:^0.76.8" nanoid: "npm:^4.0.2" patch-package: "npm:^8.0.0" @@ -8163,6 +8164,21 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.9.0": + version: 2.9.0 + resolution: "jotai@npm:2.9.0" + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/c5551fb90933bcbc28b11cdb4af681398a12f8eb39a4a49568ec6ce5062c2257dd84a85cbfd7ec7d970d56dfa5023d16a0ec7056bc2697fdf9b3ec94da67c9d1 + languageName: node + linkType: hard + "js-message@npm:1.0.7": version: 1.0.7 resolution: "js-message@npm:1.0.7" diff --git a/FabricExample/package.json b/FabricExample/package.json index 8af3dcd58c..9620c1733e 100644 --- a/FabricExample/package.json +++ b/FabricExample/package.json @@ -19,6 +19,7 @@ "@react-navigation/native-stack": "link:../react-navigation/packages/native-stack/", "@react-navigation/routers": "link:../react-navigation/packages/routers/", "@react-navigation/stack": "link:../react-navigation/packages/stack/", + "jotai": "^2.9.0", "nanoid": "^4.0.2", "react": "18.3.1", "react-native": "0.75.0-rc.6", diff --git a/FabricExample/yarn.lock b/FabricExample/yarn.lock index 711cda4635..e7402b0c20 100644 --- a/FabricExample/yarn.lock +++ b/FabricExample/yarn.lock @@ -3344,6 +3344,7 @@ __metadata: babel-jest: "npm:^29.6.3" eslint: "npm:^8.19.0" jest: "npm:^29.6.3" + jotai: "npm:^2.9.0" nanoid: "npm:^4.0.2" patch-package: "npm:^8.0.0" prettier: "npm:2.8.8" @@ -6799,6 +6800,21 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.9.0": + version: 2.9.0 + resolution: "jotai@npm:2.9.0" + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/c5551fb90933bcbc28b11cdb4af681398a12f8eb39a4a49568ec6ce5062c2257dd84a85cbfd7ec7d970d56dfa5023d16a0ec7056bc2697fdf9b3ec94da67c9d1 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" diff --git a/android/build.gradle b/android/build.gradle index eeece66dec..b8683ee4e1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -159,8 +159,8 @@ repositories { dependencies { implementation 'com.facebook.react:react-native:+' - implementation 'androidx.appcompat:appcompat:1.4.2' - implementation 'androidx.fragment:fragment:1.3.6' + 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.6.1' diff --git a/android/src/main/java/com/swmansion/rnscreens/InsetsObserverProxy.kt b/android/src/main/java/com/swmansion/rnscreens/InsetsObserverProxy.kt new file mode 100644 index 0000000000..1ff798a0e2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/InsetsObserverProxy.kt @@ -0,0 +1,67 @@ +package com.swmansion.rnscreens + +import android.view.View +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import java.lang.ref.WeakReference + +object InsetsObserverProxy : OnApplyWindowInsetsListener { + private val listeners: ArrayList = arrayListOf() + private var eventSourceView: WeakReference = 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 + + private var shouldForwardInsetsToView = true + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + var rollingInsets = + if (shouldForwardInsetsToView) { + WindowInsetsCompat.toWindowInsetsCompat( + v.onApplyWindowInsets(insets.toWindowInsets()), + v, + ) + } else { + insets + } + + listeners.forEach { + rollingInsets = it.onApplyWindowInsets(v, insets) + } + return rollingInsets + } + + fun addOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) { + listeners.add(listener) + } + + fun removeOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) { + listeners.remove(listener) + } + + fun registerOnView(view: View) { + if (!hasBeenRegistered) { + ViewCompat.setOnApplyWindowInsetsListener(view, this) + eventSourceView = WeakReference(view) + hasBeenRegistered = true + } else if (getObservedView() != view) { + throw IllegalStateException( + "[RNScreens] Attempt to register InsetsObserverProxy on $view while it has been already registered on ${getObservedView()}", + ) + } + } + + fun unregister() { + eventSourceView.get()?.takeIf { hasBeenRegistered }?.let { + ViewCompat.setOnApplyWindowInsetsListener(it, null) + } + } + + private fun getObservedView(): View? = eventSourceView.get() +} diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index e681c300fb..84124dd8ed 100644 --- a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +++ b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt @@ -37,6 +37,8 @@ class RNScreensPackage : TurboReactPackage() { ScreenStackHeaderConfigViewManager(), ScreenStackHeaderSubviewManager(), SearchBarManager(), + ScreenFooterManager(), + ScreenContentWrapperManager(), ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/Screen.kt b/android/src/main/java/com/swmansion/rnscreens/Screen.kt index 1ab8594ef3..a601100985 100644 --- a/android/src/main/java/com/swmansion/rnscreens/Screen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/Screen.kt @@ -10,6 +10,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.webkit.WebView +import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.children import androidx.fragment.app.Fragment import com.facebook.react.bridge.GuardedRunnable @@ -17,15 +18,25 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.PixelUtil 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.swmansion.rnscreens.events.HeaderHeightChangeEvent +import com.swmansion.rnscreens.events.SheetDetentChangedEvent @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. class Screen( - context: ReactContext?, -) : FabricEnabledViewGroup(context) { + val reactContext: ReactContext, +) : FabricEnabledViewGroup(reactContext), + ScreenContentWrapper.OnLayoutCallback { val fragment: Fragment? get() = fragmentWrapper?.fragment + val sheetBehavior: BottomSheetBehavior? + get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior + + val reactEventDispatcher: EventDispatcher? + get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + var fragmentWrapper: ScreenFragmentWrapper? = null var container: ScreenContainer? = null var activityState: ActivityState? = null @@ -40,6 +51,33 @@ class Screen( var isStatusBarAnimated: Boolean? = null var isBeingRemoved = false + // Props for controlling modal presentation + var isSheetGrabberVisible: Boolean = false + var sheetCornerRadius: Float = 0F + set(value) { + field = value + (fragment as? ScreenStackFragment)?.onSheetCornerRadiusChange() + } + var sheetExpandsWhenScrolledToEdge: Boolean = true + + // We want to make sure here that at least one value is present in this array all the time. + // TODO: Model this with custom data structure to guarantee that this invariant is not violated. + var sheetDetents = mutableListOf(1.0) + var sheetLargestUndimmedDetentIndex: Int = -1 + var sheetInitialDetentIndex: Int = 0 + var sheetClosesOnTouchOutside = true + var sheetElevation: Float = 24F + + 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 + } + 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 @@ -54,6 +92,33 @@ class Screen( layoutParams = WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION) } + /** + * 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 onLayoutCallback( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + val height = bottom - top + + if (sheetDetents.count() == 1 && sheetDetents.first() == SHEET_FIT_TO_CONTENTS) { + sheetBehavior?.let { + if (it.maxHeight != height) { + it.maxHeight = height + } + } + } + } + + fun registerLayoutCallbackForWrapper(wrapper: ScreenContentWrapper) { + wrapper.delegate = this + } + override fun dispatchSaveInstanceState(container: SparseArray) { // 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 @@ -84,6 +149,7 @@ class Screen( updateScreenSizePaper(width, height) } + footer?.onParentLayout(changed, l, t, r, b, container!!.height) notifyHeaderHeightChange(totalHeight) } } @@ -92,7 +158,6 @@ class Screen( width: Int, height: Int, ) { - val reactContext = context as ReactContext reactContext.runOnNativeModulesQueueThread( object : GuardedRunnable(reactContext.exceptionHandler) { override fun runGuarded() { @@ -127,7 +192,14 @@ class Screen( ) } - fun isTransparent(): Boolean = stackPresentation === StackPresentation.TRANSPARENT_MODAL + fun isTransparent(): 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) { @@ -351,10 +423,26 @@ class Screen( ?.dispatchEvent(HeaderHeightChangeEvent(surfaceId, id, headerHeight)) } + internal fun notifySheetDetentChange( + detentIndex: Int, + isStable: Boolean, + ) { + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + reactEventDispatcher?.dispatchEvent( + SheetDetentChangedEvent( + surfaceId, + id, + detentIndex, + isStable, + ), + ) + } + enum class StackPresentation { PUSH, MODAL, TRANSPARENT_MODAL, + FORM_SHEET, } enum class StackAnimation { @@ -390,4 +478,13 @@ class Screen( NAVIGATION_BAR_TRANSLUCENT, NAVIGATION_BAR_HIDDEN, } + + companion object { + const val TAG = "Screen" + + /** + * This value describes value in sheet detents array that will be treated as `fitToContents` option. + */ + const val SHEET_FIT_TO_CONTENTS = -1.0 + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt new file mode 100644 index 0000000000..9ab8fb513b --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt @@ -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 onLayoutCallback( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + delegate?.onLayoutCallback(changed, left, top, right, bottom) + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapperManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapperManager.kt new file mode 100644 index 0000000000..b38627d442 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapperManager.kt @@ -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(), + RNSScreenContentWrapperManagerInterface { + private val delegate: ViewManagerDelegate = 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 = delegate +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt new file mode 100644 index 0000000000..d4a3092a6f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt @@ -0,0 +1,287 @@ +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 + + + // 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, + ): 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 = 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) + 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) { + 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) { + 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. + * + * @param slideOffset sheet offset as reported by [BottomSheetCallback.onSlide] + * @return position of sheet's top **relative to container** + */ + private fun sheetTopWhileDragging(slideOffset: Float): Int = + 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" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFooterManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFooterManager.kt new file mode 100644 index 0000000000..97a331919b --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFooterManager.kt @@ -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(), + RNSScreenFooterManagerInterface { + private val delegate: ViewManagerDelegate = RNSScreenFooterManagerDelegate(this) + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext) = ScreenFooter(context) + + override fun getDelegate(): ViewManagerDelegate = delegate + + companion object { + const val REACT_CLASS = "RNSScreenFooter" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt index cf260fbe06..5d34509ecf 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt @@ -15,6 +15,7 @@ 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.bottomsheet.DimmingFragment import com.swmansion.rnscreens.events.HeaderBackButtonClickedEvent import com.swmansion.rnscreens.events.ScreenAppearEvent import com.swmansion.rnscreens.events.ScreenDisappearEvent @@ -22,6 +23,7 @@ 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 @@ -92,7 +94,7 @@ open class ScreenFragment : ) val wrapper = context?.let { ScreensFrameLayout(it) }?.apply { - addView(recycleView(screen)) + addView(screen.recycle()) } return wrapper } @@ -288,7 +290,12 @@ open class ScreenFragment : // 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 + val parent = + if (parentFragment is DimmingFragment) { + parentFragment?.parentFragment + } else { + 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 @@ -312,7 +319,7 @@ open class ScreenFragment : override fun onDestroy() { super.onDestroy() val container = screen.container - if (container == null || !container.hasScreen(this)) { + 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) { @@ -326,22 +333,7 @@ open class ScreenFragment : } companion object { - @JvmStatic - protected fun recycleView(view: View): 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. - val parent = view.parent - if (parent != null) { - (parent as ViewGroup).endViewTransition(view) - parent.removeView(view) - } - - // 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. - view.visibility = View.VISIBLE - return view - } + 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: diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFragmentWrapper.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFragmentWrapper.kt index 615b5b5d5c..6e25e14f20 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenFragmentWrapper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFragmentWrapper.kt @@ -15,6 +15,10 @@ interface ScreenFragmentWrapper : 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 diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenModalFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenModalFragment.kt new file mode 100644 index 0000000000..e5ee54d684 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenModalFragment.kt @@ -0,0 +1,281 @@ +package com.swmansion.rnscreens + +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewParent +import android.view.WindowManager +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.swmansion.rnscreens.bottomsheet.BottomSheetDialogRootView +import com.swmansion.rnscreens.bottomsheet.BottomSheetDialogScreen +import com.swmansion.rnscreens.bottomsheet.SheetUtils +import com.swmansion.rnscreens.events.ScreenDismissedEvent +import com.swmansion.rnscreens.ext.parentAsView +import com.swmansion.rnscreens.ext.recycle + +class ScreenModalFragment : + BottomSheetDialogFragment, + ScreenStackFragmentWrapper { + override lateinit var screen: Screen + + // Nested containers + override val childScreenContainers = ArrayList() + + 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 dismissFromContainer() { + check(container is ScreenStack) + val container = container as ScreenStack + container.dismiss(this) + } + + // Modal can never be first on the stack + override fun canNavigateBack(): Boolean = true + + override fun addChildScreenContainer(container: ScreenContainer) { + childScreenContainers.add(container) + } + + override fun removeChildScreenContainer(container: ScreenContainer) { + childScreenContainers.remove(container) + } + + override fun onContainerUpdate() { + } + + override fun onViewAnimationStart() { + } + + override fun onViewAnimationEnd() { + } + + override fun tryGetActivity(): Activity? = requireActivity() + + override fun tryGetContext(): ReactContext? { + if (context is ReactContext) { + return context as ReactContext + } + if (screen.context is ReactContext) { + return screen.context as ReactContext + } + + var parent: ViewParent? = screen.container + while (parent != null) { + if (parent is Screen && parent.context is ReactContext) { + return parent.context as ReactContext + } + parent = parent.parent + } + + return null + } + + override fun canDispatchLifecycleEvent(event: ScreenFragment.ScreenLifecycleEvent): Boolean { + TODO("Not yet implemented") + } + + override fun updateLastEventDispatched(event: ScreenFragment.ScreenLifecycleEvent) { + TODO("Not yet implemented") + } + + override fun dispatchLifecycleEvent( + event: ScreenFragment.ScreenLifecycleEvent, + fragmentWrapper: ScreenFragmentWrapper, + ) { + TODO("Not yet implemented") + } + + override fun dispatchLifecycleEventInChildContainers(event: ScreenFragment.ScreenLifecycleEvent) { + TODO("Not yet implemented") + } + + override fun dispatchHeaderBackButtonClickedEvent() { + TODO("Not yet implemented") + } + + override fun dispatchTransitionProgressEvent( + alpha: Float, + closing: Boolean, + ) { + TODO("Not yet implemented") + } + + override fun onDestroy() { + super.onDestroy() + val container = container + if (container == null || !container.hasScreen(this)) { + val screenContext = screen.context + if (screenContext is ReactContext) { + val surfaceId = UIManagerHelper.getSurfaceId(screenContext) + UIManagerHelper + .getEventDispatcherForReactTag(screenContext, screen.id) + ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) + } + } + childScreenContainers.clear() + } + + override fun removeToolbar(): Unit = throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now") + + override fun setToolbar(toolbar: Toolbar): Unit = + throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now") + + override fun setToolbarShadowHidden(hidden: Boolean): Unit = + throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now") + + override fun setToolbarTranslucent(translucent: Boolean): Unit = + throw IllegalStateException("[RNScreens] Modal screens on Android do not support header right now") + + private fun configureDialogAndBehaviour(): BottomSheetDialog { + sheetDialog = BottomSheetDialogScreen(requireContext(), this) + sheetDialog.dismissWithAnimation = true + sheetDialog.setCanceledOnTouchOutside(screen.sheetClosesOnTouchOutside) + + configureBehaviour() + + return sheetDialog + } + + /** + * This method might return slightly different values depending on code path, + * but during testing I've found this effect negligible. For practical purposes + * this is acceptable. + */ + private fun tryResolveContainerHeight(): Int? { + screen.container?.height?.let { return it } + context + ?.resources + ?.displayMetrics + ?.heightPixels + ?.let { return it } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager) + ?.currentWindowMetrics + ?.bounds + ?.height() + ?.let { return it } + } + return null + } + + private fun configureBehaviour() { + val containerHeight = tryResolveContainerHeight() + check(containerHeight != null) { "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" } + + behavior.apply { + isHideable = true + isDraggable = true + } + + when (screen.sheetDetents.count()) { + 1 -> + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + isFitToContents = true + maxHeight = (screen.sheetDetents.first() * containerHeight).toInt() + } + + 2 -> + behavior.apply { + state = + SheetUtils.sheetStateFromDetentIndex( + screen.sheetInitialDetentIndex, + screen.sheetDetents.count(), + ) + skipCollapsed = false + isFitToContents = true + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + maxHeight = (screen.sheetDetents[1] * containerHeight).toInt() + } + + 3 -> + behavior.apply { + state = + SheetUtils.sheetStateFromDetentIndex( + screen.sheetInitialDetentIndex, + screen.sheetDetents.count(), + ) + skipCollapsed = false + isFitToContents = false + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + expandedOffset = ((1 - screen.sheetDetents[2]) * containerHeight).toInt() + halfExpandedRatio = + (screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat() + } + + else -> throw IllegalStateException("[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.") + } + } + + companion object { + const val TAG = "ScreenModalFragment" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt index afd6848978..27624fdf70 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt @@ -7,6 +7,7 @@ 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.DimmingFragment import com.swmansion.rnscreens.events.StackFinishTransitioningEvent import java.util.Collections import kotlin.collections.ArrayList @@ -26,6 +27,11 @@ class ScreenStack( private var previousChildrenCount = 0 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() @@ -38,18 +44,16 @@ class ScreenStack( get() = stack val rootScreen: Screen - get() { - for (i in 0 until screenCount) { - val screenWrapper = getScreenFragmentWrapperAt(i) - if (!dismissedWrappers.contains(screenWrapper)) { - return screenWrapper.screen - } - } - throw IllegalStateException("Stack has no root screen set") + 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 -> DimmingFragment(ScreenStackFragment(screen)) + else -> ScreenStackFragment(screen) } - override fun adapt(screen: Screen) = ScreenStackFragment(screen) - override fun startViewTransition(view: View) { super.startViewTransition(view) removalTransitionStarted = true @@ -94,8 +98,10 @@ class ScreenStack( // 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 ^ - var visibleBottom: ScreenFragmentWrapper? = null // this is only set if newTop has TRANSPARENT_MODAL presentation mode + var visibleBottom: ScreenFragmentWrapper? = + null // this is only set if newTop has one of transparent presentation modes isDetachingCurrentScreen = false // we reset it so the previous value is not used by mistake + for (i in screenWrappers.indices.reversed()) { val screenWrapper = getScreenFragmentWrapperAt(i) if (!dismissedWrappers.contains(screenWrapper)) { @@ -109,6 +115,7 @@ class ScreenStack( } } } + var shouldUseOpenAnimation = true var stackAnimation: StackAnimation? = null if (!stack.contains(newTop)) { @@ -141,9 +148,24 @@ class ScreenStack( if (stackAnimation != null) { if (shouldUseOpenAnimation) { when (stackAnimation) { - StackAnimation.DEFAULT -> it.setCustomAnimations(R.anim.rns_default_enter_in, R.anim.rns_default_enter_out) - StackAnimation.NONE -> it.setCustomAnimations(R.anim.rns_no_animation_20, R.anim.rns_no_animation_20) - StackAnimation.FADE -> it.setCustomAnimations(R.anim.rns_fade_in, R.anim.rns_fade_out) + StackAnimation.DEFAULT -> + it.setCustomAnimations( + R.anim.rns_default_enter_in, + R.anim.rns_default_enter_out, + ) + + StackAnimation.NONE -> + it.setCustomAnimations( + R.anim.rns_no_animation_20, + R.anim.rns_no_animation_20, + ) + + StackAnimation.FADE -> + it.setCustomAnimations( + R.anim.rns_fade_in, + R.anim.rns_fade_out, + ) + StackAnimation.SLIDE_FROM_RIGHT -> it.setCustomAnimations( R.anim.rns_slide_in_from_right, @@ -164,9 +186,24 @@ class ScreenStack( } } else { when (stackAnimation) { - StackAnimation.DEFAULT -> it.setCustomAnimations(R.anim.rns_default_exit_in, R.anim.rns_default_exit_out) - StackAnimation.NONE -> it.setCustomAnimations(R.anim.rns_no_animation_20, R.anim.rns_no_animation_20) - StackAnimation.FADE -> it.setCustomAnimations(R.anim.rns_fade_in, R.anim.rns_fade_out) + StackAnimation.DEFAULT -> + it.setCustomAnimations( + R.anim.rns_default_exit_in, + R.anim.rns_default_exit_out, + ) + + StackAnimation.NONE -> + it.setCustomAnimations( + R.anim.rns_no_animation_20, + R.anim.rns_no_animation_20, + ) + + StackAnimation.FADE -> + it.setCustomAnimations( + R.anim.rns_fade_in, + R.anim.rns_fade_out, + ) + StackAnimation.SLIDE_FROM_RIGHT -> it.setCustomAnimations( R.anim.rns_slide_in_from_left, @@ -239,7 +276,9 @@ class ScreenStack( } } // when first visible screen found, make all screens after that visible - it.add(id, fragmentWrapper.fragment).runOnCommit { top?.screen?.bringToFront() } + it.add(id, fragmentWrapper.fragment).runOnCommit { + top?.screen?.bringToFront() + } } } else if (newTop != null && !newTop.fragment.isAdded) { it.add(id, newTop.fragment) @@ -262,7 +301,9 @@ class ScreenStack( 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) + fragmentWrapper.screen.changeAccessibilityMode( + IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + ) // don't change a11y below non-transparent screens if (fragmentWrapper == visibleBottom) { @@ -363,6 +404,8 @@ class ScreenStack( } companion object { + const val TAG = "ScreenStack" + private fun needsDrawReordering(fragmentWrapper: ScreenFragmentWrapper): Boolean = // 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. diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt index cca93143c9..1c06c7f3a0 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt @@ -3,6 +3,8 @@ package com.swmansion.rnscreens import android.annotation.SuppressLint import android.content.Context 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 @@ -10,17 +12,44 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowManager import android.view.animation.Animation import android.view.animation.AnimationSet +import android.view.animation.AnimationUtils import android.view.animation.Transformation +import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout +import androidx.annotation.RequiresApi import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.commit import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView 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.bottomsheet.BottomSheetBehavior.BottomSheetCallback +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.DimmingFragment +import com.swmansion.rnscreens.bottomsheet.SheetUtils +import com.swmansion.rnscreens.ext.recycle import com.swmansion.rnscreens.utils.DeviceUtils +sealed class KeyboardState + +object KeyboardNotVisible : KeyboardState() + +object KeyboardDidHide : KeyboardState() + +class KeyboardVisible( + val height: Int, +) : KeyboardState() + class ScreenStackFragment : ScreenFragment, ScreenStackFragmentWrapper { @@ -34,6 +63,15 @@ class ScreenStackFragment : 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 + } + @SuppressLint("ValidFragment") constructor(screenView: Screen) : super(screenView) @@ -99,50 +137,350 @@ class ScreenStackFragment : } } - override fun onStart() { - lastFocusedChild?.requestFocus() - super.onStart() + // If the Screen has `formSheet` presentation this callback is attached to its behavior. + // It is responsible for firing detent changed events & removing the sheet from the container + // once it is hidden by user gesture. + private val bottomSheetStateCallback = + object : BottomSheetCallback() { + private var lastStableState: Int = SheetUtils.sheetStateFromDetentIndex(screen.sheetInitialDetentIndex, screen.sheetDetents.count()) + + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (SheetUtils.isStateStable(newState)) { + lastStableState = newState + screen.notifySheetDetentChange(SheetUtils.detentIndexFromSheetState(lastStableState, screen.sheetDetents.count()), true) + } else if (newState == BottomSheetBehavior.STATE_DRAGGING) { + screen.notifySheetDetentChange(SheetUtils.detentIndexFromSheetState(lastStableState, screen.sheetDetents.count()), false) + } + + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + // If we are wrapped in DimmingFragment we want it to be removed alongside + // => we use its fragment manager. Otherwise we just remove this fragment. + if (this@ScreenStackFragment.parentFragment is DimmingFragment) { + parentFragmentManager.commit { + setReorderingAllowed(true) + remove(this@ScreenStackFragment) + } + } else { + this@ScreenStackFragment.dismissFromContainer() + } + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) = Unit + } + + override fun onCreateAnimation( + transit: Int, + enter: Boolean, + nextAnim: Int, + ): Animation? { + if (screen.stackPresentation != Screen.StackPresentation.FORM_SHEET) { + return null + } + return if (enter) { + AnimationUtils.loadAnimation(context, R.anim.rns_slide_in_from_bottom) + } else { + AnimationUtils.loadAnimation(context, R.anim.rns_slide_out_to_bottom) + } + } + + internal fun onSheetCornerRadiusChange() { + (screen.background as MaterialShapeDrawable).shapeAppearanceModel = + ShapeAppearanceModel + .Builder() + .apply { + setTopLeftCorner(CornerFamily.ROUNDED, screen.sheetCornerRadius) + setTopRightCorner(CornerFamily.ROUNDED, screen.sheetCornerRadius) + }.build() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - val view: ScreensCoordinatorLayout? = - context?.let { ScreensCoordinatorLayout(it, this) } + ): View { + coordinatorLayout = ScreensCoordinatorLayout(requireContext(), this) screen.layoutParams = CoordinatorLayout .LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT, - ).apply { behavior = if (isToolbarTranslucent) null else ScrollingViewBehavior() } - - view?.addView(recycleView(screen)) - - appBarLayout = - context?.let { AppBarLayout(it) }?.apply { - // By default AppBarLayout will have a background color set but since we cover the whole layout - // with toolbar (that can be semi-transparent) the bar layout background color does not pay a - // role. On top of that it breaks screens animations when alfa offscreen compositing is off - // (which is the default) - setBackgroundColor(Color.TRANSPARENT) - layoutParams = - AppBarLayout.LayoutParams( - AppBarLayout.LayoutParams.MATCH_PARENT, - AppBarLayout.LayoutParams.WRAP_CONTENT, + ).apply { + behavior = + if (screen.stackPresentation == Screen.StackPresentation.FORM_SHEET) { + createAndConfigureBottomSheetBehaviour() + } else if (isToolbarTranslucent) { + null + } else { + ScrollingViewBehavior() + } + } + + if (screen.stackPresentation == Screen.StackPresentation.FORM_SHEET) { + 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 + } + + coordinatorLayout.addView(screen.recycle()) + + if (screen.stackPresentation != Screen.StackPresentation.MODAL && + screen.stackPresentation != Screen.StackPresentation.FORM_SHEET + ) { + appBarLayout = + context?.let { AppBarLayout(it) }?.apply { + // By default AppBarLayout will have a background color set but since we cover the whole layout + // with toolbar (that can be semi-transparent) the bar layout background color does not pay a + // role. On top of that it breaks screens animations when alfa offscreen compositing is off + // (which is the default) + setBackgroundColor(Color.TRANSPARENT) + layoutParams = + AppBarLayout.LayoutParams( + AppBarLayout.LayoutParams.MATCH_PARENT, + AppBarLayout.LayoutParams.WRAP_CONTENT, + ) + } + + coordinatorLayout.addView(appBarLayout) + if (isToolbarShadowHidden) { + appBarLayout?.targetElevation = 0f + } + toolbar?.let { appBarLayout?.addView(it.recycle()) } + setHasOptionsMenu(true) + } + return coordinatorLayout + } + + /** + * 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? { + if (screen.container != null) { + return screenStack.height + } + + 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 val keyboardSheetCallback = + object : BottomSheetCallback() { + @RequiresApi(Build.VERSION_CODES.M) + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + val isImeVisible = + WindowInsetsCompat + .toWindowInsetsCompat(bottomSheet.rootWindowInsets) + .isVisible(WindowInsetsCompat.Type.ime()) + if (isImeVisible) { + // Does it not interfere with React Native focus mechanism? In any case I'm not aware + // of different way of hiding the keyboard. + // https://stackoverflow.com/questions/1109022/how-can-i-close-hide-the-android-soft-keyboard-programmatically + // https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility + + // I want to be polite here and request focus before dismissing the keyboard, + // however even if it fails I want to try to hide the keyboard. This sometimes works... + bottomSheet.requestFocus() + val imm = requireContext().getSystemService(InputMethodManager::class.java) + imm.hideSoftInputFromWindow(bottomSheet.windowToken, 0) + } + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) = Unit + } + + internal fun configureBottomSheetBehaviour( + behavior: BottomSheetBehavior, + keyboardState: KeyboardState = KeyboardNotVisible, + ): BottomSheetBehavior { + val containerHeight = tryResolveContainerHeight() + check(containerHeight != null) { + "[RNScreens] Failed to find window height during bottom sheet behaviour configuration" + } + + behavior.apply { + isHideable = true + isDraggable = true + + // It seems that there is a guard in material implementation that will prevent + // this callback from being registered multiple times. + addBottomSheetCallback(bottomSheetStateCallback) + } + + screen.footer?.registerWithSheetBehavior(behavior) + + return when (keyboardState) { + is KeyboardNotVisible -> { + when (screen.sheetDetents.count()) { + 1 -> + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + isFitToContents = true + maxHeight = (screen.sheetDetents.first() * containerHeight).toInt() + } + + 2 -> + behavior.apply { + state = + SheetUtils.sheetStateFromDetentIndex( + screen.sheetInitialDetentIndex, + screen.sheetDetents.count(), + ) + skipCollapsed = false + isFitToContents = true + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + maxHeight = (screen.sheetDetents[1] * containerHeight).toInt() + } + + 3 -> + behavior.apply { + state = + SheetUtils.sheetStateFromDetentIndex( + screen.sheetInitialDetentIndex, + screen.sheetDetents.count(), + ) + skipCollapsed = false + isFitToContents = false + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + expandedOffset = ((1 - screen.sheetDetents[2]) * containerHeight).toInt() + halfExpandedRatio = + (screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat() + } + + else -> throw IllegalStateException( + "[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.", ) + } } - view?.addView(appBarLayout) - if (isToolbarShadowHidden) { - appBarLayout?.elevation = 0f - appBarLayout?.stateListAnimator = null + is KeyboardVisible -> { + val newMaxHeight = + if (behavior.maxHeight - keyboardState.height > 1) { + behavior.maxHeight - keyboardState.height + } else { + behavior.maxHeight + } + when (screen.sheetDetents.count()) { + 1 -> + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + isFitToContents = true + maxHeight = newMaxHeight + addBottomSheetCallback(keyboardSheetCallback) + } + + 2 -> + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = false + isFitToContents = true + maxHeight = newMaxHeight + addBottomSheetCallback(keyboardSheetCallback) + } + + 3 -> + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = false + isFitToContents = false + maxHeight = newMaxHeight + addBottomSheetCallback(keyboardSheetCallback) + } + + 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(keyboardSheetCallback) + when (screen.sheetDetents.count()) { + 1 -> + behavior.apply { + skipCollapsed = true + isFitToContents = true + maxHeight = (screen.sheetDetents.first() * containerHeight).toInt() + } + + 2 -> + behavior.apply { + skipCollapsed = false + isFitToContents = true + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + maxHeight = (screen.sheetDetents[1] * containerHeight).toInt() + } + + 3 -> + behavior.apply { + skipCollapsed = false + isFitToContents = false + peekHeight = (screen.sheetDetents[0] * containerHeight).toInt() + expandedOffset = ((1 - screen.sheetDetents[2]) * containerHeight).toInt() + halfExpandedRatio = + (screen.sheetDetents[1] / screen.sheetDetents[2]).toFloat() + } + + else -> throw IllegalStateException( + "[RNScreens] Invalid detent count ${screen.sheetDetents.count()}. Expected at most 3.", + ) + } + } } - toolbar?.let { appBarLayout?.addView(recycleView(it)) } - setHasOptionsMenu(true) - return view + } + + // In general it would be great to create BottomSheetBehaviour only via this method as it runs some + // side effects. + internal fun createAndConfigureBottomSheetBehaviour(): BottomSheetBehavior = + configureBottomSheetBehaviour(BottomSheetBehavior()) + + private fun attachShapeToScreen(screen: Screen) { + val cornerSize = PixelUtil.toPixelFromDIP(screen.sheetCornerRadius) + val shapeAppearanceModel = + ShapeAppearanceModel + .Builder() + .apply { + setTopLeftCorner(CornerFamily.ROUNDED, cornerSize) + setTopRightCorner(CornerFamily.ROUNDED, cornerSize) + }.build() + val shape = MaterialShapeDrawable(shapeAppearanceModel) + shape.setTint((screen.background as? ColorDrawable?)?.color ?: Color.TRANSPARENT) + screen.background = shape } override fun onStop() { @@ -228,24 +566,26 @@ class ScreenStackFragment : } } - override fun dismiss() { - val container: ScreenContainer? = screen.container - check(container is ScreenStack) { "ScreenStackFragment added into a non-stack container" } - container.dismiss(this) + override fun dismissFromContainer() { + screenStack.dismiss(this) } private class ScreensCoordinatorLayout( context: Context, - private val mFragment: ScreenFragment, - ) : CoordinatorLayout(context) { - private val mAnimationListener: Animation.AnimationListener = + private val fragment: ScreenStackFragment, +// ) : CoordinatorLayout(context), ReactCompoundViewGroup, ReactHitSlopView { + ) : CoordinatorLayout(context), + ReactPointerEventsView { + override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets = super.onApplyWindowInsets(insets) + + private val animationListener: Animation.AnimationListener = object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation) { - mFragment.onViewAnimationStart() + fragment.onViewAnimationStart() } override fun onAnimationEnd(animation: Animation) { - mFragment.onViewAnimationEnd() + fragment.onViewAnimationEnd() } override fun onAnimationRepeat(animation: Animation) {} @@ -261,13 +601,13 @@ class ScreenStackFragment : // and also this is not necessary when going back since the lifecycle methods // are correctly dispatched then. // We also add fakeAnimation to the set of animations, which sends the progress of animation - val fakeAnimation = ScreensAnimation(mFragment).apply { duration = animation.duration } + val fakeAnimation = ScreensAnimation(fragment).apply { duration = animation.duration } - if (animation is AnimationSet && !mFragment.isRemoving) { + if (animation is AnimationSet && !fragment.isRemoving) { animation .apply { addAnimation(fakeAnimation) - setAnimationListener(mAnimationListener) + setAnimationListener(animationListener) }.also { super.startAnimation(it) } @@ -276,7 +616,7 @@ class ScreenStackFragment : .apply { addAnimation(animation) addAnimation(fakeAnimation) - setAnimationListener(mAnimationListener) + setAnimationListener(animationListener) }.also { super.startAnimation(it) } @@ -295,6 +635,28 @@ class ScreenStackFragment : super.clearFocus() } } + +// override fun reactTagForTouch(touchX: Float, touchY: Float): Int { +// throw IllegalStateException("Screen wrapper should never be asked for the view tag") +// } +// +// override fun interceptsTouchEvent(touchX: Float, touchY: Float): Boolean { +// return false +// } +// +// override fun getHitSlopRect(): Rect? { +// val screen: Screen = fragment.screen +// // left – The X coordinate of the left side of the rectangle +// // top – The Y coordinate of the top of the rectangle i +// // right – The X coordinate of the right side of the rectangle +// // bottom – The Y coordinate of the bottom of the rectangle +// return Rect(screen.x.toInt(), -screen.y.toInt(), screen.x.toInt() + screen.width, screen.y.toInt() + screen.height) +// } + + // 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 fun getPointerEvents(): PointerEvents = PointerEvents.BOX_NONE } private class ScreensAnimation( diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragmentWrapper.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragmentWrapper.kt index e9f4e74a4b..440892860e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragmentWrapper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragmentWrapper.kt @@ -15,5 +15,8 @@ interface ScreenStackFragmentWrapper : ScreenFragmentWrapper { // Navigation fun canNavigateBack(): Boolean - fun dismiss() + /** + * Removes this fragment from the container it/it's screen belongs to. + */ + fun dismissFromContainer() } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt index e1fc8cecc3..dba26bdefa 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt @@ -55,14 +55,14 @@ class ScreenStackHeaderConfig( val parentFragment = it.parentFragment if (parentFragment is ScreenStackFragment) { if (parentFragment.screen.nativeBackButtonDismissalEnabled) { - parentFragment.dismiss() + parentFragment.dismissFromContainer() } else { parentFragment.dispatchHeaderBackButtonClickedEvent() } } } else { if (it.screen.nativeBackButtonDismissalEnabled) { - it.dismiss() + it.dismissFromContainer() } else { it.dispatchHeaderBackButtonClickedEvent() } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt index 99e88f62d2..d29380e365 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt @@ -1,6 +1,8 @@ package com.swmansion.rnscreens +import android.view.View import com.facebook.react.bridge.JSApplicationIllegalArgumentException +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.MapBuilder import com.facebook.react.module.annotations.ReactModule @@ -20,6 +22,7 @@ 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 : @@ -42,6 +45,42 @@ open class ScreenViewManager : 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?, @@ -81,7 +120,8 @@ open class ScreenViewManager : view.stackPresentation = when (presentation) { "push" -> Screen.StackPresentation.PUSH - "modal", "containedModal", "fullScreenModal", "formSheet" -> + "formSheet" -> Screen.StackPresentation.FORM_SHEET + "modal", "containedModal", "fullScreenModal" -> Screen.StackPresentation.MODAL "transparentModal", "containedTransparentModal" -> Screen.StackPresentation.TRANSPARENT_MODAL @@ -210,6 +250,14 @@ open class ScreenViewManager : view.nativeBackButtonDismissalEnabled = nativeBackButtonDismissalEnabled } + @ReactProp(name = "sheetElevation") + override fun setSheetElevation( + view: Screen?, + value: Int, + ) { + view?.sheetElevation = value.toFloat() + } + // these props are not available on Android, however we must override their setters override fun setFullScreenSwipeEnabled( view: Screen?, @@ -256,30 +304,65 @@ open class ScreenViewManager : value: String?, ) = Unit + @ReactProp(name = "sheetAllowedDetents") override fun setSheetAllowedDetents( view: Screen, - value: String?, - ) = Unit + value: ReadableArray?, + ) { + view.sheetDetents.clear() + + if (value == null || value.size() == 0) { + view.sheetDetents.add(1.0) + return + } + + IntProgression + .fromClosedRange(0, value.size() - 1, 1) + .asSequence() + .map { idx -> value.getDouble(idx) } + .toCollection(view.sheetDetents) + } + @ReactProp(name = "sheetLargestUndimmedDetent") override fun setSheetLargestUndimmedDetent( view: Screen, - value: String?, - ) = Unit + 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?, + view: Screen, value: Boolean, - ) = Unit + ) { + view.isSheetGrabberVisible = value + } + @ReactProp(name = "sheetCornerRadius") override fun setSheetCornerRadius( - view: Screen?, + view: Screen, value: Float, - ) = Unit + ) { + view.sheetCornerRadius = value + } + @ReactProp(name = "sheetExpandsWhenScrolledToEdge") override fun setSheetExpandsWhenScrolledToEdge( - view: Screen?, + view: Screen, value: Boolean, - ) = Unit + ) { + view.sheetExpandsWhenScrolledToEdge = value + } + + @ReactProp(name = "sheetInitialDetent") + override fun setSheetInitialDetent( + view: Screen, + value: Int, + ) { + view.sheetInitialDetentIndex = value + } override fun getExportedCustomDirectEventTypeConstants(): MutableMap = mutableMapOf( @@ -291,6 +374,7 @@ open class ScreenViewManager : HeaderHeightChangeEvent.EVENT_NAME to MapBuilder.of("registrationName", "onHeaderHeightChange"), HeaderBackButtonClickedEvent.EVENT_NAME to MapBuilder.of("registrationName", "onHeaderBackButtonClicked"), ScreenTransitionProgressEvent.EVENT_NAME to MapBuilder.of("registrationName", "onTransitionProgress"), + SheetDetentChangedEvent.EVENT_NAME to MapBuilder.of("registrationName", "onSheetDetentChanged"), ) protected override fun getDelegate(): ViewManagerDelegate = delegate diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenWindowTraits.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenWindowTraits.kt index edcfa7d5f2..cbdb5478df 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenWindowTraits.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenWindowTraits.kt @@ -7,8 +7,10 @@ import android.app.Activity import android.content.pm.ActivityInfo import android.graphics.Color import android.os.Build +import android.view.View import android.view.ViewParent import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -26,6 +28,40 @@ object ScreenWindowTraits { private var didSetNavigationBarAppearance = false private var defaultStatusBarColor: Int? = null + private var windowInsetsListener = + object : OnApplyWindowInsetsListener { + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + val defaultInsets = ViewCompat.onApplyWindowInsets(v, insets) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowInsets = + defaultInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + + return WindowInsetsCompat + .Builder() + .setInsets( + WindowInsetsCompat.Type.statusBars(), + Insets.of( + windowInsets.left, + 0, + windowInsets.right, + windowInsets.bottom, + ), + ).build() + } else { + return defaultInsets.replaceSystemWindowInsets( + defaultInsets.systemWindowInsetLeft, + 0, + defaultInsets.systemWindowInsetRight, + defaultInsets.systemWindowInsetBottom, + ) + } + } + } + internal fun applyDidSetOrientation() { didSetOrientation = true } @@ -124,35 +160,10 @@ object ScreenWindowTraits { // and consume all the top insets so no padding will be added under the status bar. val decorView = activity.window.decorView if (translucent) { - ViewCompat.setOnApplyWindowInsetsListener(decorView) { v, insets -> - val defaultInsets = ViewCompat.onApplyWindowInsets(v, insets) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val windowInsets = - defaultInsets.getInsets(WindowInsetsCompat.Type.statusBars()) - - WindowInsetsCompat - .Builder() - .setInsets( - WindowInsetsCompat.Type.statusBars(), - Insets.of( - windowInsets.left, - 0, - windowInsets.right, - windowInsets.bottom, - ), - ).build() - } else { - defaultInsets.replaceSystemWindowInsets( - defaultInsets.systemWindowInsetLeft, - 0, - defaultInsets.systemWindowInsetRight, - defaultInsets.systemWindowInsetBottom, - ) - } - } + InsetsObserverProxy.registerOnView(decorView) + InsetsObserverProxy.addOnApplyWindowInsetsListener(windowInsetsListener) } else { - ViewCompat.setOnApplyWindowInsetsListener(decorView, null) + InsetsObserverProxy.removeOnApplyWindowInsetsListener(windowInsetsListener) } ViewCompat.requestApplyInsets(decorView) } diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogRootView.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogRootView.kt new file mode 100644 index 0000000000..80edd7cd3d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogRootView.kt @@ -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" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogScreen.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogScreen.kt new file mode 100644 index 0000000000..cb8ad75b34 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogScreen.kt @@ -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 = 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 + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt new file mode 100644 index 0000000000..5a3b9bd72d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt @@ -0,0 +1,488 @@ +package com.swmansion.rnscreens.bottomsheet + +import android.animation.ValueAnimator +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.appcompat.widget.Toolbar +import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +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.BottomSheetBehavior.BottomSheetCallback +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.R +import com.swmansion.rnscreens.Screen +import com.swmansion.rnscreens.ScreenContainer +import com.swmansion.rnscreens.ScreenFragment +import com.swmansion.rnscreens.ScreenFragmentWrapper +import com.swmansion.rnscreens.ScreenStack +import com.swmansion.rnscreens.ScreenStackFragment +import com.swmansion.rnscreens.ScreenStackFragmentWrapper +import com.swmansion.rnscreens.events.ScreenDismissedEvent + +/** + * This fragment aims to provide dimming view functionality behind the nested fragment. + * Useful when nested fragment is transparent / uses some kind of non-fullscreen presentation, + * such as `formSheet`. + */ +class DimmingFragment( + val nestedFragment: ScreenFragmentWrapper, +) : Fragment(), + LifecycleEventObserver, + ScreenStackFragmentWrapper, + Animation.AnimationListener, + OnApplyWindowInsetsListener { + private lateinit var dimmingView: DimmingView + private lateinit var containerView: GestureTransparentViewGroup + + private val maxAlpha: Float = 0.15F + + private var isKeyboardVisible: Boolean = false + private var keyboardState: KeyboardState = KeyboardNotVisible + + private var dimmingViewCallback: BottomSheetCallback? = null + + private val container: ScreenStack? + get() = screen.container as? ScreenStack + + private val insetsProxy = InsetsObserverProxy + + init { + // We register for our child lifecycle as we want to know when it's dismissed via native gesture + nestedFragment.fragment.lifecycle.addObserver(this) + } + + /** + * This bottom sheet callback is responsible for animating alpha of the dimming view. + */ + private class AnimateDimmingViewCallback( + val screen: Screen, + val viewToAnimate: View, + val maxAlpha: Float, + ) : BottomSheetCallback() { + // largest *slide offset* that is yet undimmed + private var largestUndimmedOffset: Float = + computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) + + // first *slide offset* that should be fully dimmed + private var firstDimmedOffset: Float = + computeOffsetFromDetentIndex( + (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( + 0, + screen.sheetDetents.count() - 1, + ), + ) + + // interval that we interpolate the alpha value over + private var intervalLength = firstDimmedOffset - largestUndimmedOffset + private val animator = + ValueAnimator.ofFloat(0F, maxAlpha).apply { + duration = 1 // Driven manually + addUpdateListener { + viewToAnimate.alpha = it.animatedValue as Float + } + } + + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (newState == BottomSheetBehavior.STATE_DRAGGING || newState == BottomSheetBehavior.STATE_SETTLING) { + largestUndimmedOffset = + computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) + firstDimmedOffset = + computeOffsetFromDetentIndex( + (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( + 0, + screen.sheetDetents.count() - 1 + ) + ) + assert(firstDimmedOffset >= largestUndimmedOffset) { + "[RNScreens] Invariant violation: firstDimmedOffset ($firstDimmedOffset) < largestDimmedOffset ($largestUndimmedOffset)" + } + intervalLength = firstDimmedOffset - largestUndimmedOffset + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { + if (largestUndimmedOffset < slideOffset && slideOffset < firstDimmedOffset) { + val fraction = (slideOffset - largestUndimmedOffset) / intervalLength + animator.setCurrentFraction(fraction) + } + } + + /** + * This method does compute slide offset (see [BottomSheetCallback.onSlide] docs) for detent + * at given index in the detents array. + */ + private fun computeOffsetFromDetentIndex(index: Int): Float = + when (screen.sheetDetents.size) { + 1 -> // Only 1 detent present in detents array + when (index) { + -1 -> -1F // hidden + 0 -> 1F // fully expanded + else -> -1F // unexpected, default + } + + 2 -> + when (index) { + -1 -> -1F // hidden + 0 -> 0F // collapsed + 1 -> 1F // expanded + else -> -1F + } + + 3 -> + when (index) { + -1 -> -1F // hidden + 0 -> 0F // collapsed + 1 -> screen.sheetBehavior!!.halfExpandedRatio // half + 2 -> 1F // expanded + else -> -1F + } + + else -> -1F + } + } + + override fun onCreateAnimation( + transit: Int, + enter: Boolean, + nextAnim: Int, + ): Animation? = + // We want dimming view to have always fade animation in current usages. + AnimationUtils.loadAnimation( + context, + if (enter) R.anim.rns_fade_in else R.anim.rns_fade_out + ) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initViewHierarchy() + return containerView + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + if (screen.sheetInitialDetentIndex <= screen.sheetLargestUndimmedDetentIndex) { + dimmingView.alpha = 0.0F + } else { + dimmingView.alpha = maxAlpha + } + } + + override fun onStart() { + // This is the earliest we can access child fragment manager & present another fragment + super.onStart() + insetsProxy.registerOnView(requireRootView()) + presentNestedFragment() + } + + override fun onResume() { + insetsProxy.addOnApplyWindowInsetsListener(this) + super.onResume() + } + + override fun onPause() { + super.onPause() + insetsProxy.removeOnApplyWindowInsetsListener(this) + } + + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event, + ) { + when (event) { + Lifecycle.Event.ON_START -> { + nestedFragment.screen.sheetBehavior?.let { + dimmingViewCallback = + AnimateDimmingViewCallback(nestedFragment.screen, dimmingView, maxAlpha) + it.addBottomSheetCallback(dimmingViewCallback!!) + } + } + + Lifecycle.Event.ON_STOP -> { + dismissSelf(emitDismissedEvent = true) + } + + else -> {} + } + } + + private fun presentNestedFragment() { + childFragmentManager.commit(allowStateLoss = true) { + setReorderingAllowed(true) + add(requireView().id, nestedFragment.fragment, null) + } + } + + private fun cleanRegisteredCallbacks() { + dimmingViewCallback?.let { + nestedFragment.screen.sheetBehavior?.removeBottomSheetCallback(it) + } + dimmingView.setOnClickListener(null) + nestedFragment.fragment.lifecycle.removeObserver(this) + insetsProxy.removeOnApplyWindowInsetsListener(this) + } + + private fun dismissSelf(emitDismissedEvent: Boolean = false) { + if (!this.isRemoving) { + if (emitDismissedEvent) { + val reactContext = nestedFragment.screen.reactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + UIManagerHelper + .getEventDispatcherForReactTag(reactContext, screen.id) + ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) + } + cleanRegisteredCallbacks() + dismissFromContainer() + } + } + + private fun initViewHierarchy() { + initContainerView() + initDimmingView() + containerView.addView(dimmingView) + } + + private fun initContainerView() { + containerView = + GestureTransparentViewGroup(requireContext()).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, + ) + setBackgroundColor(Color.TRANSPARENT) + // This is purely native view, React does not know of it, thus there should be no conflict with ids. + id = View.generateViewId() + } + } + + private fun initDimmingView() { + dimmingView = + DimmingView(requireContext(), 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) { + dismissSelf(true) + } + } + } + } + + private fun requireRootView(): View = + checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" } + .window.decorView + + // TODO: Move these methods related to toolbar to separate interface + override fun removeToolbar() = Unit + + override fun setToolbar(toolbar: Toolbar) = Unit + + override fun setToolbarShadowHidden(hidden: Boolean) = Unit + + override fun setToolbarTranslucent(translucent: Boolean) = Unit + + // Dimming view should never be bottom-most fragment + override fun canNavigateBack(): Boolean = true + + override fun dismissFromContainer() { + container?.dismiss(this) + } + + override var screen: Screen + get() = nestedFragment.screen + set(value) { + nestedFragment.screen = value + } + + override val childScreenContainers: List = nestedFragment.childScreenContainers + + override fun addChildScreenContainer(container: ScreenContainer) { + nestedFragment.addChildScreenContainer(container) + } + + override fun removeChildScreenContainer(container: ScreenContainer) { + nestedFragment.removeChildScreenContainer(container) + } + + override fun onContainerUpdate() { + nestedFragment.onContainerUpdate() + } + + override fun onViewAnimationStart() { + nestedFragment.onViewAnimationStart() + } + + override fun onViewAnimationEnd() { + nestedFragment.onViewAnimationEnd() + } + + override fun tryGetActivity(): Activity? { + return activity + } + + override fun tryGetContext(): ReactContext? { + return context as? ReactContext? + } + + override val fragment: Fragment + get() = this + + 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 onAnimationStart(animation: Animation?) = Unit + + override fun onAnimationEnd(animation: Animation?) { + dismissFromContainer() + } + + override fun onAnimationRepeat(animation: Animation?) = Unit + + companion object { + const val TAG = "DimmingFragment" + } + + // This is View.OnApplyWindowInsetsListener method, not view's own! + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + val isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + val imeInset = insets.getInsets(WindowInsetsCompat.Type.ime()) + + if (isImeVisible) { + isKeyboardVisible = true + keyboardState = KeyboardVisible(imeInset.bottom) + screen.sheetBehavior?.let { + (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( + it, + KeyboardVisible(imeInset.bottom) + ) + } + + if (this.isRemoving) { + return insets + } + + val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return WindowInsetsCompat + .Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + prevInsets.left, + prevInsets.top, + prevInsets.right, + 0, + ), + ).build() + } else { + if (this.isRemoving) { + val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return WindowInsetsCompat + .Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + prevInsets.left, + prevInsets.top, + prevInsets.right, + 0, + ), + ).build() + } + + screen.sheetBehavior?.let { + if (isKeyboardVisible) { + (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( + it, + KeyboardDidHide + ) + } else if (keyboardState != KeyboardNotVisible) { + (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( + it, + KeyboardNotVisible + ) + } else { + } + } + + keyboardState = KeyboardNotVisible + isKeyboardVisible = false + + val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return WindowInsetsCompat + .Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + prevInsets.left, + prevInsets.top, + prevInsets.right, + 0, + ), + ).build() + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt new file mode 100644 index 0000000000..4693a65670 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt @@ -0,0 +1,66 @@ +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.PointerEvents +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 not fully fills + * the viewport. + * + * This dimming view has one more additional feature: it blocks gestures if its alpha > 0. + */ +@SuppressLint("ViewConstructor") // Only we instantiate this view +class DimmingView( + context: Context, + initialAlpha: Float = 0.6F, +) : ViewGroup(context), + ReactCompoundViewGroup, + ReactPointerEventsView { + private 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 + + 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 getPointerEvents(): PointerEvents = + if (blockGestures) PointerEvents.AUTO else PointerEvents.NONE + + companion object { + const val TAG = "DimmingView" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/GestureTransparentViewGroup.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/GestureTransparentViewGroup.kt new file mode 100644 index 0000000000..dd10115adc --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/GestureTransparentViewGroup.kt @@ -0,0 +1,24 @@ +package com.swmansion.rnscreens.bottomsheet + +import android.content.Context +import android.widget.FrameLayout +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView + +/** + * View group that will be ignored by RN event system, and won't be target of touches. + * + * Currently used as container for the form sheet, so that user can interact with the view + * under the sheet (otherwise RN captures the gestures). + */ +class GestureTransparentViewGroup( + context: Context, +) : FrameLayout(context), + ReactPointerEventsView { + + override fun getPointerEvents(): PointerEvents = PointerEvents.BOX_NONE + + companion object { + const val TAG = "GestureTransparentFrameLayout" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt new file mode 100644 index 0000000000..0e31843af5 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt @@ -0,0 +1,127 @@ +package com.swmansion.rnscreens.bottomsheet + +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 + +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 + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/events/SheetDetentChangedEvent.kt b/android/src/main/java/com/swmansion/rnscreens/events/SheetDetentChangedEvent.kt new file mode 100644 index 0000000000..6fc53f941c --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/events/SheetDetentChangedEvent.kt @@ -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(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" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ext/NumericExt.kt b/android/src/main/java/com/swmansion/rnscreens/ext/NumericExt.kt new file mode 100644 index 0000000000..4f8dc46d9c --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ext/NumericExt.kt @@ -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 diff --git a/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt b/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt new file mode 100644 index 0000000000..f9258e1ed1 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt @@ -0,0 +1,32 @@ +package com.swmansion.rnscreens.ext + +import android.graphics.drawable.ColorDrawable +import android.view.View +import android.view.ViewGroup + +internal fun View.parentAsView() = this.parent as? View + +internal fun View.parentAsViewGroup() = this.parent as? ViewGroup + +internal fun View.recycle(): View { + // screen fragments reuse view instances instead of creating new ones. In order to reuse a given + // view it needs to be detached from the view hierarchy to allow the fragment to attach it back. + this.parentAsViewGroup()?.let { parent -> + parent.endViewTransition(this) + parent.removeView(this) + } + + // view detached from fragment manager get their visibility changed to GONE after their state is + // dumped. Since we don't restore the state but want to reuse the view we need to change + // visibility back to VISIBLE in order for the fragment manager to animate in the view. + this.visibility = View.VISIBLE + return this +} + +internal fun View.maybeBgColor(): Int? { + val bgDrawable = this.background + if (bgDrawable is ColorDrawable) { + return bgDrawable.color + } + return null +} diff --git a/android/src/main/res/base/drawable/rns_rounder_top_corners_shape.xml b/android/src/main/res/base/drawable/rns_rounder_top_corners_shape.xml new file mode 100644 index 0000000000..238b3c0526 --- /dev/null +++ b/android/src/main/res/base/drawable/rns_rounder_top_corners_shape.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerDelegate.java new file mode 100644 index 0000000000..047adaef01 --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerDelegate.java @@ -0,0 +1,25 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaDelegate.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; +import androidx.annotation.Nullable; +import com.facebook.react.uimanager.BaseViewManagerDelegate; +import com.facebook.react.uimanager.BaseViewManagerInterface; + +public class RNSScreenContentWrapperManagerDelegate & RNSScreenContentWrapperManagerInterface> extends BaseViewManagerDelegate { + public RNSScreenContentWrapperManagerDelegate(U viewManager) { + super(viewManager); + } + @Override + public void setProperty(T view, String propName, @Nullable Object value) { + super.setProperty(view, propName, value); + } +} diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerInterface.java new file mode 100644 index 0000000000..bd6c01d7df --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerInterface.java @@ -0,0 +1,16 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaInterface.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; + +public interface RNSScreenContentWrapperManagerInterface { + // No props +} diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerDelegate.java new file mode 100644 index 0000000000..d8e08e7dfc --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerDelegate.java @@ -0,0 +1,25 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaDelegate.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; +import androidx.annotation.Nullable; +import com.facebook.react.uimanager.BaseViewManagerDelegate; +import com.facebook.react.uimanager.BaseViewManagerInterface; + +public class RNSScreenFooterManagerDelegate & RNSScreenFooterManagerInterface> extends BaseViewManagerDelegate { + public RNSScreenFooterManagerDelegate(U viewManager) { + super(viewManager); + } + @Override + public void setProperty(T view, String propName, @Nullable Object value) { + super.setProperty(view, propName, value); + } +} diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerInterface.java new file mode 100644 index 0000000000..29d299a847 --- /dev/null +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerInterface.java @@ -0,0 +1,16 @@ +/** +* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). +* +* Do not edit this file as changes may cause incorrect behavior and will be lost +* once the code is regenerated. +* +* @generated by codegen project: GeneratePropsJavaInterface.js +*/ + +package com.facebook.react.viewmanagers; + +import android.view.View; + +public interface RNSScreenFooterManagerInterface { + // No props +} diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java index 02540fdb1e..d73de5dda3 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java @@ -12,6 +12,7 @@ import android.view.View; import androidx.annotation.Nullable; import com.facebook.react.bridge.ColorPropConverter; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.uimanager.BaseViewManagerDelegate; import com.facebook.react.uimanager.BaseViewManagerInterface; @@ -24,10 +25,10 @@ public RNSScreenManagerDelegate(U viewManager) { public void setProperty(T view, String propName, @Nullable Object value) { switch (propName) { case "sheetAllowedDetents": - mViewManager.setSheetAllowedDetents(view, (String) value); + mViewManager.setSheetAllowedDetents(view, (ReadableArray) value); break; case "sheetLargestUndimmedDetent": - mViewManager.setSheetLargestUndimmedDetent(view, (String) value); + mViewManager.setSheetLargestUndimmedDetent(view, value == null ? -1 : ((Double) value).intValue()); break; case "sheetGrabberVisible": mViewManager.setSheetGrabberVisible(view, value == null ? false : (boolean) value); @@ -38,6 +39,12 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "sheetExpandsWhenScrolledToEdge": mViewManager.setSheetExpandsWhenScrolledToEdge(view, value == null ? false : (boolean) value); break; + case "sheetInitialDetent": + mViewManager.setSheetInitialDetent(view, value == null ? 0 : ((Double) value).intValue()); + break; + case "sheetElevation": + mViewManager.setSheetElevation(view, value == null ? 24 : ((Double) value).intValue()); + break; case "customAnimationOnSwipe": mViewManager.setCustomAnimationOnSwipe(view, value == null ? false : (boolean) value); break; diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java index 832a65584f..a6931331bc 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java @@ -11,14 +11,17 @@ import android.view.View; import androidx.annotation.Nullable; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; public interface RNSScreenManagerInterface { - void setSheetAllowedDetents(T view, @Nullable String value); - void setSheetLargestUndimmedDetent(T view, @Nullable String value); + void setSheetAllowedDetents(T view, @Nullable ReadableArray value); + void setSheetLargestUndimmedDetent(T view, int value); void setSheetGrabberVisible(T view, boolean value); void setSheetCornerRadius(T view, float value); void setSheetExpandsWhenScrolledToEdge(T view, boolean value); + void setSheetInitialDetent(T view, int value); + void setSheetElevation(T view, int value); void setCustomAnimationOnSwipe(T view, boolean value); void setFullScreenSwipeEnabled(T view, boolean value); void setFullScreenSwipeShadowEnabled(T view, boolean value); diff --git a/apps/src/tests/Test1649.tsx b/apps/src/tests/Test1649.tsx deleted file mode 100644 index 2a74361694..0000000000 --- a/apps/src/tests/Test1649.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import * as React from 'react'; -import { Button, StyleSheet, View, Text, ScrollView } from 'react-native'; -import { NavigationContainer, ParamListBase } from '@react-navigation/native'; -import { - createNativeStackNavigator, - NativeStackNavigationProp, - NativeStackNavigationOptions, -} from '@react-navigation/native-stack'; -import { SheetDetentTypes } from 'react-native-screens'; - -const Stack = createNativeStackNavigator(); - -export default function App(): JSX.Element { - const initialScreenOptions: NativeStackNavigationOptions = { - presentation: 'formSheet', - sheetAllowedDetents: 'all', - sheetLargestUndimmedDetent: 'medium', - sheetGrabberVisible: false, - sheetCornerRadius: -1, - sheetExpandsWhenScrolledToEdge: true, - }; - - return ( - - , - headerTitleStyle: { - color: 'cyan', - }, - headerShown: true, - headerBackVisible: false, - }}> - - - - - - - ); -} - -function First({ - navigation, -}: { - navigation: NativeStackNavigationProp; -}) { - return ( -