react-native-screens 3.35.0-rc.1 → 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/android/build.gradle +2 -2
  2. package/android/src/main/java/com/swmansion/rnscreens/InsetsObserverProxy.kt +67 -0
  3. package/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +2 -0
  4. package/android/src/main/java/com/swmansion/rnscreens/Screen.kt +101 -4
  5. package/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt +38 -0
  6. package/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapperManager.kt +25 -0
  7. package/android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt +287 -0
  8. package/android/src/main/java/com/swmansion/rnscreens/ScreenFooterManager.kt +25 -0
  9. package/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +11 -19
  10. package/android/src/main/java/com/swmansion/rnscreens/ScreenFragmentWrapper.kt +4 -0
  11. package/android/src/main/java/com/swmansion/rnscreens/ScreenModalFragment.kt +281 -0
  12. package/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +62 -19
  13. package/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +403 -41
  14. package/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragmentWrapper.kt +4 -1
  15. package/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +2 -2
  16. package/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt +95 -11
  17. package/android/src/main/java/com/swmansion/rnscreens/ScreenWindowTraits.kt +39 -28
  18. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogRootView.kt +104 -0
  19. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetDialogScreen.kt +26 -0
  20. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt +488 -0
  21. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt +66 -0
  22. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/GestureTransparentViewGroup.kt +24 -0
  23. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt +127 -0
  24. package/android/src/main/java/com/swmansion/rnscreens/events/SheetDetentChangedEvent.kt +27 -0
  25. package/android/src/main/java/com/swmansion/rnscreens/ext/NumericExt.kt +12 -0
  26. package/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt +32 -0
  27. package/android/src/main/res/base/drawable/rns_rounder_top_corners_shape.xml +8 -0
  28. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerDelegate.java +25 -0
  29. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenContentWrapperManagerInterface.java +16 -0
  30. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerDelegate.java +25 -0
  31. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenFooterManagerInterface.java +16 -0
  32. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java +9 -2
  33. package/android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerInterface.java +5 -2
  34. package/ios/RNSConvert.h +5 -3
  35. package/ios/RNSConvert.mm +14 -20
  36. package/ios/RNSScreen.h +3 -2
  37. package/ios/RNSScreen.mm +433 -49
  38. package/ios/RNSScreenContentWrapper.h +44 -0
  39. package/ios/RNSScreenContentWrapper.mm +61 -0
  40. package/ios/RNSScreenFooter.h +30 -0
  41. package/ios/RNSScreenFooter.mm +137 -0
  42. package/lib/commonjs/components/Screen.js +6 -2
  43. package/lib/commonjs/components/Screen.js.map +1 -1
  44. package/lib/commonjs/components/ScreenContentWrapper.js +19 -0
  45. package/lib/commonjs/components/ScreenContentWrapper.js.map +1 -0
  46. package/lib/commonjs/components/ScreenFooter.js +23 -0
  47. package/lib/commonjs/components/ScreenFooter.js.map +1 -0
  48. package/lib/commonjs/fabric/ModalScreenNativeComponent.js.map +1 -1
  49. package/lib/commonjs/fabric/ScreenContentWrapperNativeComponent.js +10 -0
  50. package/lib/commonjs/fabric/ScreenContentWrapperNativeComponent.js.map +1 -0
  51. package/lib/commonjs/fabric/ScreenFooterNativeComponent.js +10 -0
  52. package/lib/commonjs/fabric/ScreenFooterNativeComponent.js.map +1 -0
  53. package/lib/commonjs/fabric/ScreenNativeComponent.js.map +1 -1
  54. package/lib/commonjs/index.js +30 -0
  55. package/lib/commonjs/index.js.map +1 -1
  56. package/lib/commonjs/native-stack/views/FooterComponent.js +18 -0
  57. package/lib/commonjs/native-stack/views/FooterComponent.js.map +1 -0
  58. package/lib/commonjs/native-stack/views/NativeStackView.js +59 -14
  59. package/lib/commonjs/native-stack/views/NativeStackView.js.map +1 -1
  60. package/lib/module/components/Screen.js +6 -2
  61. package/lib/module/components/Screen.js.map +1 -1
  62. package/lib/module/components/ScreenContentWrapper.js +12 -0
  63. package/lib/module/components/ScreenContentWrapper.js.map +1 -0
  64. package/lib/module/components/ScreenFooter.js +17 -0
  65. package/lib/module/components/ScreenFooter.js.map +1 -0
  66. package/lib/module/fabric/ModalScreenNativeComponent.js.map +1 -1
  67. package/lib/module/fabric/ScreenContentWrapperNativeComponent.js +3 -0
  68. package/lib/module/fabric/ScreenContentWrapperNativeComponent.js.map +1 -0
  69. package/lib/module/fabric/ScreenFooterNativeComponent.js +3 -0
  70. package/lib/module/fabric/ScreenFooterNativeComponent.js.map +1 -0
  71. package/lib/module/fabric/ScreenNativeComponent.js.map +1 -1
  72. package/lib/module/index.js +2 -0
  73. package/lib/module/index.js.map +1 -1
  74. package/lib/module/native-stack/views/FooterComponent.js +11 -0
  75. package/lib/module/native-stack/views/FooterComponent.js.map +1 -0
  76. package/lib/module/native-stack/views/NativeStackView.js +61 -15
  77. package/lib/module/native-stack/views/NativeStackView.js.map +1 -1
  78. package/lib/typescript/components/Screen.d.ts.map +1 -1
  79. package/lib/typescript/components/ScreenContentWrapper.d.ts +6 -0
  80. package/lib/typescript/components/ScreenContentWrapper.d.ts.map +1 -0
  81. package/lib/typescript/components/ScreenFooter.d.ts +12 -0
  82. package/lib/typescript/components/ScreenFooter.d.ts.map +1 -0
  83. package/lib/typescript/fabric/ModalScreenNativeComponent.d.ts +2 -3
  84. package/lib/typescript/fabric/ModalScreenNativeComponent.d.ts.map +1 -1
  85. package/lib/typescript/fabric/ScreenContentWrapperNativeComponent.d.ts +7 -0
  86. package/lib/typescript/fabric/ScreenContentWrapperNativeComponent.d.ts.map +1 -0
  87. package/lib/typescript/fabric/ScreenFooterNativeComponent.d.ts +7 -0
  88. package/lib/typescript/fabric/ScreenFooterNativeComponent.d.ts.map +1 -0
  89. package/lib/typescript/fabric/ScreenNativeComponent.d.ts +9 -3
  90. package/lib/typescript/fabric/ScreenNativeComponent.d.ts.map +1 -1
  91. package/lib/typescript/index.d.ts +2 -0
  92. package/lib/typescript/index.d.ts.map +1 -1
  93. package/lib/typescript/native-stack/types.d.ts +63 -23
  94. package/lib/typescript/native-stack/types.d.ts.map +1 -1
  95. package/lib/typescript/native-stack/views/FooterComponent.d.ts +7 -0
  96. package/lib/typescript/native-stack/views/FooterComponent.d.ts.map +1 -0
  97. package/lib/typescript/native-stack/views/NativeStackView.d.ts.map +1 -1
  98. package/lib/typescript/types.d.ts +42 -17
  99. package/lib/typescript/types.d.ts.map +1 -1
  100. package/native-stack/README.md +16 -14
  101. package/package.json +1 -1
  102. package/react-native.config.js +18 -16
  103. package/src/components/Screen.tsx +6 -2
  104. package/src/components/ScreenContentWrapper.tsx +12 -0
  105. package/src/components/ScreenFooter.tsx +18 -0
  106. package/src/fabric/ModalScreenNativeComponent.ts +2 -4
  107. package/src/fabric/ScreenContentWrapperNativeComponent.ts +9 -0
  108. package/src/fabric/ScreenFooterNativeComponent.ts +6 -0
  109. package/src/fabric/ScreenNativeComponent.ts +10 -4
  110. package/src/index.tsx +10 -0
  111. package/src/native-stack/types.tsx +57 -23
  112. package/src/native-stack/views/FooterComponent.tsx +10 -0
  113. package/src/native-stack/views/NativeStackView.tsx +74 -11
  114. package/src/types.tsx +41 -16
