-
-
Notifications
You must be signed in to change notification settings - Fork 609
feat!: iOS custom detents & Android form sheets #2045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 250 commits
49b5ad6
700d46e
7b4ed40
612ada8
86307aa
00d50b2
c442b25
426bee8
c3abf7c
20a7ae6
1a0653d
6bda77c
2fe8144
57f0b43
f8e9baf
cadde6a
dafca03
4dea550
4f47012
f604827
087a938
d940324
1323f1a
52e5839
7cab9c2
1ca4e3d
49caf0a
ab835b8
77b9670
f8b3946
4479c1a
72b2631
1acbdc6
a11db31
bbfb20f
3c7cddc
a214f19
9d86a34
0550367
0753533
97c29ce
ed8874e
9d3bdd5
b6939fc
88d10d8
3763cf3
2f91223
b106b8b
9a4ff66
a30f4ee
d84f64c
6d1fff5
a40aae1
9672ec1
75fe630
5248114
38b2fe8
94e2686
42080f0
6db2b0a
664852f
38851a9
8512a04
33a192c
dc3db10
5c35d5d
fba211f
e709618
8a29c55
6393124
e297812
96003ba
44946ca
a5d2c94
33049fc
8e8e6f0
22fddb7
6f992f0
c877a53
a0a7912
b4a81d9
dd641ec
c27ab79
a68b907
3920f2a
8e27264
a63d768
25a9f70
09c1143
0c334a2
aac2cee
5f6e12f
4bbed13
6653171
d08aba3
413832b
15820e2
ed028ad
1c8805b
b1fa392
0ce92a7
5530b16
b0f20fe
e0b76be
768c2ca
7aa835f
78510bd
4d8ce7e
0e2c6c5
406eed2
19704b1
dd75129
8e8b5b4
839b9d4
4808e49
e509aa6
d07156a
6eb4a3b
edb04f4
96a0abf
6bfb629
2d923e4
75e0af8
acc12ea
c99b184
55776cd
6022759
00aac51
c3eb4a9
b7c262e
dfc95cd
5ce40cd
91cea41
05a5085
2f0da1e
74dc5b0
3744929
b1533ec
d85cb17
4c6ecdc
692904f
eb5986a
e4ebf84
e484e8a
eddcd8e
06a2167
c4183aa
8fc1ecb
127efbb
4cad0c2
03e0633
f81d1b9
b286c48
b8e25b9
7b7fb41
c3aafbd
03c056e
e6c880e
ad68762
0dd9b50
85e509b
da15cae
48779d4
6aae2ca
b316f26
cca0279
c566288
626319b
40c1abd
28efb25
b4698d0
dcb97da
a6ccf8d
458af30
d056286
5f14195
809aee5
ed74c9f
ffefdfc
9289f24
c03decb
e8b6824
99f95a6
50adfc4
255e4c5
42e86bb
14d960d
9d2bcda
04803c5
e77d467
82bc8e9
7ff2f6f
97ee80a
cf8c18a
f1e9ad4
5f936b6
4825e2c
7772707
384f3e0
aaee08d
c474285
cebb527
e53f30b
24083ee
9157e31
fbbbc6b
2eb3cf5
f35131c
7c5a1d5
cc8b06c
6d919cd
f7c7a86
dc832d3
e58c0b3
c71061c
8ad9968
ede0b5b
3cdecb7
495b3eb
6b541b4
c8ee412
19e863f
16b4145
9bb8322
e7d6e3d
fd01f5b
79a2484
5964f4d
600f818
a74598a
1f8f411
c301f2b
3af9813
b1965b6
d9fbce7
44be1b5
1a64f41
fe036cd
7f99c80
15e096d
0b3053f
e2c997a
ddff79e
c46bbbe
38655c2
a8c27ec
62dc767
adc0d61
3f752db
74ac52e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This class is needed, because sheets do need to listen for keyboard appearance. Each sheet does need to register a listener to decor view, while Android allows for only single listener! Thus I've introduced a proxy, who can aggregate listeners and fan out the events. |
||
| 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( | ||
| "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 |
|---|---|---|
|
|
@@ -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> | ||
kkafar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why 24?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No real reason here. I decided that it just looks nice. When looking at design guides material components do use |
||
|
|
||
| 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<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 | ||
|
|
@@ -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,15 @@ class Screen( | |
| ) | ||
| } | ||
|
|
||
| fun isTransparent(): Boolean = stackPresentation === StackPresentation.TRANSPARENT_MODAL | ||
| fun isTransparent(): Boolean = | ||
| when (stackPresentation) { | ||
| StackPresentation.TRANSPARENT_MODAL, | ||
| StackPresentation.FORM_SHEET, | ||
| StackPresentation.MODAL, | ||
| -> true | ||
|
|
||
| else -> false | ||
| } | ||
|
|
||
| private fun hasWebView(viewGroup: ViewGroup): Boolean { | ||
| for (i in 0 until viewGroup.childCount) { | ||
|
|
@@ -351,10 +424,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 +479,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( | ||
kkafar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.