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