@@ -159,8 +159,8 @@ repositories {
159
159
 
160
160
  dependencies {
161
161
  implementation 'com.facebook.react:react-native:+'
162
- implementation 'androidx.appcompat:appcompat:1.4.2'
163
- implementation 'androidx.fragment:fragment:1.3.6'
162
+ implementation 'androidx.appcompat:appcompat:1.6.1'
163
+ implementation 'androidx.fragment:fragment-ktx:1.6.1'
164
164
  implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
165
165
  implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
166
166
  implementation 'com.google.android.material:material:1.6.1'
@@ -0,0 +1,67 @@
1
+ package com.swmansion.rnscreens
2
+
3
+ import android.view.View
4
+ import androidx.core.view.OnApplyWindowInsetsListener
5
+ import androidx.core.view.ViewCompat
6
+ import androidx.core.view.WindowInsetsCompat
7
+ import java.lang.ref.WeakReference
8
+
9
+ object InsetsObserverProxy : OnApplyWindowInsetsListener {
10
+ private val listeners: ArrayList<OnApplyWindowInsetsListener> = arrayListOf()
11
+ private var eventSourceView: WeakReference<View> = WeakReference(null)
12
+
13
+ // Please note semantics of this property. This is not `isRegistered`, because somebody, could unregister
14
+ // us, without our knowledge, e.g. reanimated or different 3rd party library. This holds only information
15
+ // whether this observer has been initially registered.
16
+ private var hasBeenRegistered: Boolean = false
17
+
18
+ private var shouldForwardInsetsToView = true
19
+
20
+ override fun onApplyWindowInsets(
21
+ v: View,
22
+ insets: WindowInsetsCompat,
23
+ ): WindowInsetsCompat {
24
+ var rollingInsets =
25
+ if (shouldForwardInsetsToView) {
26
+ WindowInsetsCompat.toWindowInsetsCompat(
27
+ v.onApplyWindowInsets(insets.toWindowInsets()),
28
+ v,
29
+ )
30
+ } else {
31
+ insets
32
+ }
33
+
34
+ listeners.forEach {
35
+ rollingInsets = it.onApplyWindowInsets(v, insets)
36
+ }
37
+ return rollingInsets
38
+ }
39
+
40
+ fun addOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) {
41
+ listeners.add(listener)
42
+ }
43
+
44
+ fun removeOnApplyWindowInsetsListener(listener: OnApplyWindowInsetsListener) {
45
+ listeners.remove(listener)
46
+ }
47
+
48
+ fun registerOnView(view: View) {
49
+ if (!hasBeenRegistered) {
50
+ ViewCompat.setOnApplyWindowInsetsListener(view, this)
51
+ eventSourceView = WeakReference(view)
52
+ hasBeenRegistered = true
53
+ } else if (getObservedView() != view) {
54
+ throw IllegalStateException(
55
+ "[RNScreens] Attempt to register InsetsObserverProxy on $view while it has been already registered on ${getObservedView()}",
56
+ )
57
+ }
58
+ }
59
+
60
+ fun unregister() {
61
+ eventSourceView.get()?.takeIf { hasBeenRegistered }?.let {
62
+ ViewCompat.setOnApplyWindowInsetsListener(it, null)
63
+ }
64
+ }
65
+
66
+ private fun getObservedView(): View? = eventSourceView.get()
67
+ }
@@ -37,6 +37,8 @@ class RNScreensPackage : TurboReactPackage() {
37
37
  ScreenStackHeaderConfigViewManager(),
38
38
  ScreenStackHeaderSubviewManager(),
39
39
  SearchBarManager(),
40
+ ScreenFooterManager(),
41
+ ScreenContentWrapperManager(),
40
42
  )
41
43
  }
