Skip to content

Commit

Permalink
feat!: iOS custom detents & Android form sheets (software-mansion#2045)
Browse files Browse the repository at this point in the history
## Description

This PR introduces series of features & changes:

1. possibility of specifying custom detents for form sheets on devices
with iOS 16 or newer,
2. changes existing form sheet API of `Screen` component (namely types
of values accepted),
3. Android form sheets (bottom sheets presented in current presentation
context (in iOS terms) with dimming view with configurable interaction.
The form sheet supports up to three detent levels with additional option
of `fitToContents`
4. Android Footer component that works together with `formSheet`
presentation style
5. 🚧 Android modal bottom sheet - similar to `formSheet`, however the
sheet is mounted under separate window.
6. 🚧 iOS Footer component - similar to Android
7. Usage of Material 3
8. series of new props allowing for: 
a. controlling style of the `Screen` component (necessary workaround for
issue with flickering on iOS,
b. controlling whether the screen fragment of particular screen should
be unmounted or not on Android when the screen is on JS stack but not
visible (necessary to achieve "staying form sheet" when navigating back
to a screen with presented form sheet),
c. listening for `sheetDetentChange` events, in case of Android stable &
dragging states are reported, in case of iOS only stable states
  d. todo: describe rest

## Changes


## Known issues

1. [x] ~After recent commits - iOS compilation on Fabric~
2. [ ] Android: issue with nested scrollview - invalid behaviour when
there is not enough content for scrollview to scroll (viewport is >=
content size). Solvable by patching react-native:
facebook/react-native#44099, no other workaround
found. There is one approach [suggested by
grahammendick](https://github.com/grahammendick/navigation/blob/916688d267bd3fc520e2e22328b6aa66124f52ed/NavigationReactNative/src/android/src/main/java/com/navigation/reactnative/CoordinatorLayoutView.java#L96-L148),
however yet untested.
3. [ ] Android 'modal' presentation can crash randomly (unknown reason
yet, can be deffered)

## Test code and steps to reproduce

I've used & extended `Test1649` to present all capabilities of new API.

## Checklist

- [ ] Included code example that can be used to test this change
- [ ] Updated TS types
- [ ] Updated documentation: <!-- For adding new props to native-stack
-->
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx
- [ ] Ensured that CI passes
  • Loading branch information
kkafar authored and ja1ns committed Oct 9, 2024
1 parent 1907c02 commit 6fc63a0
Show file tree
Hide file tree
Showing 78 changed files with 4,363 additions and 517 deletions.
1 change: 1 addition & 0 deletions Example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions Example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions FabricExample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions FabricExample/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OnApplyWindowInsetsListener> = arrayListOf()
private var eventSourceView: WeakReference<View> = WeakReference(null)

// Please note semantics of this property. This is not `isRegistered`, because somebody, could unregister
// us, without our knowledge, e.g. reanimated or different 3rd party library. This holds only information
// whether this observer has been initially registered.
private var hasBeenRegistered: Boolean = false

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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class RNScreensPackage : TurboReactPackage() {
ScreenStackHeaderConfigViewManager(),
ScreenStackHeaderSubviewManager(),
SearchBarManager(),
ScreenFooterManager(),
ScreenContentWrapperManager(),
)
}

Expand Down
105 changes: 101 additions & 4 deletions android/src/main/java/com/swmansion/rnscreens/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,33 @@ 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
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<Screen>?
get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<Screen>

val reactEventDispatcher: EventDispatcher?
get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)

var fragmentWrapper: ScreenFragmentWrapper? = null
var container: ScreenContainer? = null
var activityState: ActivityState? = null
Expand All @@ -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
Expand All @@ -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<Parcelable>) {
// do nothing, react native will keep the view hierarchy so no need to serialize/deserialize
// view's states. The side effect of restoring is that TextInput components would trigger
Expand Down Expand Up @@ -84,6 +149,7 @@ class Screen(
updateScreenSizePaper(width, height)
}

footer?.onParentLayout(changed, l, t, r, b, container!!.height)
notifyHeaderHeightChange(totalHeight)
}
}
Expand All @@ -92,7 +158,6 @@ class Screen(
width: Int,
height: Int,
) {
val reactContext = context as ReactContext
reactContext.runOnNativeModulesQueueThread(
object : GuardedRunnable(reactContext.exceptionHandler) {
override fun runGuarded() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 6fc63a0

Please sign in to comment.