diff --git a/android/src/main/java/com/reactnativenavigation/options/ModalOptions.java b/android/src/main/java/com/reactnativenavigation/options/ModalOptions.java index d031fbcd16a..b98093e6a92 100644 --- a/android/src/main/java/com/reactnativenavigation/options/ModalOptions.java +++ b/android/src/main/java/com/reactnativenavigation/options/ModalOptions.java @@ -4,10 +4,16 @@ import com.reactnativenavigation.options.params.Bool; import com.reactnativenavigation.options.params.NullBool; +import com.reactnativenavigation.options.params.NullText; +import com.reactnativenavigation.options.params.Text; import com.reactnativenavigation.options.parsers.BoolParser; +import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; +import java.util.List; + public class ModalOptions { public static ModalOptions parse(final JSONObject json) { @@ -17,15 +23,41 @@ public static ModalOptions parse(final JSONObject json) { options.presentationStyle = ModalPresentationStyle.fromString(json.optString("modalPresentationStyle")); options.blurOnUnmount = BoolParser.parse(json, "blurOnUnmount"); + JSONObject modal = json.optJSONObject("modal"); + if (modal != null) { + options.swipeToDismiss = BoolParser.parse(modal, "swipeToDismiss"); + options.prefersGrabberVisible = BoolParser.parse(modal, "prefersGrabberVisible"); + options.selectedDetent = parseText(modal, "selectedDetent"); + options.largestUndimmedDetent = parseText(modal, "largestUndimmedDetent"); + options.detents = ModalSheetDetentParser.parse(modal.optJSONArray("detents")); + } + return options; } + private static Text parseText(JSONObject json, String key) { + if (!json.has(key)) { + return new NullText(); + } + return new Text(json.optString(key)); + } + public ModalPresentationStyle presentationStyle = ModalPresentationStyle.Unspecified; public @NonNull Bool blurOnUnmount = new NullBool(); + public @NonNull Bool swipeToDismiss = new NullBool(); + public @NonNull Bool prefersGrabberVisible = new NullBool(); + public @NonNull Text selectedDetent = new NullText(); + public @NonNull Text largestUndimmedDetent = new NullText(); + public @NonNull List detents = new ArrayList<>(); public void mergeWith(final ModalOptions other) { if (other.presentationStyleHasValue()) presentationStyle = other.presentationStyle; if (other.blurOnUnmount.hasValue()) blurOnUnmount = other.blurOnUnmount; + if (other.swipeToDismiss.hasValue()) swipeToDismiss = other.swipeToDismiss; + if (other.prefersGrabberVisible.hasValue()) prefersGrabberVisible = other.prefersGrabberVisible; + if (other.selectedDetent.hasValue()) selectedDetent = other.selectedDetent; + if (other.largestUndimmedDetent.hasValue()) largestUndimmedDetent = other.largestUndimmedDetent; + if (!other.detents.isEmpty()) detents = other.detents; } private boolean presentationStyleHasValue() { @@ -35,6 +67,20 @@ private boolean presentationStyleHasValue() { public void mergeWithDefault(final ModalOptions defaultOptions) { if (!presentationStyleHasValue()) presentationStyle = defaultOptions.presentationStyle; if (!blurOnUnmount.hasValue()) blurOnUnmount = defaultOptions.blurOnUnmount; + if (!swipeToDismiss.hasValue()) swipeToDismiss = defaultOptions.swipeToDismiss; + if (!prefersGrabberVisible.hasValue()) prefersGrabberVisible = defaultOptions.prefersGrabberVisible; + if (!selectedDetent.hasValue()) selectedDetent = defaultOptions.selectedDetent; + if (!largestUndimmedDetent.hasValue()) largestUndimmedDetent = defaultOptions.largestUndimmedDetent; + if (detents.isEmpty()) detents = defaultOptions.detents; } + public boolean hasSheetPresentationOptions() { + return !detents.isEmpty() + || selectedDetent.hasValue() + || prefersGrabberVisible.hasValue(); + } + + public boolean isPageSheetPresentation() { + return presentationStyle == ModalPresentationStyle.PageSheet; + } } diff --git a/android/src/main/java/com/reactnativenavigation/options/ModalPresentationStyle.java b/android/src/main/java/com/reactnativenavigation/options/ModalPresentationStyle.java index 9b52231c4b3..06ee3981e94 100644 --- a/android/src/main/java/com/reactnativenavigation/options/ModalPresentationStyle.java +++ b/android/src/main/java/com/reactnativenavigation/options/ModalPresentationStyle.java @@ -3,7 +3,8 @@ public enum ModalPresentationStyle { Unspecified("unspecified"), None("none"), - OverCurrentContext("overCurrentContext"); + OverCurrentContext("overCurrentContext"), + PageSheet("pageSheet"); public String name; @@ -12,11 +13,17 @@ public enum ModalPresentationStyle { } public static ModalPresentationStyle fromString(String name) { + if (name == null) { + return Unspecified; + } switch (name) { case "none": return None; case "overCurrentContext": return OverCurrentContext; + case "pageSheet": + case "formSheet": + return PageSheet; default: return Unspecified; } diff --git a/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetent.java b/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetent.java new file mode 100644 index 00000000000..ec8e7d39988 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetent.java @@ -0,0 +1,29 @@ +package com.reactnativenavigation.options; + +public class ModalSheetDetent { + + public enum Type { + SYSTEM, + CUSTOM + } + + public Type type; + public String systemId; + public String customId; + public float height; + + public static ModalSheetDetent system(String id) { + ModalSheetDetent detent = new ModalSheetDetent(); + detent.type = Type.SYSTEM; + detent.systemId = id; + return detent; + } + + public static ModalSheetDetent custom(String id, float height) { + ModalSheetDetent detent = new ModalSheetDetent(); + detent.type = Type.CUSTOM; + detent.customId = id; + detent.height = height; + return detent; + } +} diff --git a/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetentParser.java b/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetentParser.java new file mode 100644 index 00000000000..3a9416c1951 --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/options/ModalSheetDetentParser.java @@ -0,0 +1,43 @@ +package com.reactnativenavigation.options; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class ModalSheetDetentParser { + + public static List parse(JSONArray json) { + if (json == null) { + return new ArrayList<>(); + } + List detents = new ArrayList<>(); + for (int i = 0; i < json.length(); i++) { + ModalSheetDetent detent = parseDetent(json.opt(i)); + if (detent != null) { + detents.add(detent); + } + } + return detents; + } + + private static ModalSheetDetent parseDetent(Object item) { + if (item instanceof String) { + return ModalSheetDetent.system(((String) item).toLowerCase()); + } + if (!(item instanceof JSONObject)) { + return null; + } + JSONObject dict = (JSONObject) item; + String id = dict.optString("id", null); + if (id == null || id.isEmpty()) { + return null; + } + double height = dict.optDouble("height", 0); + if (height <= 0) { + return null; + } + return ModalSheetDetent.custom(id, (float) height); + } +} diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalBottomSheetPresenter.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalBottomSheetPresenter.kt new file mode 100644 index 00000000000..715ffff1a9d --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalBottomSheetPresenter.kt @@ -0,0 +1,272 @@ +package com.reactnativenavigation.viewcontrollers.modal + +import android.content.Context +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.reactnativenavigation.R +import com.reactnativenavigation.options.ModalOptions +import com.reactnativenavigation.options.ModalSheetDetent +import com.reactnativenavigation.views.stack.topbar.TopBar + +object ModalBottomSheetPresenter { + + @JvmStatic + fun createContainer(context: Context): FrameLayout { + val container = FrameLayout(context) + container.id = View.generateViewId() + return container + } + + @JvmStatic + fun configure(container: FrameLayout, modal: ModalOptions) { + val behavior = behaviorFor(container) + container.setTag(R.id.modal_bottom_sheet_behavior, behavior) + + if (modal.detents.isNotEmpty()) { + applyDetents(behavior, modal.detents, container.context) + } + + if (modal.swipeToDismiss.hasValue()) { + behavior.isHideable = modal.swipeToDismiss.get() + } else { + behavior.isHideable = true + } + + if (modal.prefersGrabberVisible.hasValue() && modal.prefersGrabberVisible.get()) { + ensureGrabber(container) + attachGrabberTracking(container, behavior) + } + + if (modal.selectedDetent.hasValue()) { + container.post { + applySelectedDetent(behavior, container.context, modal.selectedDetent.get(), modal.detents) + updateGrabberPosition(container, behavior) + } + } + } + + @JvmStatic + fun applySelectedDetent( + behavior: BottomSheetBehavior, + context: Context, + selectedDetent: String, + detents: List + ) { + val normalized = selectedDetent.lowercase() + when (normalized) { + "large" -> { + behavior.isFitToContents = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + "medium" -> { + behavior.isFitToContents = false + behavior.halfExpandedRatio = HALF_EXPANDED_RATIO + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + else -> { + val custom = detents.firstOrNull { + it.type == ModalSheetDetent.Type.CUSTOM && it.customId.equals(normalized, ignoreCase = true) + } + if (custom != null) { + behavior.isFitToContents = false + behavior.skipCollapsed = false + behavior.peekHeight = detentHeightPx(context, custom.height) + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + } + } + } + + private fun applyDetents(behavior: BottomSheetBehavior, detents: List, context: Context) { + if (detents.isEmpty()) { + return + } + + var hasMedium = false + var hasLarge = false + var smallestCustom: ModalSheetDetent? = null + + for (detent in detents) { + when (detent.type) { + ModalSheetDetent.Type.SYSTEM -> when (detent.systemId) { + "medium" -> hasMedium = true + "large" -> hasLarge = true + } + ModalSheetDetent.Type.CUSTOM -> { + if (smallestCustom == null || detent.height < smallestCustom.height) { + smallestCustom = detent + } + } + } + } + + smallestCustom?.let { + behavior.peekHeight = detentHeightPx(context, it.height) + behavior.skipCollapsed = false + } ?: run { + behavior.skipCollapsed = true + } + + if (hasMedium) { + behavior.isFitToContents = false + behavior.halfExpandedRatio = HALF_EXPANDED_RATIO + } else if (hasLarge) { + behavior.isFitToContents = true + } + } + + private fun behaviorFor(container: FrameLayout): BottomSheetBehavior { + (container.getTag(R.id.modal_bottom_sheet_behavior) as? BottomSheetBehavior)?.let { + return it + } + if (container.parent is CoordinatorLayout) { + return BottomSheetBehavior.from(container) + } + throw IllegalStateException("Sheet container must be attached to a CoordinatorLayout before configuring") + } + + private fun detentHeightPx(context: Context, heightDp: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + heightDp, + context.resources.displayMetrics + ).toInt() + } + + private fun ensureGrabber(container: FrameLayout) { + if (container.findViewWithTag(GRABBER_WRAPPER_TAG) != null) { + return + } + if (container.childCount != 1) { + return + } + + val content = container.getChildAt(0) + container.removeView(content) + + val metrics = container.resources.displayMetrics + val topPadding = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, metrics).toInt() + + clearStatusBarInsetForSheet(content) + + val wrapper = FrameLayout(container.context).apply { + tag = GRABBER_WRAPPER_TAG + } + + wrapper.addView( + content, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + val handleWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 36f, metrics).toInt() + val handleHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, metrics).toInt() + val handle = View(container.context).apply { + tag = GRABBER_TAG + background = ContextCompat.getDrawable(context, R.drawable.modal_sheet_grabber) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + contentDescription = "Drag handle" + elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, metrics) + } + val handleParams = FrameLayout.LayoutParams(handleWidth, handleHeight).apply { + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + topMargin = topPadding + } + wrapper.addView(handle, handleParams) + + container.addView( + wrapper, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + private fun attachGrabberTracking(container: FrameLayout, behavior: BottomSheetBehavior) { + if (container.findViewWithTag(GRABBER_TAG) == null) { + return + } + (container.getTag(R.id.modal_bottom_sheet_grabber_callback) as? BottomSheetCallback)?.let { + behavior.removeBottomSheetCallback(it) + } + val callback = object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateGrabberPosition(container, behavior) + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + updateGrabberPosition(container, behavior) + } + } + container.setTag(R.id.modal_bottom_sheet_grabber_callback, callback) + behavior.addBottomSheetCallback(callback) + container.post { updateGrabberPosition(container, behavior) } + } + + private fun updateGrabberPosition(container: FrameLayout, behavior: BottomSheetBehavior) { + val parent = container.parent as? View ?: return + val wrapper = container.findViewWithTag(GRABBER_WRAPPER_TAG) ?: return + val handle = wrapper.findViewWithTag(GRABBER_TAG) ?: return + val metrics = container.resources.displayMetrics + val padding = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, metrics).toInt() + + val content = if (wrapper.childCount > 0) wrapper.getChildAt(0) else return + val topBar = findTopBar(content) + val topMargin = if (topBar != null) { + val topBarLocation = IntArray(2) + val wrapperLocation = IntArray(2) + topBar.getLocationInWindow(topBarLocation) + wrapper.getLocationInWindow(wrapperLocation) + (topBarLocation[1] - wrapperLocation[1] + padding).coerceAtLeast(padding) + } else { + when (behavior.state) { + BottomSheetBehavior.STATE_EXPANDED -> container.top + BottomSheetBehavior.STATE_HALF_EXPANDED -> + (parent.height * (1f - behavior.halfExpandedRatio)).toInt() + BottomSheetBehavior.STATE_COLLAPSED -> + parent.height - behavior.peekHeight + else -> parent.height - behavior.peekHeight + } + padding + } + + val lp = handle.layoutParams as FrameLayout.LayoutParams + lp.topMargin = topMargin + handle.layoutParams = lp + } + + private fun clearStatusBarInsetForSheet(content: View) { + val topBar = findTopBar(content) ?: return + val layoutParams = topBar.layoutParams as? MarginLayoutParams ?: return + if (layoutParams.topMargin != 0) { + layoutParams.topMargin = 0 + topBar.requestLayout() + } + } + + private fun findTopBar(view: View): TopBar? { + if (view is TopBar) { + return view + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + findTopBar(view.getChildAt(i))?.let { return it } + } + } + return null + } + + private const val GRABBER_WRAPPER_TAG = "modal_sheet_grabber_wrapper" + private const val GRABBER_TAG = "modal_sheet_grabber" + private const val HALF_EXPANDED_RATIO = 0.5f +} diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenter.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenter.java index 81382b1a589..6ed672ab44b 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenter.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalPresenter.java @@ -2,20 +2,31 @@ import android.view.View; import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.reactnativenavigation.R; import com.reactnativenavigation.options.AnimationOptions; import com.reactnativenavigation.options.ModalPresentationStyle; import com.reactnativenavigation.options.Options; import com.reactnativenavigation.react.CommandListener; import com.reactnativenavigation.utils.ScreenAnimationListener; +import com.reactnativenavigation.viewcontrollers.parent.ParentController; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; +import com.google.android.material.bottomsheet.BottomSheetBehavior; + import org.jetbrains.annotations.NotNull; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static com.reactnativenavigation.utils.CoordinatorLayoutUtils.matchParentLP; +import static com.reactnativenavigation.utils.CoordinatorLayoutUtils.matchParentWithBehaviour; public class ModalPresenter { @@ -23,6 +34,7 @@ public class ModalPresenter { private CoordinatorLayout modalsLayout; private final ModalAnimator modalAnimator; private Options defaultOptions = new Options(); + private final Map sheetContainersByModalId = new HashMap<>(); ModalPresenter(ModalAnimator animator) { this.modalAnimator = animator; @@ -46,7 +58,12 @@ void showModal(ViewController appearing, ViewController disappearing, Comm return; } - Options options = appearing.resolveCurrentOptions(defaultOptions); + Options options = resolveSheetOptions(appearing); + + if (shouldUseBottomSheet(options)) { + showBottomSheetModal(appearing, disappearing, options, listener); + return; + } AnimationOptions enterAnimationOptions = options.animations.showModal.getEnter(); appearing.setWaitForRender(enterAnimationOptions.waitForRender); @@ -68,6 +85,69 @@ void showModal(ViewController appearing, ViewController disappearing, Comm } } + private void showBottomSheetModal(ViewController appearing, ViewController disappearing, Options options, CommandListener listener) { + modalsLayout.setVisibility(View.VISIBLE); + + FrameLayout container = ModalBottomSheetPresenter.createContainer(modalsLayout.getContext()); + View content = appearing.getView(); + if (content.getParent() instanceof ViewGroup) { + ((ViewGroup) content.getParent()).removeView(content); + } + container.addView(content, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + + BottomSheetBehavior behavior = new BottomSheetBehavior<>(); + modalsLayout.addView(container, matchParentWithBehaviour(behavior)); + ModalBottomSheetPresenter.configure(container, options.modal); + sheetContainersByModalId.put(appearing.getId(), container); + appearing.getView().setTag(R.id.modal_bottom_sheet_container, container); + + appearing.getView().setAlpha(1); + onShowModalEnd(appearing, disappearing, listener); + } + + public void applySheetOptions(ViewController modalRoot) { + Options resolved = resolveSheetOptions(modalRoot); + if (!shouldUseBottomSheet(resolved)) { + return; + } + FrameLayout container = sheetContainersByModalId.get(modalRoot.getId()); + if (container == null) { + return; + } + container.post(() -> ModalBottomSheetPresenter.configure(container, resolved.modal)); + } + + private boolean shouldUseBottomSheet(Options options) { + return options.modal.isPageSheetPresentation() || options.modal.hasSheetPresentationOptions(); + } + + private Options resolveSheetOptions(ViewController modalRoot) { + Options options = modalRoot.resolveCurrentOptions(defaultOptions); + if (!(modalRoot instanceof ParentController)) { + return options; + } + ParentController parent = (ParentController) modalRoot; + ViewController child = parent.getCurrentChild(); + if (child == null) { + Collection> children = parent.getChildControllers(); + if (!children.isEmpty()) { + child = children.iterator().next(); + } + } + if (child == null) { + return options; + } + Options childOptions = child.resolveCurrentOptions(defaultOptions); + if (!childOptions.modal.isPageSheetPresentation() && !childOptions.modal.hasSheetPresentationOptions()) { + return options; + } + Options merged = options.copy(); + if (childOptions.modal.presentationStyle != ModalPresentationStyle.Unspecified) { + merged.modal.presentationStyle = childOptions.modal.presentationStyle; + } + merged.modal.mergeWith(childOptions.modal); + return merged; + } @NotNull private ScreenAnimationListener createListener(ViewController toAdd, ViewController toRemove, CommandListener listener) { @@ -91,12 +171,20 @@ public void onCancel() { private void onShowModalEnd(ViewController toAdd, @Nullable ViewController toRemove, CommandListener listener) { toAdd.onViewDidAppear(); - if (toRemove != null && toAdd.resolveCurrentOptions(defaultOptions).modal.presentationStyle != ModalPresentationStyle.OverCurrentContext) { + if (toRemove != null && shouldDetachUnderlyingOnShow(toAdd)) { toRemove.detachView(); } listener.onSuccess(toAdd.getId()); } + private boolean shouldDetachUnderlyingOnShow(ViewController appearing) { + Options options = resolveSheetOptions(appearing); + if (shouldUseBottomSheet(options)) { + return false; + } + return options.modal.presentationStyle != ModalPresentationStyle.OverCurrentContext; + } + void dismissModal(ViewController toDismiss, @Nullable ViewController toAdd, ViewController root, CommandListener listener) { if (modalsLayout == null) { listener.onError("Can not dismiss modal before activity is created"); @@ -126,8 +214,13 @@ boolean shouldDismissModal(ViewController toDismiss) { public Options resolveOptions(ViewController modalController){ return modalController.resolveCurrentOptions(defaultOptions); } + private void onDismissEnd(ViewController toDismiss, CommandListener listener) { listener.onSuccess(toDismiss.getId()); + FrameLayout sheetContainer = sheetContainersByModalId.remove(toDismiss.getId()); + if (sheetContainer != null) { + modalsLayout.removeView(sheetContainer); + } toDismiss.destroy(); if (isEmpty()) modalsLayout.setVisibility(View.GONE); } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java index 0e00f254364..1e2c1851351 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java @@ -201,4 +201,14 @@ public void onHostResume() { public boolean peekDisplayedOverCurrentContext() { return !isEmpty() && presenter.resolveOptions(peek()).modal.presentationStyle == ModalPresentationStyle.OverCurrentContext; } + + public void applySheetMergeOptions(String componentId, Options mergeOptions) { + if (mergeOptions == null || mergeOptions == Options.EMPTY) { + return; + } + ViewController modalRoot = findModalByComponentId(componentId); + if (modalRoot != null) { + presenter.applySheetOptions(modalRoot); + } + } } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java index 45877b1702a..a82c608ef8c 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java @@ -167,6 +167,7 @@ public void mergeOptions(final String componentId, Options options) { ViewController target = findController(componentId); if (target != null) { target.mergeOptions(options); + modalStack.applySheetMergeOptions(componentId, options); } } diff --git a/android/src/main/res/drawable/modal_sheet_grabber.xml b/android/src/main/res/drawable/modal_sheet_grabber.xml new file mode 100644 index 00000000000..4e142254c02 --- /dev/null +++ b/android/src/main/res/drawable/modal_sheet_grabber.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/src/main/res/values/ids.xml b/android/src/main/res/values/ids.xml index f43a89d7454..eeae7a5d575 100644 --- a/android/src/main/res/values/ids.xml +++ b/android/src/main/res/values/ids.xml @@ -5,6 +5,9 @@ + + + diff --git a/docs/modal-sheet-detents.md b/docs/modal-sheet-detents.md new file mode 100644 index 00000000000..bc834c729bb --- /dev/null +++ b/docs/modal-sheet-detents.md @@ -0,0 +1,79 @@ +# Modal sheet detents (iOS & Android) + +Cross-platform API for `pageSheet` / `formSheet` modals with configurable snap heights. + +Public API docs: [Modal options](../website/docs/api/options-modal.mdx) · [modalPresentationStyle](../website/docs/api/options-root.mdx#modalpresentationstyle) + +Playground: **Navigation** tab → **Sheet detents** (`playground/src/screens/SheetModalScreen.tsx`). + +E2E: `playground/e2e/SheetDetents.test.js` (Detox — 4 tests on Android, 3 on iOS plus 1 Android-only skipped on iOS). + +## Example + +```js +Navigation.showModal({ + stack: { + children: [{ + component: { + name: 'MySheet', + options: { + modalPresentationStyle: 'pageSheet', + modal: { + detents: ['medium', { id: 'compact', height: 220 }, 'large'], + selectedDetent: 'medium', + prefersGrabberVisible: true, + swipeToDismiss: true, + // iOS only: + largestUndimmedDetent: 'medium', + }, + }, + }, + }], + }, +}); + +// Runtime +Navigation.mergeOptions(componentId, { + modal: { selectedDetent: 'large' }, +}); +``` + +## Android behavior summary + +| Configuration | Result | +| --- | --- | +| Default modal (no `pageSheet`) | Full-screen modal | +| `pageSheet` only | `BottomSheetBehavior` wrapper, Material default height | +| `pageSheet` + `modal.detents` | Bottom sheet with mapped snap points | +| `formSheet` | Same as `pageSheet` on Android | +| `modal` options without `pageSheet` | Bottom sheet only if `detents`, `selectedDetent`, or `prefersGrabberVisible` is set — **not** `largestUndimmedDetent` alone | + +Detent mapping: + +- `'large'` → expanded +- `'medium'` → half-expanded (50%) +- `{ id, height }` → `peekHeight` in dp, `selectedDetent` uses the custom `id` + +Native: `ModalPresenter`, `ModalBottomSheetPresenter`, `ModalOptions`, `ModalSheetDetentParser`. + +Underlying content stays attached (no `componentDidDisappear` on the presenter). + +## iOS behavior summary + +Uses `UISheetPresentationController` via `RNNModalOptions` / `RNNSheetDetentOptions`. + +- `medium` and custom detents require iOS 16+ +- `largestUndimmedDetent` is iOS-only (parsed on Android but not applied) + +Sheet options are applied on show and on `mergeOptions` when any sheet-related `modal` field is present in the merge payload. + +## Parity gaps + +| Area | iOS | Android | +| --- | --- | --- | +| `largestUndimmedDetent` | Supported | Ignored | +| `swipeToDismiss` | Dismisses modal | `isHideable` only; no auto `dismissModal` | +| Grabber | System | Custom pill, TopBar-aware positioning | +| Custom detent unit | Points | dp | + +Full table: [options-modal.mdx](../website/docs/api/options-modal.mdx). diff --git a/ios/RNNBasePresenter.mm b/ios/RNNBasePresenter.mm index 86493a01fcc..8840f3286cf 100644 --- a/ios/RNNBasePresenter.mm +++ b/ios/RNNBasePresenter.mm @@ -48,6 +48,8 @@ - (void)applyOptionsOnInit:(RNNNavigationOptions *)initialOptions { viewController.modalInPresentation = ![withDefault.modal.swipeToDismiss withDefault:YES]; } + [withDefault.modal applySheetPresentationToViewController:viewController]; + if (withDefault.window.backgroundColor.hasValue) { UIApplication.sharedApplication.delegate.window.backgroundColor = withDefault.window.backgroundColor.get; @@ -78,6 +80,10 @@ - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions self.boundViewController.modalInPresentation = !withDefault.modal.swipeToDismiss.get; } + if ([mergeOptions.modal hasSheetPresentationOptions]) { + [withDefault.modal applySheetPresentationToViewController:self.boundViewController]; + } + if (mergeOptions.window.backgroundColor.hasValue) { UIApplication.sharedApplication.delegate.window.backgroundColor = withDefault.window.backgroundColor.get; diff --git a/ios/RNNCommandsHandler.mm b/ios/RNNCommandsHandler.mm index 9b14fcfde92..e59decd0454 100644 --- a/ios/RNNCommandsHandler.mm +++ b/ios/RNNCommandsHandler.mm @@ -401,6 +401,7 @@ - (void)showModal:(NSDictionary *)layout UIModalPresentationStyle:[withDefault.modalPresentationStyle withDefault:@"default"]]; newVc.modalTransitionStyle = [RNNConvert UIModalTransitionStyle:[withDefault.modalTransitionStyle withDefault:@"coverVertical"]]; + [withDefault.modal applySheetPresentationToViewController:newVc]; if (animated && !waitForRender) [[AnimationObserver sharedObserver] beginAnimation]; diff --git a/ios/RNNModalOptions.h b/ios/RNNModalOptions.h index 940c3305b43..01470c1e957 100644 --- a/ios/RNNModalOptions.h +++ b/ios/RNNModalOptions.h @@ -1,7 +1,17 @@ #import "RNNOptions.h" +#import "RNNSheetDetentOptions.h" +#import "Text.h" +#import @interface RNNModalOptions : RNNOptions @property(nonatomic, strong) Bool *swipeToDismiss; +@property(nonatomic, copy) NSArray *detents; +@property(nonatomic, strong) Text *selectedDetent; +@property(nonatomic, strong) Text *largestUndimmedDetent; +@property(nonatomic, strong) Bool *prefersGrabberVisible; + +- (BOOL)hasSheetPresentationOptions; +- (void)applySheetPresentationToViewController:(UIViewController *)viewController; @end diff --git a/ios/RNNModalOptions.mm b/ios/RNNModalOptions.mm index cc795cc82c5..d8781459797 100644 --- a/ios/RNNModalOptions.mm +++ b/ios/RNNModalOptions.mm @@ -1,16 +1,159 @@ #import "RNNModalOptions.h" +#import "BoolParser.h" +#import "TextParser.h" + +static UIViewController *RNNSheetPresentationHostForViewController(UIViewController *viewController) { + UIViewController *current = viewController; + while (current != nil) { + if (current.sheetPresentationController != nil) { + return current; + } + current = current.parentViewController; + } + return viewController; +} + +static NSString *RNNSheetDetentIdentifierFromString(NSString *identifier) API_AVAILABLE(ios(15.0)) { + NSString *normalized = [identifier lowercaseString]; + if (@available(iOS 16.0, *)) { + if ([normalized isEqualToString:@"medium"]) { + return UISheetPresentationControllerDetentIdentifierMedium; + } + } + if ([normalized isEqualToString:@"large"]) { + return UISheetPresentationControllerDetentIdentifierLarge; + } + return identifier; +} @implementation RNNModalOptions - (instancetype)initWithDict:(NSDictionary *)dict { self = [super initWithDict:dict]; self.swipeToDismiss = [BoolParser parse:dict key:@"swipeToDismiss"]; + self.detents = [RNNSheetDetentOptions parseDetents:dict[@"detents"]]; + self.selectedDetent = [TextParser parse:dict key:@"selectedDetent"]; + self.largestUndimmedDetent = [TextParser parse:dict key:@"largestUndimmedDetent"]; + self.prefersGrabberVisible = [BoolParser parse:dict key:@"prefersGrabberVisible"]; return self; } - (void)mergeOptions:(RNNModalOptions *)options { if (options.swipeToDismiss.hasValue) self.swipeToDismiss = options.swipeToDismiss; + if (options.detents) + self.detents = options.detents; + if (options.selectedDetent.hasValue) + self.selectedDetent = options.selectedDetent; + if (options.largestUndimmedDetent.hasValue) + self.largestUndimmedDetent = options.largestUndimmedDetent; + if (options.prefersGrabberVisible.hasValue) + self.prefersGrabberVisible = options.prefersGrabberVisible; +} + +- (BOOL)hasSheetPresentationOptions { + return self.detents.count > 0 || self.selectedDetent.hasValue || self.largestUndimmedDetent.hasValue || + self.prefersGrabberVisible.hasValue; +} + +- (void)applySheetPresentationToViewController:(UIViewController *)viewController { + if (![self hasSheetPresentationOptions]) { + return; + } + + if (@available(iOS 15.0, *)) { + UIViewController *host = RNNSheetPresentationHostForViewController(viewController); + UISheetPresentationController *sheet = host.sheetPresentationController; + if (sheet == nil) { + return; + } + + __weak RNNModalOptions *weakSelf = self; + void (^applyChanges)(void) = ^{ + RNNModalOptions *strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + if (strongSelf.detents.count > 0) { + NSArray *resolvedDetents = [strongSelf resolveDetents]; + if (resolvedDetents.count > 0) { + sheet.detents = resolvedDetents; + } + } + + if (strongSelf.prefersGrabberVisible.hasValue) { + sheet.prefersGrabberVisible = strongSelf.prefersGrabberVisible.get; + } + + if (strongSelf.selectedDetent.hasValue) { + NSString *detentId = RNNSheetDetentIdentifierFromString(strongSelf.selectedDetent.get); + sheet.selectedDetentIdentifier = detentId; + } + + if (strongSelf.largestUndimmedDetent.hasValue) { + NSString *detentId = + RNNSheetDetentIdentifierFromString(strongSelf.largestUndimmedDetent.get); + sheet.largestUndimmedDetentIdentifier = detentId; + } + }; + + if (host.view.window != nil) { + [sheet animateChanges:applyChanges]; + } else { + applyChanges(); + } + } +} + +- (NSArray *)resolveDetents API_AVAILABLE(ios(15.0)) { + NSMutableArray *resolved = [NSMutableArray new]; + + for (RNNSheetDetentOptions *detent in self.detents) { + UISheetPresentationControllerDetent *resolvedDetent = [self resolveDetent:detent]; + if (resolvedDetent != nil) { + [resolved addObject:resolvedDetent]; + } + } + + return resolved; +} + +- (UISheetPresentationControllerDetent *)resolveDetent:(RNNSheetDetentOptions *)detent + API_AVAILABLE(ios(15.0)) { + if (detent.type == RNNSheetDetentTypeSystem) { + NSString *identifier = [[detent.systemIdentifier get] lowercaseString]; + if (@available(iOS 16.0, *)) { + if ([identifier isEqualToString:@"medium"]) { + return [UISheetPresentationControllerDetent mediumDetent]; + } + if ([identifier isEqualToString:@"large"]) { + return [UISheetPresentationControllerDetent largeDetent]; + } + } else if (@available(iOS 15.0, *)) { + if ([identifier isEqualToString:@"large"]) { + return [UISheetPresentationControllerDetent largeDetent]; + } + } + return nil; + } + + if (@available(iOS 16.0, *)) { + NSString *identifier = [detent.customIdentifier get]; + CGFloat height = [detent.height withDefault:0]; + if (identifier.length == 0 || height <= 0) { + return nil; + } + + return [UISheetPresentationControllerDetent customDetentWithIdentifier:identifier + resolver:^CGFloat( + id + context) { + return height; + }]; + } + + return nil; } @end diff --git a/ios/RNNSheetDetentOptions.h b/ios/RNNSheetDetentOptions.h new file mode 100644 index 00000000000..207a5bdaaef --- /dev/null +++ b/ios/RNNSheetDetentOptions.h @@ -0,0 +1,19 @@ +#import "Double.h" +#import "RNNOptions.h" +#import "Text.h" + +typedef NS_ENUM(NSInteger, RNNSheetDetentType) { + RNNSheetDetentTypeSystem, + RNNSheetDetentTypeCustom, +}; + +@interface RNNSheetDetentOptions : RNNOptions + +@property(nonatomic, assign) RNNSheetDetentType type; +@property(nonatomic, strong) Text *systemIdentifier; +@property(nonatomic, strong) Text *customIdentifier; +@property(nonatomic, strong) Double *height; + ++ (NSArray *)parseDetents:(id)json; + +@end diff --git a/ios/RNNSheetDetentOptions.mm b/ios/RNNSheetDetentOptions.mm new file mode 100644 index 00000000000..eed62dd88b4 --- /dev/null +++ b/ios/RNNSheetDetentOptions.mm @@ -0,0 +1,49 @@ +#import "RNNSheetDetentOptions.h" +#import "DoubleParser.h" +#import "TextParser.h" + +@implementation RNNSheetDetentOptions + ++ (NSArray *)parseDetents:(id)json { + if (![json isKindOfClass:[NSArray class]]) { + return nil; + } + + NSMutableArray *detents = [NSMutableArray new]; + for (id item in (NSArray *)json) { + RNNSheetDetentOptions *detent = [RNNSheetDetentOptions parseDetent:item]; + if (detent != nil) { + [detents addObject:detent]; + } + } + + return detents.count > 0 ? detents : nil; +} + ++ (RNNSheetDetentOptions *)parseDetent:(id)item { + if ([item isKindOfClass:[NSString class]]) { + RNNSheetDetentOptions *detent = [RNNSheetDetentOptions new]; + detent.type = RNNSheetDetentTypeSystem; + detent.systemIdentifier = [[Text alloc] initWithValue:item]; + return detent; + } + + if (![item isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSDictionary *dict = (NSDictionary *)item; + Text *identifierText = [TextParser parse:dict key:@"id"]; + if (!identifierText.hasValue) { + return nil; + } + NSString *identifier = identifierText.get; + + RNNSheetDetentOptions *detent = [RNNSheetDetentOptions new]; + detent.type = RNNSheetDetentTypeCustom; + detent.customIdentifier = [[Text alloc] initWithValue:identifier]; + detent.height = [DoubleParser parse:dict key:@"height"]; + return detent; +} + +@end diff --git a/ios/ReactNativeNavigation.xcodeproj/project.pbxproj b/ios/ReactNativeNavigation.xcodeproj/project.pbxproj index c8324568c51..d80f46722e7 100644 --- a/ios/ReactNativeNavigation.xcodeproj/project.pbxproj +++ b/ios/ReactNativeNavigation.xcodeproj/project.pbxproj @@ -274,6 +274,8 @@ 50A00C38200F84D6000F01A6 /* RNNOverlayOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50A00C36200F84D6000F01A6 /* RNNOverlayOptions.mm */; }; 50A246372395399700A192C5 /* RNNModalOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 50A246352395399700A192C5 /* RNNModalOptions.h */; }; 50A246382395399700A192C5 /* RNNModalOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50A246362395399700A192C5 /* RNNModalOptions.mm */; }; + 50E7A00123BB5CD900717F01 /* RNNSheetDetentOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 50E7A00323BB5CD900717F03 /* RNNSheetDetentOptions.h */; }; + 50E7A00223BB5CD900717F02 /* RNNSheetDetentOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50E7A00423BB5CD900717F04 /* RNNSheetDetentOptions.mm */; }; 50A4962323FD51B900F4816D /* WindowOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 50A4962123FD51B900F4816D /* WindowOptions.h */; }; 50A4962423FD51B900F4816D /* WindowOptions.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50A4962223FD51B900F4816D /* WindowOptions.mm */; }; 50A5628A23DDAB5A0027C219 /* ScreenAnimationController.h in Headers */ = {isa = PBXBuildFile; fileRef = 50A5628823DDAB5A0027C219 /* ScreenAnimationController.h */; }; @@ -771,6 +773,8 @@ 50A00C36200F84D6000F01A6 /* RNNOverlayOptions.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNNOverlayOptions.mm; sourceTree = ""; }; 50A246352395399700A192C5 /* RNNModalOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNNModalOptions.h; sourceTree = ""; }; 50A246362395399700A192C5 /* RNNModalOptions.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNNModalOptions.mm; sourceTree = ""; }; + 50E7A00323BB5CD900717F03 /* RNNSheetDetentOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNNSheetDetentOptions.h; sourceTree = ""; }; + 50E7A00423BB5CD900717F04 /* RNNSheetDetentOptions.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNNSheetDetentOptions.mm; sourceTree = ""; }; 50A4962123FD51B900F4816D /* WindowOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WindowOptions.h; sourceTree = ""; }; 50A4962223FD51B900F4816D /* WindowOptions.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WindowOptions.mm; sourceTree = ""; }; 50A5628823DDAB5A0027C219 /* ScreenAnimationController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScreenAnimationController.h; sourceTree = ""; }; @@ -1297,6 +1301,8 @@ 30987122507D8CBF16624F93 /* DotIndicatorOptions.h */, 50A246352395399700A192C5 /* RNNModalOptions.h */, 50A246362395399700A192C5 /* RNNModalOptions.mm */, + 50E7A00323BB5CD900717F03 /* RNNSheetDetentOptions.h */, + 50E7A00423BB5CD900717F04 /* RNNSheetDetentOptions.mm */, 50D3A37023B8D77C00717F95 /* SharedElementTransitionOptions.h */, 50D3A37123B8D77C00717F95 /* SharedElementTransitionOptions.mm */, 50FCD83523FC102200000DD0 /* DeprecationOptions.h */, @@ -1895,6 +1901,7 @@ 5017D9F2239D2FCB00B74047 /* BottomTabsOnSwitchToTabAttacher.h in Headers */, 50706E6D20CE7CA5003345C3 /* UIImage+utils.h in Headers */, 50A246372395399700A192C5 /* RNNModalOptions.h in Headers */, + 50E7A00123BB5CD900717F01 /* RNNSheetDetentOptions.h in Headers */, 50996C6D23AA68B900008F89 /* DisplayLinkAnimator.h in Headers */, 30987680135A8C78E62D5B8E /* DotIndicatorOptions.h in Headers */, 30987CA5048A48D6CE76B06C /* DotIndicatorParser.h in Headers */, @@ -2081,6 +2088,7 @@ 5038A3BA216DFCFD009280BC /* UITabBarController+RNNOptions.mm in Sources */, 5030B62123D5B4CB008F1642 /* LNInterpolable.mm in Sources */, 50A246382395399700A192C5 /* RNNModalOptions.mm in Sources */, + 50E7A00223BB5CD900717F02 /* RNNSheetDetentOptions.mm in Sources */, 50A4962423FD51B900F4816D /* WindowOptions.mm in Sources */, 50E38DDE23A7A306009817F6 /* AnimatedImageView.mm in Sources */, 263905B21E4C6F440023D7D3 /* MMDrawerController.mm in Sources */, diff --git a/playground/e2e/SheetDetents.test.js b/playground/e2e/SheetDetents.test.js new file mode 100644 index 00000000000..487054575ed --- /dev/null +++ b/playground/e2e/SheetDetents.test.js @@ -0,0 +1,39 @@ +import TestIDs from '../src/testIDs'; +import Utils from './Utils'; + +const { elementById, elementByLabel } = Utils; + +describe('sheet detents', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await elementById(TestIDs.NAVIGATION_TAB).tap(); + await elementById(TestIDs.SHEET_DETENTS_BTN).tap(); + }); + + it('shows sheet modal with pageSheet presentation', async () => { + await expect(elementById(TestIDs.SHEET_DETENT_EXPAND_LARGE_BTN)).toBeVisible(); + await expect(elementByLabel('Sheet Detents')).toBeVisible(); + await expect(elementById(TestIDs.SHEET_DETENT_STATUS)).toHaveText('medium'); + }); + + it('updates selected detent via mergeOptions without crashing', async () => { + await elementById(TestIDs.SHEET_DETENT_EXPAND_LARGE_BTN).tap(); + await expect(elementById(TestIDs.SHEET_DETENT_STATUS)).toHaveText('large'); + + await elementById(TestIDs.SHEET_DETENT_MEDIUM_BTN).tap(); + await expect(elementById(TestIDs.SHEET_DETENT_STATUS)).toHaveText('medium'); + + await elementById(TestIDs.SHEET_DETENT_COMPACT_BTN).tap(); + await expect(elementById(TestIDs.SHEET_DETENT_STATUS)).toHaveText('compact'); + }); + + it.e2e(':android: keeps bottom tabs visible under pageSheet', async () => { + await expect(elementById(TestIDs.NAVIGATION_TAB)).toBeVisible(); + }); + + it('dismisses sheet modal', async () => { + await elementById(TestIDs.SHEET_DETENT_DISMISS_BTN).tap(); + await expect(elementById(TestIDs.SHEET_DETENT_STATUS)).not.toBeVisible(); + await expect(elementById(TestIDs.SHEET_DETENTS_BTN)).toBeVisible(); + }); +}); diff --git a/playground/src/screens/NavigationScreen.tsx b/playground/src/screens/NavigationScreen.tsx index d2e4da5b6c7..93ad8f3ed9a 100644 --- a/playground/src/screens/NavigationScreen.tsx +++ b/playground/src/screens/NavigationScreen.tsx @@ -73,6 +73,11 @@ export default class NavigationScreen extends NavigationComponent { onPress={this.showPageSheetModal} platform="ios" /> +