42
44
 
@@ -10,6 +10,7 @@ import android.view.View
10
10
  import android.view.ViewGroup
11
11
  import android.view.WindowManager
12
12
  import android.webkit.WebView
13
+ import androidx.coordinatorlayout.widget.CoordinatorLayout
13
14
  import androidx.core.view.children
14
15
  import androidx.fragment.app.Fragment
15
16
  import com.facebook.react.bridge.GuardedRunnable
@@ -17,15 +18,25 @@ import com.facebook.react.bridge.ReactContext
17
18
  import com.facebook.react.uimanager.PixelUtil
18
19
  import com.facebook.react.uimanager.UIManagerHelper
19
20
  import com.facebook.react.uimanager.UIManagerModule
21
+ import com.facebook.react.uimanager.events.EventDispatcher
22
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
20
23
  import com.swmansion.rnscreens.events.HeaderHeightChangeEvent
24
+ import com.swmansion.rnscreens.events.SheetDetentChangedEvent
21
25
 
22
26
  @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
23
27
  class Screen(
24
- context: ReactContext?,
25
- ) : FabricEnabledViewGroup(context) {
28
+ val reactContext: ReactContext,
29
+ ) : FabricEnabledViewGroup(reactContext),
30
+ ScreenContentWrapper.OnLayoutCallback {
26
31
  val fragment: Fragment?
27
32
  get() = fragmentWrapper?.fragment
28
33
 
34
+ val sheetBehavior: BottomSheetBehavior<Screen>?
35
+ get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<Screen>
36
+
37
+ val reactEventDispatcher: EventDispatcher?
38
+ get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
39
+
29
40
  var fragmentWrapper: ScreenFragmentWrapper? = null
30
41
  var container: ScreenContainer? = null
31
42
  var activityState: ActivityState? = null
@@ -40,6 +51,33 @@ class Screen(
40
51
  var isStatusBarAnimated: Boolean? = null
41
52
  var isBeingRemoved = false
42
53
 
54
+ // Props for controlling modal presentation
55
+ var isSheetGrabberVisible: Boolean = false
56
+ var sheetCornerRadius: Float = 0F
57
+ set(value) {
58
+ field = value
59
+ (fragment as? ScreenStackFragment)?.onSheetCornerRadiusChange()
60
+ }
61
+ var sheetExpandsWhenScrolledToEdge: Boolean = true
62
+
63
+ // We want to make sure here that at least one value is present in this array all the time.
64
+ // TODO: Model this with custom data structure to guarantee that this invariant is not violated.
65
+ var sheetDetents = mutableListOf(1.0)
66
+ var sheetLargestUndimmedDetentIndex: Int = -1
67
+ var sheetInitialDetentIndex: Int = 0
68
+ var sheetClosesOnTouchOutside = true
69
+ var sheetElevation: Float = 24F
70
+
71
+ var footer: ScreenFooter? = null
72
+ set(value) {
73
+ if (value == null && field != null) {
74
+ sheetBehavior?.let { field!!.unregisterWithSheetBehavior(it) }
75
+ } else if (value != null) {
76
+ sheetBehavior?.let { value.registerWithSheetBehavior(it) }
77
+ }
78
+ field = value
79
+ }
80
+
43
81
  init {
44
82
  // we set layout params as WindowManager.LayoutParams to workaround the issue with TextInputs
45
83
  // not displaying modal menus (e.g., copy/paste or selection). The missing menus are due to the
@@ -54,6 +92,33 @@ class Screen(
54
92
  layoutParams = WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION)
55
93
  }
56
94
 
95
+ /**
96
+ * ScreenContentWrapper notifies us here on it's layout. It is essential for implementing
97
+ * `fitToContents` for formSheets, as this is first entry point where we can acquire
98
+ * height of our content.
99
+ */
100
+ override fun onLayoutCallback(
101
+ changed: Boolean,
102
+ left: Int,
103
+ top: Int,
104
+ right: Int,
105
+ bottom: Int,
106
+ ) {
107
+ val height = bottom - top
108
+
109
+ if (sheetDetents.count() == 1 && sheetDetents.first() == SHEET_FIT_TO_CONTENTS) {
110
+ sheetBehavior?.let {
111
+ if (it.maxHeight != height) {
112
+ it.maxHeight = height
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ fun registerLayoutCallbackForWrapper(wrapper: ScreenContentWrapper) {
119
+ wrapper.delegate = this
120
+ }
121
+
57
122
  override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) {
58
123
  // do nothing, react native will keep the view hierarchy so no need to serialize/deserialize
59
124
  // view's states. The side effect of restoring is that TextInput components would trigger
@@ -84,6 +149,7 @@ class Screen(
84
149
  updateScreenSizePaper(width, height)
85
150
  }
86
151
 
152
+ footer?.onParentLayout(changed, l, t, r, b, container!!.height)
87
153
  notifyHeaderHeightChange(totalHeight)
88
154
  }
89
155
  }
@@ -92,7 +158,6 @@ class Screen(
92
158
  width: Int,
93
159
  height: Int,
94
160
  ) {
95
- val reactContext = context as ReactContext
96
161
  reactContext.runOnNativeModulesQueueThread(
97
162
  object : GuardedRunnable(reactContext.exceptionHandler) {
98
163
  override fun runGuarded() {
@@ -127,7 +192,14 @@ class Screen(
127
192
  )
128
193
  }
129
194
 
130
- fun isTransparent(): Boolean = stackPresentation === StackPresentation.TRANSPARENT_MODAL
195
+ fun isTransparent(): Boolean =
196
+ when (stackPresentation) {
197
+ StackPresentation.TRANSPARENT_MODAL,
198
+ StackPresentation.FORM_SHEET,
199
+ -> true
200
+
201
+ else -> false
202
+ }
131
203
 
132
204
  private fun hasWebView(viewGroup: ViewGroup): Boolean {
133
205
  for (i in 0 until viewGroup.childCount) {
@@ -351,10 +423,26 @@ class Screen(
351
423
  ?.dispatchEvent(HeaderHeightChangeEvent(surfaceId, id, headerHeight))
352
424
  }
353
425
 
426
+ internal fun notifySheetDetentChange(
427
+ detentIndex: Int,
428
+ isStable: Boolean,
429
+ ) {
430
+ val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
431
+ reactEventDispatcher?.dispatchEvent(
432
+ SheetDetentChangedEvent(
433
+ surfaceId,
434
+ id,
435
+ detentIndex,
436
+ isStable,
437
+ ),
438
+ )
439
+ }
440
+
354
441
  enum class StackPresentation {
355
442
  PUSH,
356
443
  MODAL,
357
444
  TRANSPARENT_MODAL,
445
+ FORM_SHEET,
358
446
  }
359
447
 
360
448
  enum class StackAnimation {
@@ -390,4 +478,13 @@ class Screen(
390
478
  NAVIGATION_BAR_TRANSLUCENT,
391
479
  NAVIGATION_BAR_HIDDEN,
392
480
  }
481
+
482
+ companion object {
483
+ const val TAG = "Screen"
484
+
485
+ /**
486
+ * This value describes value in sheet detents array that will be treated as `fitToContents` option.
487
+ */
488
+ const val SHEET_FIT_TO_CONTENTS = -1.0
489
+ }
393
490
  }
@@ -0,0 +1,38 @@
1
+ package com.swmansion.rnscreens
2
+
3
+ import android.annotation.SuppressLint
4
+ import com.facebook.react.bridge.ReactContext
5
+ import com.facebook.react.views.view.ReactViewGroup
6
+
7
+ /**
8
+ * When we wrap children of the Screen component inside this component in JS code,
9
+ * we can later use it to get the enclosing frame size of our content as it is rendered by RN.
10
+ *
11
+ * This is useful when adapting form sheet height to its contents height.
12
+ */
13
+ @SuppressLint("ViewConstructor")
14
+ class ScreenContentWrapper(
15
+ reactContext: ReactContext,
16
+ ) : ReactViewGroup(reactContext) {
17
+ internal var delegate: OnLayoutCallback? = null
18
+
19
+ interface OnLayoutCallback {
20
+ fun onLayoutCallback(
21
+ changed: Boolean,
22
+ left: Int,
23
+ top: Int,
24
+ right: Int,
25
+ bottom: Int,
26
+ )
27
+ }
28
+
29
+ override fun onLayout(
30
+ changed: Boolean,
31
+ left: Int,
32
+ top: Int,
33
+ right: Int,
34
+ bottom: Int,
35
+ ) {
36
+ delegate?.onLayoutCallback(changed, left, top, right, bottom)
37
+ }
38
+ }
@@ -0,0 +1,25 @@
1
+ package com.swmansion.rnscreens
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+ import com.facebook.react.uimanager.ViewGroupManager
6
+ import com.facebook.react.uimanager.ViewManagerDelegate
7
+ import com.facebook.react.viewmanagers.RNSScreenContentWrapperManagerDelegate
8
+ import com.facebook.react.viewmanagers.RNSScreenContentWrapperManagerInterface
9
+
10
+ @ReactModule(name = ScreenContentWrapperManager.REACT_CLASS)
11
+ class ScreenContentWrapperManager :
12
+ ViewGroupManager<ScreenContentWrapper>(),
13
+ RNSScreenContentWrapperManagerInterface<ScreenContentWrapper> {
14
+ private val delegate: ViewManagerDelegate<ScreenContentWrapper> = RNSScreenContentWrapperManagerDelegate(this)
15
+
16
+ companion object {
17
+ const val REACT_CLASS = "RNSScreenContentWrapper"
18
+ }
19
+
20
+ override fun getName(): String = REACT_CLASS
21
+
22
+ override fun createViewInstance(reactContext: ThemedReactContext): ScreenContentWrapper = ScreenContentWrapper(reactContext)
23
+
24
+ override fun getDelegate(): ViewManagerDelegate<ScreenContentWrapper> = delegate
25
+ }
@@ -0,0 +1,287 @@
1
+ package com.swmansion.rnscreens
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.view.View
5
+ import androidx.core.view.ViewCompat
6
+ import androidx.core.view.WindowInsetsAnimationCompat
7
+ import androidx.core.view.WindowInsetsCompat
8
+ import com.facebook.react.bridge.ReactContext
9
+ import com.facebook.react.views.view.ReactViewGroup
10
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
11
+ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
12
+ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
13
+ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
14
+ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
15
+ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
16
+ import com.google.android.material.math.MathUtils
17
+ import com.swmansion.rnscreens.bottomsheet.SheetUtils
18
+ import kotlin.math.max
19
+
20
+ @SuppressLint("ViewConstructor")
21
+ class ScreenFooter(
22
+ val reactContext: ReactContext,
23
+ ) : ReactViewGroup(reactContext) {
24
+ private var lastContainerHeight: Int = 0
25
+ private var lastStableSheetState: Int = STATE_HIDDEN
26
+ private var isAnimationControlledByKeyboard = false
27
+ private var lastSlideOffset = 0.0f
28
+ private var lastBottomInset = 0
29
+ private var isCallbackRegistered = false
30
+
31
+ // ScreenFooter is supposed to be direct child of Screen
32
+ private val screenParent
33
+ get() = parent as? Screen
34
+
35
+ private val sheetBehavior
36
+ get() = requireScreenParent().sheetBehavior
37
+
38
+
39
+ // Due to Android restrictions on layout flow, particularly
40
+ // the fact that onMeasure must set `measuredHeight` & `measuredWidth` React calls `measure` on every
41
+ // view group with accurate dimensions computed by Yoga. This is our entry point to get current view dimensions.
42
+ private val reactHeight
43
+ get() = measuredHeight
44
+
45
+ private val reactWidth
46
+ get() = measuredWidth
47
+
48
+ // Main goal of this callback implementation is to handle keyboard appearance. We use it to make sure
49
+ // that the footer respects keyboard during layout.
50
+ // Note `DISPATCH_MODE_STOP` is used here to avoid propagation of insets callback to footer subtree.
51
+ private val insetsAnimation =
52
+ object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
53
+ override fun onStart(
54
+ animation: WindowInsetsAnimationCompat,
55
+ bounds: WindowInsetsAnimationCompat.BoundsCompat,
56
+ ): WindowInsetsAnimationCompat.BoundsCompat {
57
+ isAnimationControlledByKeyboard = true
58
+ return super.onStart(animation, bounds)
59
+ }
60
+
61
+ override fun onProgress(
62
+ insets: WindowInsetsCompat,
63
+ runningAnimations: MutableList<WindowInsetsAnimationCompat>,
64
+ ): WindowInsetsCompat {
65
+ val imeBottomInset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
66
+ val navigationBarBottomInset =
67
+ insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
68
+
69
+ // **It looks like** when keyboard is presented its inset does include navigation bar
70
+ // bottom inset, while it is already accounted for somewhere (dunno where).
71
+ // That is why we subtract navigation bar bottom inset here.
72
+ //
73
+ // Situations where keyboard is not visible and navigation bar is present are handled
74
+ // directly in layout function by not allowing lastBottomInset to contribute value less
75
+ // than 0. Alternative would be write logic specific to keyboard animation direction (hide / show).
76
+ lastBottomInset = imeBottomInset - navigationBarBottomInset
77
+ layoutFooterOnYAxis(
78
+ lastContainerHeight,
79
+ reactHeight,
80
+ sheetTopWhileDragging(lastSlideOffset),
81
+ lastBottomInset,
82
+ )
83
+
84
+ // Please note that we do *not* consume any insets here, so that we do not interfere with
85
+ // any other view.
86
+ return insets
87
+ }
88
+
89
+ override fun onEnd(animation: WindowInsetsAnimationCompat) {
90
+ isAnimationControlledByKeyboard = false
91
+ }
92
+ }
93
+
94
+ init {
95
+ val rootView = checkNotNull(reactContext.currentActivity) {
96
+ "[RNScreens] Context detached from activity while creating ScreenFooter"
97
+ }.window.decorView
98
+
99
+ // Note that we do override insets animation on given view. I can see it interfering e.g.
100
+ // with reanimated keyboard or even other places in our code. Need to test this.
101
+ ViewCompat.setWindowInsetsAnimationCallback(rootView, insetsAnimation)
102
+ }
103
+
104
+ private fun requireScreenParent(): Screen = requireNotNull(screenParent)
105
+
106
+ private fun requireSheetBehavior(): BottomSheetBehavior<Screen> = requireNotNull(sheetBehavior)
107
+
108
+ // React calls `layout` function to set view dimensions, thus this is our entry point for
109
+ // fixing layout up after Yoga repositions it.
110
+ override fun onLayout(
111
+ changed: Boolean,
112
+ left: Int,
113
+ top: Int,
114
+ right: Int,
115
+ bottom: Int,
116
+ ) {
117
+ super.onLayout(changed, left, top, right, bottom)
118
+ layoutFooterOnYAxis(
119
+ lastContainerHeight,
120
+ bottom - top,
121
+ sheetTopInStableState(requireSheetBehavior().state),
122
+ lastBottomInset,
123
+ )
124
+ }
125
+
126
+ private var footerCallback =
127
+ object : BottomSheetCallback() {
128
+ override fun onStateChanged(
129
+ bottomSheet: View,
130
+ newState: Int,
131
+ ) {
132
+ if (!SheetUtils.isStateStable(newState)) {
133
+ return
134
+ }
135
+
136
+ when (newState) {
137
+ STATE_COLLAPSED,
138
+ STATE_HALF_EXPANDED,
139
+ STATE_EXPANDED,
140
+ ->
141
+ layoutFooterOnYAxis(
142
+ lastContainerHeight,
143
+ reactHeight,
144
+ sheetTopInStableState(newState),
145
+ lastBottomInset,
146
+ )
147
+
148
+ else -> {}
149
+ }
150
+ lastStableSheetState = newState
151
+ }
152
+
153
+ override fun onSlide(
154
+ bottomSheet: View,
155
+ slideOffset: Float,
156
+ ) {
157
+ lastSlideOffset = max(slideOffset, 0.0f)
158
+ if (!isAnimationControlledByKeyboard) {
159
+ layoutFooterOnYAxis(
160
+ lastContainerHeight,
161
+ reactHeight,
162
+ sheetTopWhileDragging(lastSlideOffset),
163
+ lastBottomInset,
164
+ )
165
+ }
166
+ }
167
+ }
168
+
169
+ // Important to keep this method idempotent! We attempt to (un)register
170
+ // our callback in different places depending on whether the behavior is already created.
171
+ fun registerWithSheetBehavior(behavior: BottomSheetBehavior<Screen>) {
172
+ if (!isCallbackRegistered) {
173
+ behavior.addBottomSheetCallback(footerCallback)
174
+ isCallbackRegistered = true
175
+ }
176
+ }
177
+
178
+ // Important to keep this method idempotent! We attempt to (un)register
179
+ // our callback in different places depending on whether the behavior is already created.
180
+ fun unregisterWithSheetBehavior(behavior: BottomSheetBehavior<Screen>) {
181
+ if (isCallbackRegistered) {
182
+ behavior.removeBottomSheetCallback(footerCallback)
183
+ isCallbackRegistered = false
184
+ }
185
+ }
186
+
187
+ override fun onAttachedToWindow() {
188
+ super.onAttachedToWindow()
189
+ sheetBehavior?.let { registerWithSheetBehavior(it) }
190
+ }
191
+
192
+ override fun onDetachedFromWindow() {
193
+ super.onDetachedFromWindow()
194
+ sheetBehavior?.let { unregisterWithSheetBehavior(it) }
195
+ }
196
+
197
+ /**
198
+ * Calculate position of sheet's top while it is in stable state given concrete sheet state.
199
+ *
200
+ * This method should not be used for sheet in unstable state.
201
+ *
202
+ * @param state sheet state as defined in [BottomSheetBehavior]
203
+ * @return position of sheet's top **relative to container**
204
+ */
205
+ private fun sheetTopInStableState(state: Int): Int {
206
+ val behavior = requireSheetBehavior()
207
+ return when (state) {
208
+ STATE_COLLAPSED -> lastContainerHeight - behavior.peekHeight
209
+ STATE_HALF_EXPANDED -> (lastContainerHeight * (1 - behavior.halfExpandedRatio)).toInt()
210
+ STATE_EXPANDED -> behavior.expandedOffset
211
+ STATE_HIDDEN -> lastContainerHeight
212
+ else -> throw IllegalArgumentException("[RNScreens] use of stable-state method for unstable state")
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Calculate position of sheet's top while it is in dragging / settling state given concrete slide offset
218
+ * as reported by [BottomSheetCallback.onSlide].
219
+ *
220
+ * This method should not be used for sheet in stable state.
221
+ *
222
+ * @param slideOffset sheet offset as reported by [BottomSheetCallback.onSlide]
223
+ * @return position of sheet's top **relative to container**
224
+ */
225
+ private fun sheetTopWhileDragging(slideOffset: Float): Int =
226
+ MathUtils
227
+ .lerp(
228
+ sheetTopInStableState(STATE_COLLAPSED).toFloat(),
229
+ sheetTopInStableState(
230
+ STATE_EXPANDED,
231
+ ).toFloat(),
232
+ slideOffset,
233
+ ).toInt()
234
+
235
+ /**
236
+ * Parent Screen will call this on it's layout. We need to be notified on any update to Screen's content
237
+ * or its container dimensions change. This is also our entrypoint to acquiring container height.
238
+ */
239
+ fun onParentLayout(
240
+ changed: Boolean,
241
+ left: Int,
242
+ top: Int,
243
+ right: Int,
244
+ bottom: Int,
245
+ containerHeight: Int,
246
+ ) {
247
+ lastContainerHeight = containerHeight
248
+ layoutFooterOnYAxis(
249
+ containerHeight,
250
+ reactHeight,
251
+ sheetTopInStableState(requireSheetBehavior().state),
252
+ )
253
+ }
254
+
255
+ /**
256
+ * Layouts this component within parent screen. It takes care only of vertical axis, leaving
257
+ * horizontal axis solely for React to handle.
258
+ *
259
+ * This is a bit against Android rules, that parents should layout their children,
260
+ * however I wanted to keep this logic away from Screen component to avoid introducing
261
+ * complexity there and have footer logic as much separated as it is possible.
262
+ *
263
+ * Please note that React has no clue about updates enforced in below method.
264
+ *
265
+ * @param containerHeight this should be the height of the screen (sheet) container used
266
+ * to calculate sheet properties when configuring behavior (pixels)
267
+ * @param footerHeight summarized height of this component children (pixels)
268
+ * @param sheetTop current bottom sheet top (Screen top) **relative to container** (pixels)
269
+ * @param bottomInset current bottom inset, used to offset the footer by keyboard height (pixels)
270
+ */
271
+ fun layoutFooterOnYAxis(
272
+ containerHeight: Int,
273
+ footerHeight: Int,
274
+ sheetTop: Int,
275
+ bottomInset: Int = 0,
276
+ ) {
277
+ // max(bottomInset, 0) is just a hack to avoid double offset of navigation bar.
278
+ val newTop = containerHeight - footerHeight - sheetTop - max(bottomInset, 0)
279
+ val heightBeforeUpdate = reactHeight
280
+ this.top = max(newTop, 0)
281
+ this.bottom = this.top + heightBeforeUpdate
282
+ }
283
+
284
+ companion object {
285
+ const val TAG = "ScreenFooter"
286
+ }
287
+ }
@@ -0,0 +1,25 @@
1
+ package com.swmansion.rnscreens
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+ import com.facebook.react.uimanager.ViewGroupManager
6
+ import com.facebook.react.uimanager.ViewManagerDelegate
7
+ import com.facebook.react.viewmanagers.RNSScreenFooterManagerDelegate
8
+ import com.facebook.react.viewmanagers.RNSScreenFooterManagerInterface
9
+
10
+ @ReactModule(name = ScreenFooterManager.REACT_CLASS)
11
+ class ScreenFooterManager :
12
+ ViewGroupManager<ScreenFooter>(),
13
+ RNSScreenFooterManagerInterface<ScreenFooter> {
14
+ private val delegate: ViewManagerDelegate<ScreenFooter> = RNSScreenFooterManagerDelegate(this)
15
+
16
+ override fun getName(): String = REACT_CLASS
17
+
18
+ override fun createViewInstance(context: ThemedReactContext) = ScreenFooter(context)
19
+
20
+ override fun getDelegate(): ViewManagerDelegate<ScreenFooter> = delegate
21
+
22
+ companion object {
23
+ const val REACT_CLASS = "RNSScreenFooter"
24
+ }
25
+ }