Skip to content

feat(Android, FormSheet v5): Add support for fractional detents#4251

Open
t0maboro wants to merge 17 commits into
mainfrom
@t0maboro/android-formsheet-detents-2
Open

feat(Android, FormSheet v5): Add support for fractional detents#4251
t0maboro wants to merge 17 commits into
mainfrom
@t0maboro/android-formsheet-detents-2

Conversation

@t0maboro

@t0maboro t0maboro commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Description

Adds configurable detents to the new FormSheets on Android. Due to the native limitations, it allows passing 0-3 detents from JS, which are parsed to BottomSheetBehavior params (peek/half-expanded/expanded/max-height).
I am introducing a FormSheetBehaviorController which is a stateless mapper from a resolved detent config to BottomSheetBehavior, forcing a fresh layout pass so the behavior re-settles to the new detent configuration.

Additionally, I am making some bugfixes related to or exposed by introducing the sheet detents mechanism.

  • Enter-animation flicker on API < 30 - Material's BottomSheetBehavior registers a WindowInsetsAnimationCallback which updates translationY and is causing the sheet to jump during the enter animation (I am modifying translation on a custom slide-in animation). We clear that callback after the first layout, because we manage insets ourselves via a fixed container height, preventing the conflict.
  • Material only draws the BottomSheetDialog edge-to-edge when the theme opts in, and the nav bar is translucent; otherwise, the CoordinatorLayout (and our full-bleed dimming view) is inset below the status bar. We now force edge-to-edge on the dialog window on every API level.

Closes: https://github.com/software-mansion/react-native-screens-labs/issues/1551

Changes

  • Introduced FormSheetBehaviorController responsible for mapping the raw JS detents array into specific BottomSheetBehavior properties.
  • Disabled native insets animation to explicitly clear Material's WindowInsetsAnimationCallback, unblocking our custom translationY enter-animation.
  • Forced Edge-to-Edge in onAttachedToWindow() in FormSheetDialog on both the window decor and the internal Material containers (R.id.container, R.id.coordinator), ensuring the dimming view covers the status bar area and the FormSheetContainer space is predictable across different API levels.

Before & after - visual documentation

detents.mov

Test plan

Tested on the base example &

import React, { useState } from 'react';
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
import { FormSheet, SafeAreaView } from 'react-native-screens/experimental';
import { scenarioDescription } from './scenario-description';
import { createScenario } from '@apps/tests/shared/helpers';
import { Colors } from '@apps/shared/styling';

const DETENT_CONFIGS = [
  { label: 'Single detent (0.5)', detents: [0.5] },
  { label: 'Full screen (1.0)', detents: [1.0] },
  { label: 'Two detents (0.3, 0.8)', detents: [0.3, 0.8] },
  { label: 'Half and full (0.5, 1.0)', detents: [0.5, 1.0] },
  { label: 'Three detents (0.2, 0.5, 0.9)', detents: [0.2, 0.5, 0.9] },
  { label: 'Three tall (0.35, 0.65, 1.0)', detents: [0.35, 0.65, 1.0] },
  { label: 'Default fallback (empty array)', detents: [] },
];

function SheetScenario({ label, detents }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <View style={styles.scenarioContainer}>
      <Button
        title={`Open: ${label}`}
        color={Colors.primary}
        onPress={() => setIsOpen(true)}
      />

      <FormSheet
        isOpen={isOpen}
        onNativeDismiss={() => setIsOpen(false)}
        detents={detents}>
        <View style={styles.sheetContent}>
          <Text style={styles.sheetTitle}>FormSheet Content</Text>
          <Text style={styles.sheetSubtitle}>
            Static configuration: {JSON.stringify(detents)}
          </Text>
          <View style={styles.spacing} />
          <Button
            title="Close sheet from JS"
            color={Colors.primary}
            onPress={() => setIsOpen(false)}
          />
        </View>
      </FormSheet>
    </View>
  );
}

function TestFormSheetBase() {
  return (
    <SafeAreaView edges={{top: true, bottom: true}} style={styles.container}>
      <Text style={styles.title}>FormSheet Configuration Tests</Text>

      <ScrollView
        style={styles.scrollContainer}
        contentContainerStyle={styles.scrollContent}>
        {DETENT_CONFIGS.map((config, index) => (
          <SheetScenario
            key={index}
            label={config.label}
            detents={config.detents}
          />
        ))}
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.offBackground,
  },
  scrollContainer: {
    flex: 1,
  },
  scrollContent: {
    padding: 16,
    paddingBottom: 40,
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
    marginVertical: 20,
    color: Colors.text,
    textAlign: 'center',
  },
  scenarioContainer: {
    marginBottom: 16,
    backgroundColor: 'rgba(0,0,0,0.03)',
    padding: 12,
    borderRadius: 8,
    borderWidth: 1,
    borderColor: 'rgba(0,0,0,0.1)',
  },
  sheetContent: {
    flex: 1,
    backgroundColor: Colors.background,
    padding: 24,
    justifyContent: 'center',
    alignItems: 'center',
  },
  sheetTitle: {
    fontSize: 22,
    fontWeight: '600',
    marginBottom: 8,
    color: Colors.text,
  },
  sheetSubtitle: {
    fontSize: 14,
    color: Colors.text,
    opacity: 0.7,
    fontFamily: 'monospace',
  },
  spacing: {
    height: 32,
  },
});

export default createScenario(TestFormSheetBase, scenarioDescription);

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds fractional detent support for the new Android FormSheet (gamma) by mapping a JS detents array into BottomSheetBehavior configuration, while also addressing visual/insets issues (enter-animation flicker on API < 30 and non-edge-to-edge dialog layout).

Changes:

  • Plumbs detents from the RN prop layer (FormSheetHostViewManager/FormSheetHost) into FormSheetConfig and FormSheetDialogManager.
  • Introduces FormSheetDetents + FormSheetBehaviorController to validate detent input and apply corresponding BottomSheetBehavior params (peek/half-expanded/expanded/maxHeight) with a forced re-layout.
  • Forces edge-to-edge rendering in FormSheetDialog and disables Material’s insets animation callback to prevent translationY conflicts with the custom enter animation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetHostViewManager.kt Implements detents prop parsing from ReadableArray into a Kotlin list.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetHost.kt Stores detents on the host and passes them into FormSheetConfig.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetDialogManager.kt Resolves detents, triggers reconfiguration on open/config change, updates container height, and disables Material insets animation callback.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetDialog.kt Forces edge-to-edge drawing for consistent dimming + layout across API levels.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetDetents.kt Adds detent validation + helpers for computing pixel heights and container sizing.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetConfig.kt Extends config with detents.
android/src/main/java/com/swmansion/rnscreens/gamma/modals/formsheet/FormSheetBehaviorController.kt Stateless mapper from resolved detents to BottomSheetBehavior properties.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

t0maboro and others added 2 commits July 2, 2026 17:13
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@kkafar kkafar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job. I think that the abstractions you've introduced make sense.

I have series of remarks regarding some details. Let's iron them out before proceeding.

isFitToContents = true
peekHeight = detents.firstHeight(sheetAvailableSpace)
maxHeight = detents.maxAllowedHeight(sheetAvailableSpace)
// TODO: @t0maboro - in v4 impl the state was passed as a param, consider the same approach

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it might be useful to support initialDetentIndex or whatever prop.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed in this very moment though. Looks good. You might modify that on is-needed basis.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was also some logic in v4 SheetDelegate impl. around the keyboard handling - I haven't verified whether it still applies in the new implementation, so leaving myself a note to revisit this line

Comment on lines 19 to 20
hideNativeDimmingView(window)
disableNativeWindowAnimation(window)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines have not beed modified in this PR, but I notice them here for the first time.

They are risky. AFAIK the window object you modify in their implementation is shared between FormSheetDialog view hierarchy, "original view hierarchy" (where react root view resides), and any other dialog opened before/after the form sheet.

Therefore modifying global window settings will affect behaviour of not only the formsheet, but also all other dialogs.

E.g. if you disable the dimming view this way, my prediction is that the <Modal /> component from react-native won't have dimming view anymore. Similarly the animations will be disabled.

Please verify this. In case I'm right (let's hope I'm wrong).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay! We did some research together with @t0maboro and it turns out that each Dialog on Android creates it's own window instance! That's great then.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +39 to +44
private fun forceEdgeToEdge() {
val window = window ?: return
WindowCompat.setDecorFitsSystemWindows(window, false)
findViewById<View>(com.google.android.material.R.id.container)?.fitsSystemWindows = false
findViewById<View>(com.google.android.material.R.id.coordinator)?.fitsSystemWindows = false
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an important assumption. We MUST emphasise this in the component description that right now we enforce / assume edge-to-edge mode being enabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -81,12 +126,27 @@ class FormSheetDialogManager(
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
view.translationY = view.height.toFloat()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we set the view.translationY here? You already do that in runEnterAnimation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed this line & it seems to be working just fine. Please see to it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've noticed this, because setting translationY for animation purposes in onPreDraw seems rather out of place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
view.translationY = view.height.toFloat()
disableMaterialInsetsAnimationCallback(view)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this one: why do we do it in onPreDraw? Can't we do it much earlier, e.g. after the BottomSheetBehavior of the formsheet is initialized for the very first time? When does BottomSheetBehavior / BottomSheetDialog register this callback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm already changing it in the followup PR to use doOnLayout

private fun disableMaterialInsetsAnimationCallback(view: FrameLayout) {
view.doOnLayout {
ViewCompat.setWindowInsetsAnimationCallback(it, null)
}
}
- let me know if that's okay regarding timing, I did it on pre-draw here to ensure that I am after the layout pass

Comment on lines +182 to 184
private fun updateNativeContainerHeight() {
val dialogDecorHeight = dialog.window?.decorView?.height ?: 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a style guide -> if there is nothing to do if dialogDecorHeight == 0 then just early return and remove one level of indentation from the rest of the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would make more sense to take null as any other API in Android, instead of "empty callback".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

companion object {
private const val LARGE_DETENT = 1.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private const val LARGE_DETENT = 1.0
private const val LARGE_DETENT_FRACTION = 1.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// TODO: @t0maboro
// - a dedicated presentation manager should be introduced as on iOS,
// - invalidation flags logic should be implemented following other components convention
val isOpening = newConfig.isOpen && !formSheetConfig.isOpen

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already test for that earlier in this function. Please move this value to the top of the scope and reuse it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@t0maboro t0maboro requested a review from kkafar July 3, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants