diff --git a/.yarn/patches/react-native-npm-0.85.3.patch b/.yarn/patches/react-native-npm-0.85.3.patch deleted file mode 100644 index 0321acd7..00000000 --- a/.yarn/patches/react-native-npm-0.85.3.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm ---- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm -+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm -@@ -1614,7 +1614,7 @@ - - - (BOOL)canBecomeFirstResponder - { -- return YES; -+ return ReactNativeFeatureFlags::enableImperativeFocus(); - } - - - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args diff --git a/AGENTS.md b/AGENTS.md index d7b501ec..38acf5fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,13 @@ Add entry to `Unreleased` section in `CHANGELOG.md` for user-facing changes: - `πŸŽ‰ New features` - New functionality - `πŸ› Bug fixes` - Bug fixes +- `πŸ’₯ Breaking changes` - Changes requiring user action to upgrade - `πŸ’‘ Others` - Refactors, internal changes Format: `- **Platform**: Description. ([#123](https://github.com/lodev09/react-native-true-sheet/pull/123) by [@username](https://github.com/username))` +One entry per PR β€” summarize the PR's user-facing change in a single entry under its primary section. + +Be concise β€” one or two short sentences stating the change and what it means for the user. No implementation details. + Sort entries by platform: **iOS** first, then **Android**, then **Web**, then cross-platform or unscoped entries last. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51380357..0a034073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,592 +2,31 @@ ## Unreleased -## 3.11.12 - -### πŸ› Bug fixes - -- **Android**: Touchables rendered after a `View` with `zIndex > 0` that contains a `TrueSheet` respond again β€” the host view's `pointerEvents` (`box-none`) was silently dropped by the codegen delegate, so hit-testing could target the full-screen hidden host and swallow touches behind it. ([#787](https://github.com/lodev09/react-native-true-sheet/pull/787) by [@lodev09](https://github.com/lodev09)) -- Touchables inside a sheet fire their `onPress` again when the sheet content contains a nested native-stack screen (or a second finger touches the sheet mid-press) β€” since [#767](https://github.com/lodev09/react-native-true-sheet/pull/767), the sheet claimed the responder whenever the negotiation re-ran mid-gesture, stealing it from the pressed touchable. The sheet now stops the negotiation at its boundary without claiming, which also keeps the touch-leak fix without any `setJSResponder` side effects. ([#786](https://github.com/lodev09/react-native-true-sheet/pull/786) by [@lodev09](https://github.com/lodev09)) - -## 3.11.11 - -### πŸ› Bug fixes - -- **iOS**: Opaque `backgroundColor` now renders exactly on iOS 26.1+ when the sheet is presented over a full-screen presenter (e.g. a `fullScreenModal` route or RN `Modal`) β€” `UIColorEffect` tints the liquid glass rather than replacing it, shifting dark low-chroma colors, so the controller view is now painted as well. ([#779](https://github.com/lodev09/react-native-true-sheet/pull/779) by [@lodev09](https://github.com/lodev09)) -- **Android**: Touches on the sheet no longer fire the `onPress` of a `Pressable` outside it β€” the fix in [#767](https://github.com/lodev09/react-native-true-sheet/pull/767) never took effect here. A touch that hit no React child was dispatched to JS twice (once from `onInterceptTouchEvent`, once from `onTouchEvent`), and the second stream found the sheet already holding the responder, so React only asked its ancestors. ([#777](https://github.com/lodev09/react-native-true-sheet/pull/777) by [@lodev09](https://github.com/lodev09)) -- Scrollable sheets scroll again when their content isn't wrapped in a touchable β€” [#767](https://github.com/lodev09/react-native-true-sheet/pull/767) claimed unclaimed touches on the sheet container, making an ancestor of the `ScrollView` the JS responder, which blocks scrolling on both platforms. The touch handlers moved to the sheet's host view, which is never a native ancestor of the content. ([#777](https://github.com/lodev09/react-native-true-sheet/pull/777) by [@lodev09](https://github.com/lodev09)) - -## 3.11.10 - -### πŸŽ‰ New features - -- New `accessibilityOptions` prop for customizing (e.g. localizing) the accessibility strings announced by screen readers β€” grabber label/hint, detent state values (with `{index}`/`{count}` placeholders), Android expand/collapse action labels, and the sheet pane/dialog title on Android and Web. ([#770](https://github.com/lodev09/react-native-true-sheet/pull/770) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Android**: The first tap on a `Pressable` is no longer swallowed after a `TextInput` in the same sheet (or footer) is touched β€” the sheet's root views now forward `requestDisallowInterceptTouchEvent` up the tree (mirroring `ReactSurfaceView`) so the touch dispatcher sees the gesture end and the stale responder is released. ([#765](https://github.com/lodev09/react-native-true-sheet/pull/765), [#766](https://github.com/lodev09/react-native-true-sheet/pull/766) by [@lodev09](https://github.com/lodev09)) -- **Android**: TalkBack focus is now trapped within a modal (dimmed) sheet β€” background views are hidden from accessibility while presented and restored on dismiss, and focus moves into the sheet on present. The grabber announces as "Drag handle" and is read before sheet content. ([#768](https://github.com/lodev09/react-native-true-sheet/pull/768) by [@lodev09](https://github.com/lodev09)) -- **Android**: Hardware back press during sheet teardown no longer throws `Could not get native view tag` β€” when the native view is detached (e.g. host screen unmounted by navigation) before the back handler is removed, back press is now a no-op and propagates. ([#769](https://github.com/lodev09/react-native-true-sheet/pull/769) by [@lodev09](https://github.com/lodev09)) -- Keyboard-driven sheet growth no longer corrupts `animatedIndex` β€” iOS skips detent offset learning while the keyboard has the sheet grown and re-settles once it's away; Android clamps expected detent tops to the available height. Settle emits now learn at the final frame and bypass the dedupe, so presenting and settling land exactly on the detent index. ([#762](https://github.com/lodev09/react-native-true-sheet/pull/762) by [@lodev09](https://github.com/lodev09)) -- Touches on the sheet no longer leak to touchables outside it β€” tapping the sheet surface used to fire the `onPress` of a parent `Pressable` since unclaimed touches bubble up the React tree. The sheet container now claims them and stops touch propagation at the sheet boundary. ([#767](https://github.com/lodev09/react-native-true-sheet/pull/767) by [@lodev09](https://github.com/lodev09)) - -## 3.11.9 - -### πŸ› Bug fixes - -- **Web**: The `peek` detent's `TrueSheetPeek` content offset is now measured live during initial presentation β€” previously the first `willPresent` on autopresent missed it (state lags mount by a frame) and reported only the header/footer height. ([#742](https://github.com/lodev09/react-native-true-sheet/pull/742) by [@lodev09](https://github.com/lodev09)) - -## 3.11.8 - -### πŸ› Bug fixes - -- **Web**: Initial presentation events now report accurate `auto` and `peek` detent geometry by waiting until the drawer is mounted and measurable. ([#740](https://github.com/lodev09/react-native-true-sheet/pull/740) by [@lodev09](https://github.com/lodev09)) -- **Web**: Scrollable sheets at the maximum detent now continue dragging after Chrome mobile cancels the pointer stream during scroll bounce. ([#741](https://github.com/lodev09/react-native-true-sheet/pull/741) by [@lodev09](https://github.com/lodev09)) - -## 3.11.7 - -### πŸ› Bug fixes - -- **Android**: The sheet can now be dragged from views overlaying a scrollable β€” e.g. a header (including a `TextInput` in it) positioned over a `FlatList` β€” previously the drag did nothing. ([#734](https://github.com/lodev09/react-native-true-sheet/pull/734) by [@lodev09](https://github.com/lodev09)) -- **Web**: `onWillPresent`, `onDidPresent`, and `onDetentChange` now emit the target detent `position`/`detent` β€” consistent with iOS/Android. Previously `onWillPresent` emitted `0` on first present or the offscreen position on re-present, `onDetentChange` emitted the old position, and `detent` was `0` for `auto`/`peek` detents. ([#737](https://github.com/lodev09/react-native-true-sheet/pull/737) by [@lodev09](https://github.com/lodev09)) -- **Web**: Fixed scrollable content cutting off mid-drag, scrolling along with the sheet drag on mobile, and delayed drag after Safari's scroll bounce. ([#739](https://github.com/lodev09/react-native-true-sheet/pull/739) by [@lodev09](https://github.com/lodev09)) - -## 3.11.6 - -### πŸ› Bug fixes - -- **iOS**: `onPositionChange` no longer emits a slightly fractional `index` during the present transition β€” the detent offset is now learned before positions are emitted, so the index lands exactly on the target detent instead of snapping after `didPresent`. ([#731](https://github.com/lodev09/react-native-true-sheet/pull/731) by [@lodev09](https://github.com/lodev09)) - -## 3.11.5 - -### πŸŽ‰ New features - -- New `TrueSheetPeek` component that marks part of the content as the sheet's peek content β€” the `peek` detent height covers the content through its bottom edge (measured natively), along with the `header` and `footer` heights. ([#730](https://github.com/lodev09/react-native-true-sheet/pull/730) by [@lodev09](https://github.com/lodev09)) - -## 3.11.4 - -### πŸ› Bug fixes - -- **iOS**: Fixed tvOS builds crashing at launch (`RCTThirdPartyComponentsProvider` nil class). The podspec now supports tvOS by compiling inert stub components β€” sheets remain unsupported on tvOS. ([#729](https://github.com/lodev09/react-native-true-sheet/pull/729) by [@lodev09](https://github.com/lodev09)) - -### πŸŽ‰ New features - -- **iOS**, **Android**: Scrollable sheets now follow the text caret as the user types, scrolling to the caret line instead of the input's full bounds. ([#723](https://github.com/lodev09/react-native-true-sheet/pull/723) by [@bigcupcoffee](https://github.com/bigcupcoffee)) -- New `peek` detent that collapses the sheet to the combined `header` and `footer` height, falling back to a fixed `150` when neither is provided. ([#728](https://github.com/lodev09/react-native-true-sheet/pull/728) by [@lodev09](https://github.com/lodev09)) - -## 3.11.3 - -### πŸ› Bug fixes - -- **Web**: A `presentation="form"` sheet no longer floats on mobile portrait when `detached` is not set; it stays edge-attached, mirroring iOS form-sheet behavior on compact devices. ([#724](https://github.com/lodev09/react-native-true-sheet/pull/724) by [@lodev09](https://github.com/lodev09)) - -## 3.11.2 - -### πŸŽ‰ New features - -- **iOS**: The system back-swipe now interactively drags the sheet closed, tracking the finger and emitting `onPositionChange` through the drag and cancel/commit settle. ([#719](https://github.com/lodev09/react-native-true-sheet/pull/719) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Android**: A non-dismissible sheet no longer collapses to the first detent on back; back now propagates so navigation can go back to the previous screen. ([#719](https://github.com/lodev09/react-native-true-sheet/pull/719) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed build failure on Xcode versions older than 26 (e.g. Xcode 16 / iOS 18.5 SDK) by guarding the iOS 26 scroll-edge interaction code behind a compile-time SDK check. ([#721](https://github.com/lodev09/react-native-true-sheet/pull/721) by [@murattil](https://github.com/murattil)) - -## 3.11.1 - -### πŸŽ‰ New features - -- Added `footerOptions.keyboardOffset` to adjust how far the footer rises when the keyboard opens. Pass `-insets.bottom` to tuck its safe-area padding behind the keyboard instead of leaving a gap. ([#716](https://github.com/lodev09/react-native-true-sheet/pull/716) by [@bigcupcoffee](https://github.com/bigcupcoffee)) - -## 3.11.0 - ### πŸŽ‰ New features -- **Web**: New `vaul`-based renderer replacing `@gorhom/bottom-sheet`, with full feature parity. ([#639](https://github.com/lodev09/react-native-true-sheet/pull/639), [#675](https://github.com/lodev09/react-native-true-sheet/pull/675), [#676](https://github.com/lodev09/react-native-true-sheet/pull/676), [#677](https://github.com/lodev09/react-native-true-sheet/pull/677), [#678](https://github.com/lodev09/react-native-true-sheet/pull/678), [#679](https://github.com/lodev09/react-native-true-sheet/pull/679), [#684](https://github.com/lodev09/react-native-true-sheet/pull/684), [#687](https://github.com/lodev09/react-native-true-sheet/pull/687), [#692](https://github.com/lodev09/react-native-true-sheet/pull/692) by [@lodev09](https://github.com/lodev09)) +- **iOS**: Smoother `onPositionChange`, and `onWillDismiss` now fires only when a drag-to-dismiss is committed. ([#744](https://github.com/lodev09/react-native-true-sheet/pull/744), [#756](https://github.com/lodev09/react-native-true-sheet/pull/756) by [@lodev09](https://github.com/lodev09)) +- The `auto` detent now works with plugged scrollables β€” the sheet sizes to the scrollable's content height. ([#743](https://github.com/lodev09/react-native-true-sheet/pull/743) by [@lodev09](https://github.com/lodev09)) +- New `headerOptions` prop with a `position` option β€” set to `'absolute'` to float the header over the content and exclude it from the `auto` detent height. ([#747](https://github.com/lodev09/react-native-true-sheet/pull/747) by [@lodev09](https://github.com/lodev09)) +- The footer now absorbs the bottom safe-area inset as padding when `insetAdjustment` is `automatic`, except while the keyboard is open. ([#749](https://github.com/lodev09/react-native-true-sheet/pull/749), [#750](https://github.com/lodev09/react-native-true-sheet/pull/750) by [@lodev09](https://github.com/lodev09)) +- New `scrollableOptions.keyboardOffset` option to adjust the scrollable's keyboard bottom inset. ([#785](https://github.com/lodev09/react-native-true-sheet/pull/785) by [@lodev09](https://github.com/lodev09)) ### πŸ› Bug fixes -- **iOS**: Improved accessibility support so sheet content, footer controls, and controllers presented over the sheet (alert, action sheet, image picker) are reliably reachable by XCTest and assistive technologies. ([#699](https://github.com/lodev09/react-native-true-sheet/pull/699), [#700](https://github.com/lodev09/react-native-true-sheet/pull/700) by [@erickreutz](https://github.com/erickreutz), [#702](https://github.com/lodev09/react-native-true-sheet/pull/702), [#703](https://github.com/lodev09/react-native-true-sheet/pull/703) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed Mac Catalyst build issue. ([#685](https://github.com/lodev09/react-native-true-sheet/pull/685) by [@theeket](https://github.com/theeket)) -- **iOS**: Fixed footer rendering at the bottom of the content view instead of the bottom of the sheet when the footer view is recycled across present cycles. ([#688](https://github.com/lodev09/react-native-true-sheet/pull/688) by [@lucaswickstrom](https://github.com/lucaswickstrom)) -- **Android**: Fixed `resize()` being interrupted when sheet content size changes during the animation, causing the sheet to revert to the previous detent. ([#693](https://github.com/lodev09/react-native-true-sheet/pull/693) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed horizontal child gestures inside the `footer` or sheet body being cancelled by `BottomSheetBehavior` when a touch drifted vertically past `touchSlop`. Footer touches are now latched on `ACTION_DOWN` and routed for the full stream, and horizontal-dominant gestures bypass sheet drag β€” matching iOS. ([#698](https://github.com/lodev09/react-native-true-sheet/pull/698) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed sheet drag stealing touches that start over a `TextInput`, and vertical child gestures (e.g. a `react-native-gesture-handler` pan) being cancelled by sheet drag before they could activate. The sheet now yields the touch stream once a child claims a native gesture β€” matching iOS. ([#712](https://github.com/lodev09/react-native-true-sheet/pull/712) by [@bigcupcoffee](https://github.com/bigcupcoffee) and [@lodev09](https://github.com/lodev09)) -- Fixed the Sheet Navigator breaking under Expo Router SDK 56 by importing from `@react-navigation/core` (auto-rewritten to `expo-router/react-navigation` on SDK 56, resolves normally in bare projects). The optional peer dependency changed from `@react-navigation/native` to `@react-navigation/core`. ([#695](https://github.com/lodev09/react-native-true-sheet/pull/695) by [@lodev09](https://github.com/lodev09)) +- **Android**: A stacked parent sheet now follows its child frame-by-frame when the child resizes, and no longer gets stuck at stale detents after a half-expanded settle. ([#797](https://github.com/lodev09/react-native-true-sheet/pull/797) by [@lodev09](https://github.com/lodev09)) +- **Android**: The scrollable content now tracks the keyboard frame-by-frame β€” the focused input rides the keyboard up smoothly, and the scroll position eases back on dismiss instead of snapping. ([#798](https://github.com/lodev09/react-native-true-sheet/pull/798) by [@lodev09](https://github.com/lodev09)) +- The keyboard-driven scroll inset now accounts for a floating footer's height. ([#752](https://github.com/lodev09/react-native-true-sheet/pull/752) by [@lodev09](https://github.com/lodev09)) +- Auto detents now correctly size explicit-height and deeply nested scrollables. ([#783](https://github.com/lodev09/react-native-true-sheet/pull/783) by [@lodev09](https://github.com/lodev09)) -### ⚠️ Breaking +### πŸ’₯ Breaking changes -- Renamed `pageSizing: boolean` to `presentation: 'page' | 'form'` (default `'page'`). `presentation='form'` is absolute and ignores `maxContentWidth`. Migration: `pageSizing={true}` β†’ `presentation='page'` (default); `pageSizing={false}` β†’ `presentation='form'`. ([#680](https://github.com/lodev09/react-native-true-sheet/pull/680) by [@lodev09](https://github.com/lodev09)) -- **Web**: Removed `stackBehavior` prop; stacking is now handled automatically. ([#639](https://github.com/lodev09/react-native-true-sheet/pull/639)) -- **Web**: Peer dependency changed from `@gorhom/bottom-sheet` to `@radix-ui/react-dialog`. ([#639](https://github.com/lodev09/react-native-true-sheet/pull/639)) +- The footer now lays out relative by default, taking up space below the content β€” set `footerOptions.position` to `'absolute'` to restore the previous floating behavior. ([#748](https://github.com/lodev09/react-native-true-sheet/pull/748), [#754](https://github.com/lodev09/react-native-true-sheet/pull/754) by [@lodev09](https://github.com/lodev09)) +- Content now wraps its children's height by default instead of filling the sheet β€” pass `flex: 1` via `style` to fill. ([#746](https://github.com/lodev09/react-native-true-sheet/pull/746) by [@lodev09](https://github.com/lodev09)) +- The container is now sized to the sheet's visible height per detent and tracks it in realtime while dragging, requiring React Native 0.82+. ([#735](https://github.com/lodev09/react-native-true-sheet/pull/735) by [@lodev09](https://github.com/lodev09)) +- The scrollable's bottom safe-area inset now mirrors iOS's `contentInsetAdjustmentBehavior="automatic"` on all platforms β€” opt out with the new `scrollableOptions.contentInsetAdjustmentBehavior`. ([#784](https://github.com/lodev09/react-native-true-sheet/pull/784), [#796](https://github.com/lodev09/react-native-true-sheet/pull/796) by [@lodev09](https://github.com/lodev09)) +- The `scrollable` prop is replaced by `scrollableRef` β€” pass a ref of your `ScrollView`/`FlatList` to wire nested scrolling, keyboard insets, and `auto` detent sizing. ([#795](https://github.com/lodev09/react-native-true-sheet/pull/795) by [@lodev09](https://github.com/lodev09)) +- The sheet navigator is now built on the new `standard-navigation` peer dependency, with first-class Expo Router support via the `Sheet` layout from the new `/navigation/expo-router` entry point. ([#772](https://github.com/lodev09/react-native-true-sheet/pull/772) by [@lodev09](https://github.com/lodev09)) ### πŸ’‘ Others -- Bump `react-native-monorepo-config` to fix build on windows. ([#672](https://github.com/lodev09/react-native-true-sheet/pull/672) by [@harveylx](https://github.com/harveylx)) -- Upgrade examples to Expo SDK 56 / React Native 0.85.3, TypeScript 6, and `react-native-builder-bob` 0.41. ([#694](https://github.com/lodev09/react-native-true-sheet/pull/694) by [@lodev09](https://github.com/lodev09)) -- **Docs**: Documented that Expo SDK 56+ ships `react-native-screens` precompiled on EAS, silently dropping the navigation patch; added a Troubleshooting fix (`EXPO_USE_PRECOMPILED_MODULES=0`). ([#715](https://github.com/lodev09/react-native-true-sheet/pull/715) by [@lodev09](https://github.com/lodev09)) - -## 3.10.1 - -### πŸ› Bug fixes - -- **Android**: Emit `onDidFocus` after the parent's translate-up animation completes when a stacked child sheet is dismissed. ([#666](https://github.com/lodev09/react-native-true-sheet/pull/666) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed focused input in sheet causing auto-focus on main screen input after dismiss. ([#649](https://github.com/lodev09/react-native-true-sheet/pull/649) by [@lodev09](https://github.com/lodev09)) - -## 3.10.0 - -### πŸŽ‰ New features - -- Add accessibility support to grabber view with VoiceOver/TalkBack actions and state descriptions. ([#587](https://github.com/lodev09/react-native-true-sheet/pull/587) by [@lodev09](https://github.com/lodev09)) -- Add `scrollingExpandsSheet` option to `scrollableOptions`. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Add `topScrollEdgeEffect` and `bottomScrollEdgeEffect` to `scrollableOptions` for iOS 26+. ([#595](https://github.com/lodev09/react-native-true-sheet/pull/595) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **iOS**: Fixed blur flicker/flash at the bottom when dismissing sheet with `backgroundBlur` enabled. ([#633](https://github.com/lodev09/react-native-true-sheet/pull/633) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed `NoSuchMethodError` crash on Android < 11 (API 30) when presenting a sheet with grabber accessibility. ([#606](https://github.com/lodev09/react-native-true-sheet/pull/606) by [@Mohamed-kassim](https://github.com/Mohamed-kassim)) -- **iOS**: Fixed keyboard scroll positioning when sheet auto-expands from a smaller detent. ([#592](https://github.com/lodev09/react-native-true-sheet/pull/592) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed dead state after rapid present/dismiss cycles. ([#593](https://github.com/lodev09/react-native-true-sheet/pull/593) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed position change not emitting when detent or index changed. ([#584](https://github.com/lodev09/react-native-true-sheet/pull/584) by [@lodev09](https://github.com/lodev09)) -- **Android**: Use RN `BackHandler` for back press detection for reliability across Android versions. ([#580](https://github.com/lodev09/react-native-true-sheet/pull/580) by [@lodev09](https://github.com/lodev09)) - -### πŸ’‘ Others - -- **iOS**: Use codegen enum types instead of `NSInteger` casts for better type safety. ([#612](https://github.com/lodev09/react-native-true-sheet/pull/612) by [@lodev09](https://github.com/lodev09)) -- Add docs versioning with automated release script. ([#586](https://github.com/lodev09/react-native-true-sheet/pull/586) by [@lodev09](https://github.com/lodev09)) -- Add missing `layout` and `screenLayout` props to `TrueSheetNavigator`. ([#615](https://github.com/lodev09/react-native-true-sheet/pull/615) by [@bram-dc](https://github.com/bram-dc)) -- Add `truesheet-usage` AI skill and documentation for AI coding agents. ([#621](https://github.com/lodev09/react-native-true-sheet/pull/621) by [@mehradotdev](https://github.com/mehradotdev)) -- Upgrade example to Expo SDK 55 and RN 0.83. ([#630](https://github.com/lodev09/react-native-true-sheet/pull/630) by [@lodev09](https://github.com/lodev09)) -- Add RN 0.83 keyboard animation workaround to troubleshooting docs. ([#632](https://github.com/lodev09/react-native-true-sheet/pull/632) by [@lodev09](https://github.com/lodev09)) - -### ⚠️ Breaking - -- **Android**: `nestedScrollingEnabled` is now automatically managed when `scrollable` is enabled. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585)) - -## 3.9.9 - -### πŸ› Bug fixes - -- **Android**: Fixed back handler blocking navigation when sheet is hidden for screen. ([#577](https://github.com/lodev09/react-native-true-sheet/pull/577) by [@lodev09](https://github.com/lodev09)) - -## 3.9.8 - -### πŸ› Bug fixes - -- **iOS**: Fixed custom grabber missing tap-to-cycle-detent behavior. ([#571](https://github.com/lodev09/react-native-true-sheet/pull/571) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed pull to refresh not working for short ScrollViews. ([#570](https://github.com/lodev09/react-native-true-sheet/pull/570) by [@lodev09](https://github.com/lodev09)) - -## 3.9.7 - -### πŸ› Bug fixes - -- **Android**: Fixed first interaction with remounted nested ScrollView dragging sheet instead of scrolling. ([#564](https://github.com/lodev09/react-native-true-sheet/pull/564) by [@lodev09](https://github.com/lodev09)) - -## 3.9.6 - -### πŸ› Bug fixes - -- **Android**: Fixed nested scroll breaking when ScrollView is conditionally removed and re-added. ([#562](https://github.com/lodev09/react-native-true-sheet/pull/562) by [@lodev09](https://github.com/lodev09)) - -## 3.9.5 - -### πŸ› Bug fixes - -- **iOS**: Fixed touch passthrough on undimmed sheets when `dismissible` is `false`. ([#548](https://github.com/lodev09/react-native-true-sheet/pull/548) by [@isaacrowntree](https://github.com/isaacrowntree)) -- **iOS**: Fixed `animatedIndex`/`animatedDetent` reporting wrong values during drag and transitions. ([#558](https://github.com/lodev09/react-native-true-sheet/pull/558) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed detent updates being ignored when changed during presentation animation. ([#557](https://github.com/lodev09/react-native-true-sheet/pull/557) by [@lodev09](https://github.com/lodev09)) - -### πŸ’‘ Others - -- Documented `auto` detent limitation when `scrollable` is enabled. ([#554](https://github.com/lodev09/react-native-true-sheet/pull/554) by [@lodev09](https://github.com/lodev09)) - -## 3.9.4 - -### πŸ› Bug fixes - -- **iOS**: Fixed content size changes being dropped during presentation animation when using `initialDetentIndex`. ([#551](https://github.com/lodev09/react-native-true-sheet/pull/551) by [@lodev09](https://github.com/lodev09)) - -## 3.9.3 - -### πŸ› Bug fixes - -- **iOS**: Fixed incorrect detent calculations on iPad when using Stage Manager or Split View. ([#550](https://github.com/lodev09/react-native-true-sheet/pull/550) by [@lodev09](https://github.com/lodev09)) - -## 3.9.2 - -### πŸ› Bug fixes - -- **Web**: Fixed content overflow not clipping to rounded corners. ([#546](https://github.com/lodev09/react-native-true-sheet/pull/546) by [@lodev09](https://github.com/lodev09)) - -## 3.9.1 - -### πŸ› Bug fixes - -- **iOS**: Fixed custom grabber not centering on device rotation. ([#545](https://github.com/lodev09/react-native-true-sheet/pull/545) by [@lodev09](https://github.com/lodev09)) -- **Android**: Removed unreliable `TransitionManager` animation from gravity and maxWidth updates. ([#544](https://github.com/lodev09/react-native-true-sheet/pull/544) by [@lodev09](https://github.com/lodev09)) - -## 3.9.0 - -### ⚠️ Breaking changes - -- `maxHeight` prop has been renamed to `maxContentHeight`. ([#495](https://github.com/lodev09/react-native-true-sheet/pull/495) by [@lodev09](https://github.com/lodev09)) - -### πŸŽ‰ New features - -- **iOS**: Enable synchronous state update for RN 0.82+. ([#505](https://github.com/lodev09/react-native-true-sheet/pull/505), [#507](https://github.com/lodev09/react-native-true-sheet/pull/507) by [@lodev09](https://github.com/lodev09)) -- **Web**: Added `detached` and `detachedOffset` props for floating card mode. ([#527](https://github.com/lodev09/react-native-true-sheet/pull/527) by [@lodev09](https://github.com/lodev09)) -- **Navigation**: Added missing sheet props and fixed event handler spread order. ([#526](https://github.com/lodev09/react-native-true-sheet/pull/526) by [@lodev09](https://github.com/lodev09)) -- Added `maxContentWidth` prop to control the maximum width of the sheet content. ([#495](https://github.com/lodev09/react-native-true-sheet/pull/495) by [@lodev09](https://github.com/lodev09)) -- Added `anchor` and `anchorOffset` props for side sheet positioning. ([#496](https://github.com/lodev09/react-native-true-sheet/pull/496), [#500](https://github.com/lodev09/react-native-true-sheet/pull/500) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **iOS**: Fixed crash and blank sheet when using Reanimated exiting animations. ([#493](https://github.com/lodev09/react-native-true-sheet/pull/493) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed main thread deadlock when keyboard events fire while sheet is mounted. ([#506](https://github.com/lodev09/react-native-true-sheet/pull/506) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed thread blocking on gesture dismissal when sheet is presented in a stack. ([#511](https://github.com/lodev09/react-native-true-sheet/pull/511) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed FlashList content not rendering while dragging sheet. ([#539](https://github.com/lodev09/react-native-true-sheet/pull/539) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed default background color not respecting dark mode on AppCompat-based apps. ([#501](https://github.com/lodev09/react-native-true-sheet/pull/501) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed keyboard not dismissing when sheet is dragged to a non-keyboard detent position. ([#513](https://github.com/lodev09/react-native-true-sheet/pull/513) by [@lodev09](https://github.com/lodev09)) -- **Android**: Added guard and warning to prevent dismiss events for a sheet that is not presented. ([#516](https://github.com/lodev09/react-native-true-sheet/pull/516) by [@codydorsettlynn](https://github.com/codydorsettlynn)) -- **Android**: Fixed stale nested scrolling ref blocking drag after dismissing a child sheet with a ScrollView. ([#523](https://github.com/lodev09/react-native-true-sheet/pull/523) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed keyboard detent not committing on scroll, causing sheet to snap back when keyboard hides. ([#525](https://github.com/lodev09/react-native-true-sheet/pull/525) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed children not taking full width and height due to state wrapper assignment order. ([#531](https://github.com/lodev09/react-native-true-sheet/pull/531) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed scroll drag workaround not applying for non-scrollable sheets containing a ScrollView. ([#538](https://github.com/lodev09/react-native-true-sheet/pull/538) by [@lodev09](https://github.com/lodev09)) -- **Web**: Fixed default background color not respecting dark mode. ([#502](https://github.com/lodev09/react-native-true-sheet/pull/502) by [@lodev09](https://github.com/lodev09)) -- **iOS, Android**: Fixed `auto` detent height staggering on content change due to Yoga layout feedback loop. ([#537](https://github.com/lodev09/react-native-true-sheet/pull/537) by [@lodev09](https://github.com/lodev09)) - -### πŸ“– Documentation - -- Added dynamic header & footer section to navigation guide. ([#515](https://github.com/lodev09/react-native-true-sheet/pull/515) by [@lodev09](https://github.com/lodev09)) - -### πŸ’‘ Others - -- Performance improvements across JS, iOS, and Android. ([#521](https://github.com/lodev09/react-native-true-sheet/pull/521) by [@lodev09](https://github.com/lodev09)) -- Use codegen enums for `backgroundBlur`, `anchor`, and `insetAdjustment` props. ([#499](https://github.com/lodev09/react-native-true-sheet/pull/499) by [@lodev09](https://github.com/lodev09)) -- Replace `react-native-maps` with `@lugg/maps` in examples. ([#503](https://github.com/lodev09/react-native-true-sheet/pull/503) by [@lodev09](https://github.com/lodev09)) - -## 3.8.2 - -### πŸ› Bug fixes - -- **Android**: Fixed grabber disappearing when background color changes on stacked sheets. ([#488](https://github.com/lodev09/react-native-true-sheet/pull/488) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed content clipping when sheet content changes dynamically by clearing stale ScrollView reference. ([#482](https://github.com/lodev09/react-native-true-sheet/pull/482) by [@sbs44](https://github.com/sbs44)) - -## 3.8.1 - -### πŸ“– Documentation - -- Added web limitation note for `react-native-screens` detection with `useFocusEffect` workaround. ([#480](https://github.com/lodev09/react-native-true-sheet/pull/480) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Web**: Collapse to lowest detent when not dismissible, matching native behavior. ([#479](https://github.com/lodev09/react-native-true-sheet/pull/479) by [@lodev09](https://github.com/lodev09)) -- **Web**: Fixed event firing order and conditions to match native. ([#478](https://github.com/lodev09/react-native-true-sheet/pull/478) by [@lodev09](https://github.com/lodev09)) - -## 3.8.0 - -### ⚠️ Breaking changes - -- **Dismiss Behavior**: `dismiss()` now dismisses the sheet and all sheets presented on top of it. Previously, calling `dismiss()` on a sheet with children would only either dismiss the current sheet if it had no children or only the child sheets presented on top of it, keeping the current sheet open. Now it performs a cascade dismiss of the entire stack. - Use the `dismissStack()` method if you need the old behavior of dismissing only child sheets. - -- **Remove Static Methods on Web**: Static methods (`TrueSheet.present()`, `TrueSheet.dismiss()`, etc.) are no longer supported on web. Use the `useTrueSheet()` hook instead. - -### πŸŽ‰ New features - -- Added `dismissStack()` instance and static method to dismiss only sheets presented on top of the current sheet, keeping the current sheet presented. ([#452](https://github.com/lodev09/react-native-true-sheet/pull/452), [#470](https://github.com/lodev09/react-native-true-sheet/pull/470) by [@obi-owner](https://github.com/obi-owner), [@lodev09](https://github.com/lodev09)) -- Added `scrollableOptions` prop with keyboard scroll handling for ScrollViews. ([#442](https://github.com/lodev09/react-native-true-sheet/pull/442) by [@lodev09](https://github.com/lodev09)) -- Added bottom inset to ScrollView when `insetAdjustment` is `automatic`. ([#430](https://github.com/lodev09/react-native-true-sheet/pull/430) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **iOS**: Fixed sheet not auto-dismissing when nested stack is removed. ([#472](https://github.com/lodev09/react-native-true-sheet/pull/472) by [@lodev09](https://github.com/lodev09)) -- Fixed `pop`, `popTo`, `popToTop` navigation actions not dismissing sheets properly. ([#471](https://github.com/lodev09/react-native-true-sheet/pull/471) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed scroll position jumping when nested sheet is dismissed with inverted FlatList. ([#468](https://github.com/lodev09/react-native-true-sheet/pull/468) by [@lucasklaassen](https://github.com/lucasklaassen)) -- **Android**: Fixed present promise not resolving on resize. ([c3495500](https://github.com/lodev09/react-native-true-sheet/commit/c3495500) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed scroll view pinning to respect content view padding/margin. ([#429](https://github.com/lodev09/react-native-true-sheet/pull/429), [#446](https://github.com/lodev09/react-native-true-sheet/pull/446) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed footer not translating back when keyboard hides via ScrollView. ([#424](https://github.com/lodev09/react-native-true-sheet/pull/424) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed `backgroundBlur` and `backgroundColor` not working together. ([#423](https://github.com/lodev09/react-native-true-sheet/pull/423) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed `present()` called from `useEffect` not working due to mount event not firing. ([#421](https://github.com/lodev09/react-native-true-sheet/pull/421) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed sheet content becoming empty after rapidly presenting/dismissing. ([#419](https://github.com/lodev09/react-native-true-sheet/pull/419) by [@lodev09](https://github.com/lodev09)) -- Auto re-present sheet when returning from screen dismiss (modal or navigation pop). ([#412](https://github.com/lodev09/react-native-true-sheet/pull/412) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed `onWillDismiss` event timing during drag dismiss. ([#416](https://github.com/lodev09/react-native-true-sheet/pull/416) by [@lodev09](https://github.com/lodev09)) - -### πŸ“– Documentation - -- Added overlays guide for displaying modals/dialogs on top of sheets. ([#474](https://github.com/lodev09/react-native-true-sheet/pull/474) by [@lodev09](https://github.com/lodev09)) -- Added `transformIgnorePatterns` to Jest setup guide. ([#458](https://github.com/lodev09/react-native-true-sheet/pull/458) by [@lodev09](https://github.com/lodev09)) -- Added troubleshooting docs for deep-linking modals. ([#2d111c4f](https://github.com/lodev09/react-native-true-sheet/commit/2d111c4f777a3a50e75d0894dbaa5852914f5962) by [@lodev09](https://github.com/lodev09)) - -### πŸ’‘ Others - -- Separated `present()` and `resize()` methods; calling `present()` on an already-presented sheet now logs a warning instead of resizing. ([#441](https://github.com/lodev09/react-native-true-sheet/pull/441) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Refactored container to use Yoga layout via C++ state for dimensions. ([#457](https://github.com/lodev09/react-native-true-sheet/pull/457) by [@lodev09](https://github.com/lodev09)) -- **Android**: Refactored screen event detection to use EventDispatcherListener instead of FragmentLifecycleCallbacks. ([#438](https://github.com/lodev09/react-native-true-sheet/pull/438) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Refactored screen unmount detection to use C++ EventDispatcher instead of RNSLifecycleListenerProtocol. ([#410](https://github.com/lodev09/react-native-true-sheet/pull/410) by [@lodev09](https://github.com/lodev09)) - -## 3.7.3 - -### πŸ› Bug fixes - -- **iOS**: Fixed initial present failing during deeplink navigation. ([#406](https://github.com/lodev09/react-native-true-sheet/pull/406) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Use host view window's rootViewController ([7938c69](https://github.com/lodev09/react-native-true-sheet/commit/7938c69) by [@lodev09](https://github.com/lodev09)) -- **Android**: Ignore fragment events during cold start initialization ([86fa7bb9](https://github.com/lodev09/react-native-true-sheet/commit/86fa7bb9) by [@lodev09](https://github.com/lodev09)) - -### πŸ§ͺ Tests - -- Add `dismissAll` tests ([4615ce64](https://github.com/lodev09/react-native-true-sheet/commit/4615ce64) by [@lodev09](https://github.com/lodev09)) - -## 3.7.2 - -### πŸŽ‰ New features - -- Added `dismissAll` static method. ([#393](https://github.com/lodev09/react-native-true-sheet/pull/393) by [@lodev09](https://github.com/lodev09)) -- **Android**: Added grabber tap to expand/dismiss for iOS consistency. ([#404](https://github.com/lodev09/react-native-true-sheet/pull/404) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Android**: fix(android): cache content heights for dim interpolation during dismiss. ([b297adb3](https://github.com/lodev09/react-native-true-sheet/commit/b297adb3) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed dim interpolation during dismiss when container is unmounted. ([34bd3d40](https://github.com/lodev09/react-native-true-sheet/commit/34bd3d40) by [@lodev09](https://github.com/lodev09)) -- **Web**: Fixed `onWillDismiss` not firing when dismissing via backdrop tap. ([#405](https://github.com/lodev09/react-native-true-sheet/pull/405) by [@lodev09](https://github.com/lodev09)) -- Fixed sheet not dismissing when presenter screen is popped from navigation. ([#400](https://github.com/lodev09/react-native-true-sheet/pull/400), [#402](https://github.com/lodev09/react-native-true-sheet/pull/402) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed visual glitch when navigating away with active sheet. ([#403](https://github.com/lodev09/react-native-true-sheet/pull/403) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed position tracking for pending detent changes. ([#394](https://github.com/lodev09/react-native-true-sheet/pull/394) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed keyboard and focus handling inside RN Modal. ([#387](https://github.com/lodev09/react-native-true-sheet/pull/387) by [@lodev09](https://github.com/lodev09)) - -### 🧹Chores - -- **Android**: Fix lint warnings. ([68422b0e](https://github.com/lodev09/react-native-true-sheet/commit/68422b0e) by [@lodev09](https://github.com/lodev09)) - -## 3.7.0 - -### πŸ› Bug fixes - -- **Android**: Fixed keyboard dismiss when presenting at dimmed detent. -- Improved keyboard handling for sheet presentation. ([#379](https://github.com/lodev09/react-native-true-sheet/pull/379) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed `pointerEvents` not being respected in TrueSheetFooterView. -- **Android**: Added `pointerEvents` prop support to view managers. -- **Android**: Added RootNodeKind trait for nested sheet touch handling. ([#375](https://github.com/lodev09/react-native-true-sheet/pull/375) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed coordinator layout remeasure on configuration change. -- **Android**: Fixed footer repositioning when keyboard hides via IME action button. - -## 3.6.11 - -### πŸ› Bug fixes - -- **Android**: Fixed eventDispatcher propagation via delegate chain for footer touch handling. ([#372](https://github.com/lodev09/react-native-true-sheet/pull/372) by [@lodev09](https://github.com/lodev09)) - -## 3.6.10 - -### πŸ› Bug fixes - -- **Android**: Fixed sheet drag when ScrollView cannot scroll. ([#369](https://github.com/lodev09/react-native-true-sheet/pull/369) by [@lodev09](https://github.com/lodev09)) - -## 3.6.9 - -### πŸ› Bug fixes - -- **Android**: Fixed keyboard handling to only apply for focused views within sheet. ([#365](https://github.com/lodev09/react-native-true-sheet/pull/365) by [@lodev09](https://github.com/lodev09)) -- **Android**: Fixed sheet dismissing early when container is unmounted. -- **iOS**: Fixed sheets dismissing properly during navigation and reload. - -## 3.6.8 - -### πŸ› Bug fixes - -- **iOS**: Fixed keyboard offset preservation when footer resizes. ([#361](https://github.com/lodev09/react-native-true-sheet/pull/361) by [@lodev09](https://github.com/lodev09)) -- **Android**: Optimized findRootContainerView to return first content view. -- **Android**: Fixed TrueSheet rendering above React Native Modal. ([#359](https://github.com/lodev09/react-native-true-sheet/pull/359) by [@lodev09](https://github.com/lodev09)) - -## 3.6.7 - -### πŸ› Bug fixes - -- Fixed initial present flag reset during recycle. - -## 3.6.6 - -### πŸ› Bug fixes - -- **Android**: Fixed setupModalObserver to present lifecycle. - -### πŸ’‘ Others - -- Simplified initial presentation to only trigger on attach to window. - -## 3.6.5 - -### πŸ› Bug fixes - -- **Android**: Fixed animated sheet dismiss with keyboard shown. - -## 3.6.4 - -### πŸ› Bug fixes - -- **Android**: Fixed initial presentation to wait for window attachment. - -## 3.6.3 - -### πŸ› Bug fixes - -- **iOS**: Added compile-time check for iOS 26.1+ APIs. -- **Android**: Added safe value for halfExpandedRatio. - -## 3.6.2 - -### πŸ› Bug fixes - -- **iOS**: Fixed blur intensity with backgroundBlur on iOS 26.1+. - -## 3.6.1 - -### πŸ› Bug fixes - -- **iOS**: Fixed fallback to view.backgroundColor when UIDesignRequiresCompatibility is true. - -## 3.6.0 - -### πŸŽ‰ New features - -- Added `elevation` prop for Android and Web. ([#355](https://github.com/lodev09/react-native-true-sheet/pull/355) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Android**: Fixed keyboard dismiss issue with backdrop. ([#351](https://github.com/lodev09/react-native-true-sheet/pull/351) by [@lodev09](https://github.com/lodev09)) -- **iOS**: Fixed native backgroundEffect for blur on iOS 26.1+. ([#350](https://github.com/lodev09/react-native-true-sheet/pull/350) by [@lodev09](https://github.com/lodev09)) - -## 3.5.8 - -### πŸŽ‰ New features - -- Added `headerStyle` and `footerStyle` props. - -## 3.5.7 - -### πŸ› Bug fixes - -- **Web**: Fixed pointerEvents on footer container. - -## 3.5.6 - -### πŸŽ‰ New features - -- **Web**: Added 'none' option to `stackBehavior` prop. -- **Web**: Added shadow for web sheet. - -## 3.5.5 - -### πŸ› Bug fixes - -- Fixed pointerEvents on header, footer and host view. - -## 3.5.4 - -### πŸ› Bug fixes - -- **iOS**: Fixed initial presentation to defer until view is in window hierarchy. - -## 3.5.3 - -### πŸ› Bug fixes - -- **iOS**: Fixed TrueSheetView being included in scroll view pinning traversal. - -## 3.5.2 - -### πŸ› Bug fixes - -- **Android**: Fixed sheet showing briefly when navigating within a stack. - -## 3.5.1 - -### πŸ› Bug fixes - -- **Android**: Fixed parent stacking after rn-screen dismissed. -- **Android**: Fixed dragging on parent sheet when child is stacked. -- **Android**: Improved dim tap handling for stacked sheets. -- **Android**: Fixed collapse to lowest detent on back press when non-dismissible. -- **Android**: Fixed sheet fade before hiding when rn-screen modal shows. -- **Android**: Fixed content clipping to rounded corners on older API levels. -- **Android**: Fixed collapse to lowest detent on dim tap when non-dismissible. -- **Android**: Fixed bottom sheet centering horizontally on rotation. -- **Android**: Fixed sheet from reshowing when returning from background with rn-screens. -- **Android**: Fixed dismiss animation on back button press. -- **Android**: Fixed footer positioning using onSlide for API < 30. -- **Android**: Fixed grabber hitbox causing touch issues. -- **Android**: Fixed sheet position during non-animated present. -- **Android**: Fixed stacked sheet translation on dismiss. -- **Android**: Fixed translation on initialDetentIndex present. -- **Android**: Fixed initial present on older android versions. -- **Android**: Fixed halfExpandedRatio to be between 0 and 1. - -### πŸ’‘ Others - -- **Android**: Replaced DialogFragment with CoordinatorLayout. ([#344](https://github.com/lodev09/react-native-true-sheet/pull/344) by [@lodev09](https://github.com/lodev09)) -- **Android**: Refactored to use BottomSheetDialogFragment. ([#342](https://github.com/lodev09/react-native-true-sheet/pull/342) by [@lodev09](https://github.com/lodev09)) - -## 3.5.0 - -### πŸŽ‰ New features - -- **Android**: Improved transition animations and refactored detent calculations. ([#337](https://github.com/lodev09/react-native-true-sheet/pull/337) by [@lodev09](https://github.com/lodev09)) - -### πŸ› Bug fixes - -- **Android**: Fixed sheet stack restoration when modal dismisses. -- **Android**: Fixed dim view alpha animation when restoring from modal. -- **Android**: Improved keyboard handling and detent restoration. -- **Android**: Fixed target keyboard height for detent calculations. -- **Android**: Fixed window visibility to prevent keyboard non-focus issue. - -## 3.4.2 - -### πŸ› Bug fixes - -- **Android**: Fixed sheet from showing when app returns from background. - -## 3.4.1 - -### πŸŽ‰ New features - -- **iOS**: Added default blur tint for iOS < 26. ([#334](https://github.com/lodev09/react-native-true-sheet/pull/334) by [@lodev09](https://github.com/lodev09)) - -## 3.4.0 - -### πŸŽ‰ New features - -- **Android**: Added custom dim view with smooth interpolation. ([#327](https://github.com/lodev09/react-native-true-sheet/pull/327) by [@lodev09](https://github.com/lodev09)) -- **Android**: Added parent translation updates when child sheet size changes. - -### πŸ› Bug fixes - -- **Android**: Fixed flashing footer during initial present. -- **Android**: Fixed dim view hiding when RN Screen is presented. - -### πŸ’‘ Others - -- Refactored mocks to export per module. ([#329](https://github.com/lodev09/react-native-true-sheet/pull/329) by [@lodev09](https://github.com/lodev09)) - -## 3.3.5 - -### πŸ› Bug fixes - -- **Android**: Fixed grabber shadow by using bringToFront instead of elevation. - -## 3.3.4 - -### πŸŽ‰ New features - -- Added adaptive grabber color for light/dark mode. ([#325](https://github.com/lodev09/react-native-true-sheet/pull/325) by [@lodev09](https://github.com/lodev09)) - -## 3.3.3 - -### πŸ› Bug fixes - -- Fixed key window fallback for cold start and deep link handling. ([#323](https://github.com/lodev09/react-native-true-sheet/pull/323) by [@lodev09](https://github.com/lodev09)) - -## 3.3.2 - -### πŸ› Bug fixes - -- Fixed batch dismiss behavior for stacked sheets. ([#322](https://github.com/lodev09/react-native-true-sheet/pull/322) by [@lodev09](https://github.com/lodev09)) +- Upgrade the examples to Expo SDK 57 and React Native 0.86. ([#755](https://github.com/lodev09/react-native-true-sheet/pull/755) by [@lodev09](https://github.com/lodev09)) +- Update the `truesheet-usage` AI skill for the v4 API. ([#799](https://github.com/lodev09/react-native-true-sheet/pull/799) by [@lodev09](https://github.com/lodev09)) diff --git a/README.md b/README.md index 519c4b61..867da709 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,17 @@ The true native bottom sheet experience for your React Native Apps. πŸ’© ### Prerequisites -- React Native 0.81+ +- React Native 0.82+ - New Architecture enabled - Xcode 26.1+ ### Compatibility -| TrueSheet | React Native | Expo SDK | -|-----------|--------------|----------| -| 3.7+ | 0.81+ | 54+ | -| 3.6 | 0.80 | 52-53 | +| TrueSheet | React Native | Expo SDK | +|------------|--------------|----------| +| 4.0+ | 0.82+ | 55+ | +| 3.7 - 3.11 | 0.81+ | 54+ | +| 3.6 | 0.80 | 52-53 | ### Expo diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt index 59259a30..65b7a46b 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt @@ -61,15 +61,46 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : return bottom } - var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC - var scrollViewBottomInset: Int = 0 - var scrollableEnabled: Boolean = false + var absoluteFooter: Boolean = false + + /** + * Bottom safe-area inset the footer absorbs as padding β€” the footer + * owns the sheet's bottom edge, so its background fills the inset. + */ + var footerBottomInset: Int = 0 + set(value) { + field = value + footerView?.setBottomInset(value) + } var scrollableOptions: ScrollableOptions? = null set(value) { field = value contentView?.scrollableOptions = value } + // React tag of the user-provided scrollable within the content (see the scrollableRef prop) + var scrollableHandle: Int = -1 + set(value) { + field = value + contentView?.scrollableHandle = value + } + + /** + * Bottom safe-area inset the plugged scrollable applies as content padding + * while it can scroll (see ScrollableOptions.contentInsetAdjustment) + */ + var scrollableBottomInset: Int = 0 + set(value) { + field = value + contentView?.bottomInset = value + } + + var hasAutoDetent = false + set(value) { + field = value + contentView?.hasAutoDetent = value + } + override val eventDispatcher: EventDispatcher? get() = delegate?.eventDispatcher @@ -80,8 +111,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : } fun setupScrollable() { - val bottomInset = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) scrollViewBottomInset else 0 - contentView?.setupScrollable(scrollableEnabled, bottomInset) + contentView?.setupScrollable() } fun setupKeyboardHandler() { @@ -99,8 +129,14 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : is TrueSheetContentView -> { child.delegate = this child.scrollableOptions = scrollableOptions + child.scrollableHandle = scrollableHandle + child.bottomInset = scrollableBottomInset + child.hasAutoDetent = hasAutoDetent contentView = child + // State lands before the view is attached, so pull the current value + contentHeight = child.naturalHeight + // Children mount bottom-up, so the content subtree is complete here. // Late-mounted peek views attach themselves instead (see TrueSheetPeekView). findPeekView(child)?.let { attachPeekView(it) } @@ -113,6 +149,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : is TrueSheetFooterView -> { child.delegate = this + child.setBottomInset(footerBottomInset) footerView = child } } @@ -184,6 +221,12 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : delegate?.containerViewContentDidChangeSize(width, height) } + override val footerKeyboardOcclusion: Int + get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0) + + override val hasRelativeFooter: Boolean + get() = footerView != null && !absoluteFooter + override fun contentViewDidScroll() { delegate?.containerViewContentDidScroll() } @@ -200,6 +243,8 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) : override fun footerViewDidChangeSize(width: Int, height: Int) { footerHeight = height delegate?.containerViewFooterDidChangeSize(width, height) + // A relative footer takes over the bottom inset β€” drop the scrollable's + contentView?.updateContentInset() } override fun peekViewDidChangeSize(width: Int, height: Int) { diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt index 5fe2e2ca..98768ba9 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt @@ -1,24 +1,29 @@ package com.lodev09.truesheet import android.annotation.SuppressLint +import android.os.Build import android.text.Editable import android.text.TextWatcher import android.view.View import android.view.ViewGroup import android.widget.EditText -import android.widget.ScrollView -import androidx.core.widget.NestedScrollView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.view.ReactViewGroup import com.lodev09.truesheet.core.TrueSheetKeyboardObserver import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate import com.lodev09.truesheet.utils.isDescendantOf -import com.lodev09.truesheet.utils.smoothScrollBy import com.lodev09.truesheet.utils.smoothScrollTo -data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true) +data class ScrollableOptions( + val keyboardScrollOffset: Float = 0f, + val keyboardOffset: Float = 0f, + val scrollingExpandsSheet: Boolean = true, + val contentInsetAdjustment: Boolean = true +) /** * Delegate interface for content view size changes @@ -27,6 +32,22 @@ interface TrueSheetContentViewDelegate { fun contentViewDidChangeSize(width: Int, height: Int) fun contentViewDidScroll() fun contentViewScrollViewDidChange() + + /** + * Keyboard occlusion adjustment below the content β€” positive for an absolute + * footer floating over the viewport's bottom edge (extends the caret reveal), + * negative for a relative footer sitting behind the keyboard shielding that + * much of its overlap (reduces the keyboard padding). + */ + val footerKeyboardOcclusion: Int + + /** + * A relative footer sits below the content and absorbs the bottom safe-area + * inset β€” the scrollable doesn't reach the sheet's bottom edge, so no + * content inset is applied (mirrors iOS, where UIKit resolves this + * geometrically via the scroll view's safe area). + */ + val hasRelativeFooter: Boolean } /** @@ -36,16 +57,45 @@ interface TrueSheetContentViewDelegate { @SuppressLint("ViewConstructor") class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) { var delegate: TrueSheetContentViewDelegate? = null + var stateWrapper: StateWrapper? = null - private var lastWidth = 0 - private var lastHeight = 0 - - private var pinnedScrollView: ViewGroup? = null + private var detectedScrollView: ViewGroup? = null private var originalScrollViewPaddingBottom: Int = 0 - private var bottomInset: Int = 0 - private var scrollExpansionPadding: Int = 0 + private var scrollableBounded = false + + /** + * Content height measured unconstrained by the shadow node β€” the height the + * content wants regardless of container bounds (see + * TrueSheetContentViewShadowNode). Falls back to the view height before the + * first state update. + */ + val naturalHeight: Int + get() = if (lastNaturalHeight > 0) lastNaturalHeight else height + + private var lastNaturalHeight = 0 + + fun updateNaturalHeight(heightDp: Double) { + val heightPx = heightDp.toFloat().dpToPx().toInt() + if (heightPx == lastNaturalHeight) return + lastNaturalHeight = heightPx + delegate?.contentViewDidChangeSize(width, heightPx) + } private var keyboardScrollOffset: Float = 0f + + /** + * Adjustment added to the keyboard bottom inset applied to the detected + * scrollable β€” negative values reduce the inset (e.g. to cancel out safe-area + * padding already baked into the content's paddingBottom). + */ + private var keyboardOffset: Float = 0f + + /** + * How much of keyboardOffset actually landed in the applied padding β€” the + * caret reveal compensates by this so it still targets the real keyboard edge. + */ + private var appliedKeyboardOffset = 0 + private var keyboardObserver: TrueSheetKeyboardObserver? = null private var watchedEditText: EditText? = null @@ -55,6 +105,54 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React set(value) { field = value keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f + keyboardOffset = value?.keyboardOffset?.dpToPx() ?: 0f + updateContentInset() + } + + /** + * Bottom safe-area inset applied to the scrollable's content padding while + * the content can scroll β€” Android counterpart of iOS's + * `contentInsetAdjustmentBehavior="automatic"`. Already zero when the + * sheet's `insetAdjustment` is `'never'`. + */ + var bottomInset: Int = 0 + set(value) { + if (field == value) return + field = value + updateContentInset() + } + + private var baseBottomInset = 0 + private var keyboardBottomInset = 0 + + private val scrollableLayoutListener = + View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (bottom - top != oldBottom - oldTop) updateContentInset() + } + + /** + * React tag of the user-provided scrollable (see the `scrollableRef` prop). + * Setting a new handle clears the currently resolved ScrollView. + */ + var scrollableHandle: Int = -1 + set(value) { + if (field == value) return + field = value + clearScrollable() + } + + /** + * Whether the sheet has an `auto` detent. Deriving the sheet height from the + * scroll content is circular with natural layout, so the detected ScrollView's + * viewport is force-bounded to the container only in this case. + */ + var hasAutoDetent = false + set(value) { + if (field == value) return + field = value + if (detectedScrollView != null) { + setScrollableBounded(value) + } } override fun addView(child: View?, index: Int) { @@ -68,120 +166,130 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React } private fun checkScrollViewChanged() { - if (pinnedScrollView == null || pinnedScrollView?.isDescendantOf(this) == false) { + if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) { delegate?.contentViewScrollViewDidChange() } } - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - - if (w != lastWidth || h != lastHeight) { - lastWidth = w - lastHeight = h - delegate?.contentViewDidChangeSize(w, h) + /** + * Tells the shadow node to fill the container (flexGrow/flexShrink) so the + * detected ScrollView's viewport is bounded to the visible space. Only applied + * for auto detents β€” otherwise content lays out naturally like a regular view. + */ + private fun setScrollableBounded(bounded: Boolean) { + if (scrollableBounded == bounded) return + scrollableBounded = bounded + + stateWrapper?.let { + val newState = WritableNativeMap() + newState.putBoolean("scrollableBounded", bounded) + it.updateState(newState) } } - fun setupScrollable(enabled: Boolean, bottomInset: Int) { - if (!enabled) { - clearScrollable() - return - } - - // Check if pinned scroll view is still valid (still in view hierarchy) - if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) { + fun setupScrollable() { + // Check if the detected scroll view is still valid (still in view hierarchy) + if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) { clearScrollable() } - // Already set up with same inset and valid scroll view - if (pinnedScrollView != null && this.bottomInset == bottomInset) { + if (detectedScrollView != null) { return } - val scrollView = findScrollView(this) ?: return + val scrollView = findScrollView() ?: return - // Only capture originals on first pin - if (pinnedScrollView == null) { - originalScrollViewPaddingBottom = scrollView.paddingBottom - pinnedScrollView = scrollView + originalScrollViewPaddingBottom = scrollView.paddingBottom + detectedScrollView = scrollView - scrollView.isNestedScrollingEnabled = true - (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false + scrollView.isNestedScrollingEnabled = true + (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false - scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - if (scrollY != oldScrollY) { - delegate?.contentViewDidScroll() - } + scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + if (scrollY != oldScrollY) { + delegate?.contentViewDidScroll() } } - this.bottomInset = bottomInset + // Track viewport and content size changes β€” the content inset only applies + // while the content can scroll (mirrors iOS's automatic behavior) + scrollView.addOnLayoutChangeListener(scrollableLayoutListener) + scrollView.getChildAt(0)?.addOnLayoutChangeListener(scrollableLayoutListener) - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset) + setScrollableBounded(hasAutoDetent) + updateContentInset() // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView val keyboardHeight = keyboardObserver?.currentHeight ?: 0 if (keyboardHeight > 0) { - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + keyboardHeight) + updateScrollViewInsetForKeyboard(keyboardHeight) } } - // TODO: Replace this workaround with synchronous state layout updates on every sheet resize. - // The container is currently sized to the largest detent, so at smaller detents the ScrollView - // viewport extends beyond the visible area, reducing the effective scroll range. This padding - // compensates for that difference until we can resize the container per-detent synchronously. - fun updateScrollExpansionPadding(padding: Int) { - if (scrollExpansionPadding == padding) return - scrollExpansionPadding = padding - val keyboardHeight = keyboardObserver?.currentHeight ?: 0 - val basePadding = if (keyboardHeight > 0) keyboardHeight else bottomInset - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + basePadding) - nudgeScrollView() + /** + * Re-applies the base content inset β€” the bottom safe-area inset while the + * content can scroll, no relative footer absorbs it, and + * `contentInsetAdjustmentBehavior` isn't disabled. + */ + fun updateContentInset() { + val scrollView = detectedScrollView ?: return + + val enabled = scrollableOptions?.contentInsetAdjustment ?: true + baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) { + bottomInset + } else { + 0 + } + + applyBottomPadding() + } + + // Measured against the original padding so the applied inset doesn't feed + // back into the check + private fun canScrollContent(scrollView: ViewGroup): Boolean { + val child = scrollView.getChildAt(0) ?: return false + return child.height > scrollView.height - scrollView.paddingTop - originalScrollViewPaddingBottom + } + + // The keyboard covers the safe-area region, so the larger inset wins over a sum + private fun applyBottomPadding() { + setScrollViewPaddingBottom(originalScrollViewPaddingBottom + maxOf(baseBottomInset, keyboardBottomInset)) } private fun setScrollViewPaddingBottom(paddingBottom: Int) { - val scrollView = pinnedScrollView ?: return + val scrollView = detectedScrollView ?: return scrollView.clipToPadding = false scrollView.setPadding( scrollView.paddingLeft, scrollView.paddingTop, scrollView.paddingRight, - paddingBottom + scrollExpansionPadding + paddingBottom ) } fun clearScrollable() { - pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?) - pinnedScrollView?.isNestedScrollingEnabled = false - (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true - scrollExpansionPadding = 0 + detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?) + detectedScrollView?.removeOnLayoutChangeListener(scrollableLayoutListener) + detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollableLayoutListener) + detectedScrollView?.isNestedScrollingEnabled = false + (detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true setScrollViewPaddingBottom(originalScrollViewPaddingBottom) - pinnedScrollView = null + setScrollableBounded(false) + detectedScrollView = null originalScrollViewPaddingBottom = 0 - bottomInset = 0 + baseBottomInset = 0 + keyboardBottomInset = 0 + appliedKeyboardOffset = 0 } + /** + * Resolves the user-provided `scrollableHandle` within the content subtree β€” + * React tags are view ids on Android. + */ fun findScrollView(): ViewGroup? { - if (pinnedScrollView != null) return pinnedScrollView - return findScrollView(this as View) - } - - private fun findScrollView(view: View): ViewGroup? { - if (view is ScrollView || view is NestedScrollView) { - return view as ViewGroup - } - - if (view is ViewGroup) { - for (i in 0 until view.childCount) { - val scrollView = findScrollView(view.getChildAt(i)) - if (scrollView != null) { - return scrollView - } - } - } - - return null + if (detectedScrollView != null) return detectedScrollView + if (scrollableHandle <= 0) return null + return findViewById(scrollableHandle) as? ViewGroup } // ==================== Keyboard Handling ==================== @@ -191,8 +299,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply { delegate = object : TrueSheetKeyboardObserverDelegate { - override fun keyboardWillShow(height: Int) { + // Drive the inset per keyboard animation frame so the content tracks + // the keyboard edge instead of jumping to the final inset up front. + // On hide, the shrinking inset gradually clamps the scroll position + // back in sync with the keyboard. + override fun keyboardDidChangeHeight(height: Int) { updateScrollViewInsetForKeyboard(height) + + // Follow the caret frame-by-frame β€” only API 30+ delivers + // incremental heights; legacy fires once after the keyboard is + // already up, where keyboardDidShow's smooth reveal takes over + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) { + scrollToFocusedInput(smooth = false) + } } override fun keyboardDidShow(height: Int) { @@ -202,7 +321,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React override fun keyboardWillHide() { detachCaretListener() - updateScrollViewInsetForKeyboard(0) } override fun focusDidChange(newFocus: View) { @@ -247,22 +365,39 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React } private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) { - val scrollView = pinnedScrollView ?: return - - val totalBottomInset = if (keyboardHeight > 0) keyboardHeight else bottomInset - setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset) - - scrollView.post { nudgeScrollView() } + val scrollView = detectedScrollView ?: return + + // A relative footer stays behind the keyboard below the content, so its + // height shields that much of the keyboard's overlap. An absolute footer + // floats within the viewport β€” its clearance is the content padding's job + // (the caret reveal accounts for it, see scrollToFocusedInput). + keyboardBottomInset = if (keyboardHeight > 0) { + val baseInset = maxOf(0, keyboardHeight + minOf(0, delegate?.footerKeyboardOcclusion ?: 0)) + val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt()) + appliedKeyboardOffset = adjustedInset - baseInset + adjustedInset + } else { + appliedKeyboardOffset = 0 + 0 + } + applyBottomPadding() + clampScrollPosition() } - private fun nudgeScrollView() { - val scrollView = pinnedScrollView ?: return - scrollView.smoothScrollBy(0, 1) - scrollView.smoothScrollBy(0, -1) + // Padding changes don't re-clamp the scroll position until the next layout + // pass β€” clamp synchronously so the content follows the shrinking inset on + // every keyboard frame instead of snapping afterwards + private fun clampScrollPosition() { + val scrollView = detectedScrollView ?: return + val child = scrollView.getChildAt(0) ?: return + val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom)) + if (scrollView.scrollY > maxScrollY) { + scrollView.scrollTo(scrollView.scrollX, maxScrollY) + } } - private fun scrollToFocusedInput() { - val scrollView = pinnedScrollView ?: findScrollView() ?: return + private fun scrollToFocusedInput(smooth: Boolean = true) { + val scrollView = detectedScrollView ?: findScrollView() ?: return val focusedView = findFocus() ?: return val focusedLocation = IntArray(2) @@ -289,17 +424,29 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React caretBottom = viewTop + focusedView.height } + // An absolute footer floats over the viewport's bottom edge β€” extend the + // caret target so it clears the footer, not just the keyboard. + val footerOcclusion = maxOf(0, delegate?.footerKeyboardOcclusion ?: 0) + val offset = keyboardScrollOffset.toInt() - val visibleHeight = scrollView.height - scrollView.paddingBottom + val visibleHeight = scrollView.height - scrollView.paddingBottom + appliedKeyboardOffset val visibleTop = scrollView.scrollY val visibleBottom = scrollView.scrollY + visibleHeight - when { - caretBottom + offset > visibleBottom -> - scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight) + val targetY = when { + caretBottom + offset + footerOcclusion > visibleBottom -> + caretBottom + offset + footerOcclusion - visibleHeight caretTop - offset < visibleTop -> - scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0)) + (caretTop - offset).coerceAtLeast(0) + + else -> return + } + + if (smooth) { + scrollView.smoothScrollTo(0, targetY) + } else { + scrollView.scrollTo(0, targetY) } } diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt index 2853192b..bb3976c0 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt @@ -2,6 +2,8 @@ package com.lodev09.truesheet import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp @@ -17,6 +19,16 @@ class TrueSheetContentViewManager : ViewGroupManager() { override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext) + override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? { + view.stateWrapper = stateWrapper + stateWrapper?.stateData?.let { data -> + if (data.hasKey("naturalHeight")) { + view.updateNaturalHeight(data.getDouble("naturalHeight")) + } + } + return null + } + @ReactProp(name = "pointerEvents") fun setPointerEvents(view: TrueSheetContentView, pointerEventsStr: String?) { view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr) diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt index 01dd0adf..0bfe4868 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt @@ -4,10 +4,13 @@ import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import android.view.ViewParent +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.JSPointerDispatcher import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.view.ReactViewGroup @@ -35,6 +38,7 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) : RootView { var delegate: TrueSheetFooterViewDelegate? = null + var stateWrapper: StateWrapper? = null private val eventDispatcher: EventDispatcher? get() = delegate?.eventDispatcher @@ -42,6 +46,39 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) : private var lastWidth = 0 private var lastHeight = 0 + var bottomInset = 0 + private set + + /** + * Height the footer occupies above the keyboard β€” its layout height minus + * the safe-area inset, which rides below the keyboard's top edge while the + * keyboard is open (see positionFooter). + */ + val keyboardOcclusionHeight: Int + get() = maxOf(0, height - bottomInset) + + /** + * Tells the shadow node to pad the footer's bottom edge with the sheet's + * bottom safe-area inset β€” the footer owns the sheet's bottom edge, so it + * absorbs the inset and its background fills it. + */ + fun setBottomInset(inset: Int) { + if (bottomInset == inset) return + bottomInset = inset + + val sw = stateWrapper ?: return + val insetDp = bottomInset.toFloat().pxToDp() + + // Synchronous update β€” the footer must be padded before detents are + // configured, otherwise the auto detent is set up an inset short + if (TrueSheetStateUpdater.updateFooterState(sw, insetDp)) return + + // Fallback: async state update + val newState = WritableNativeMap() + newState.putDouble("bottomInset", insetDp.toDouble()) + sw.updateState(newState) + } + private val jsTouchDispatcher = JSTouchDispatcher(this) private var jsPointerDispatcher: JSPointerDispatcher? = null private val touchDeduper = TouchEventDeduper() diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt index aedf0a32..3f734f34 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt @@ -2,6 +2,8 @@ package com.lodev09.truesheet import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp @@ -17,6 +19,11 @@ class TrueSheetFooterViewManager : ViewGroupManager() { override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetFooterView = TrueSheetFooterView(reactContext) + override fun updateState(view: TrueSheetFooterView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? { + view.stateWrapper = stateWrapper + return null + } + @ReactProp(name = "pointerEvents") fun setPointerEvents(view: TrueSheetFooterView, pointerEventsStr: String?) { view.pointerEvents = PointerEvents.parsePointerEvents(pointerEventsStr) diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt new file mode 100644 index 00000000..6a30e18d --- /dev/null +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt @@ -0,0 +1,45 @@ +package com.lodev09.truesheet + +import com.facebook.react.uimanager.StateWrapper + +/** + * Synchronous Fabric state updates via JNI (see TrueSheetStateUpdater.cpp). + * + * StateWrapper.updateState commits asynchronously, so the container's Yoga + * layout lags the sheet by a frame or two during drags. The native bridge + * commits with `unstable_Immediate`, which mounts synchronously when called + * from the UI thread β€” same-frame layout, like iOS. + */ +object TrueSheetStateUpdater { + private val isAvailable: Boolean = try { + System.loadLibrary("react_codegen_TrueSheetSpec") + true + } catch (e: UnsatisfiedLinkError) { + false + } + + /** Returns false when unavailable β€” caller should fall back to async updateState. */ + fun updateState(stateWrapper: StateWrapper, widthDp: Float, heightDp: Float): Boolean = + isAvailable && nativeUpdateState(stateWrapper, widthDp, heightDp) + + /** Returns false when unavailable β€” caller should fall back to async updateState. */ + fun updateFooterState(stateWrapper: StateWrapper, bottomInsetDp: Float): Boolean = + isAvailable && nativeUpdateFooterState(stateWrapper, bottomInsetDp) + + /** + * Publishes the precalculated bottom safe-area inset so a late-mounted + * footer's first layout is already padded (see TrueSheetFooterViewShadowNode). + */ + fun setBottomSafeArea(insetDp: Float) { + if (isAvailable) nativeSetBottomSafeArea(insetDp) + } + + @JvmStatic + private external fun nativeUpdateState(stateWrapper: Any, width: Float, height: Float): Boolean + + @JvmStatic + private external fun nativeUpdateFooterState(stateWrapper: Any, bottomInset: Float): Boolean + + @JvmStatic + private external fun nativeSetBottomSafeArea(insetDp: Float) +} diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt index 16412b06..fcbd73b7 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt @@ -101,13 +101,30 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : override fun onAttachedToWindow() { super.onAttachedToWindow() + presentInitialIfNeeded() + } - if (initialDetentIndex >= 0 && !didInitiallyPresent) { - didInitiallyPresent = true - if (initialDetentAnimated) { - present(initialDetentIndex, true) { } - } else { - post { present(initialDetentIndex, false) { } } + /** + * JS holds initialDetentIndex back one commit so effect-driven content (e.g. a + * navigation footer set via setOptions) commits together with it. The prop then + * arrives after attach, so the ViewManager triggers this on prop update too. + * Posted so sibling mounts from the current transaction land before measuring. + */ + fun presentInitialIfNeeded() { + if (initialDetentIndex < 0 || didInitiallyPresent || !isAttachedToWindow) return + + didInitiallyPresent = true + post { + // The view may have been dropped (flag reset) or detached before this + // ran β€” roll back so a later attach retries instead of presenting a + // sheet with no live host. + if (!didInitiallyPresent) return@post + if (!isAttachedToWindow) { + didInitiallyPresent = false + return@post + } + if (!viewController.isPresented) { + present(initialDetentIndex, initialDetentAnimated) { } } } } @@ -274,6 +291,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : fun setDetents(newDetents: MutableList) { viewController.detents = newDetents + setupScrollable() } fun setInsetAdjustment(insetAdjustment: String) { @@ -281,16 +299,25 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : setupScrollable() } - fun setScrollable(scrollable: Boolean) { - viewController.scrollable = scrollable + fun setScrollableOptions(options: ScrollableOptions?) { + viewController.scrollableOptions = options setupScrollable() } - fun setScrollableOptions(options: ScrollableOptions?) { - viewController.scrollableOptions = options + fun setScrollableHandle(handle: Int) { + if (viewController.scrollableHandle == handle) return + viewController.scrollableHandle = handle setupScrollable() } + fun setAbsoluteHeader(absolute: Boolean) { + viewController.absoluteHeader = absolute + } + + fun setAbsoluteFooter(absolute: Boolean) { + viewController.absoluteFooter = absolute + } + fun setFooterKeyboardOffset(offset: Float) { viewController.footerKeyboardOffset = offset.dpToPx().toInt() viewController.positionFooter() @@ -298,12 +325,20 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : private fun setupScrollable() { viewController.containerView?.let { - it.insetAdjustment = viewController.insetAdjustment - it.scrollableEnabled = viewController.scrollable - it.scrollViewBottomInset = viewController.contentBottomInset it.scrollableOptions = viewController.scrollableOptions + it.scrollableHandle = viewController.scrollableHandle + it.scrollableBottomInset = viewController.contentBottomInset + it.hasAutoDetent = viewController.detents.contains(-1.0) + it.footerBottomInset = viewController.contentBottomInset + it.absoluteFooter = viewController.absoluteFooter it.setupScrollable() } + + // Publish the inset so a footer set later (e.g. navigation setOptions) is + // padded on its very first layout instead of resizing the sheet twice. + if (viewController.insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) { + TrueSheetStateUpdater.setBottomSafeArea(viewController.contentBottomInset.toFloat().pxToDp()) + } } // ==================== Screen Event Observer ==================== @@ -343,9 +378,16 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : lastContainerHeight = height val sw = stateWrapper ?: return + val widthDp = width.toFloat().pxToDp() + val heightDp = height.toFloat().pxToDp() + + // Synchronous update β€” commits and mounts within the same UI-thread frame + if (TrueSheetStateUpdater.updateState(sw, widthDp, heightDp)) return + + // Fallback: async state update val newStateData = WritableNativeMap() - newStateData.putDouble("containerWidth", width.toFloat().pxToDp().toDouble()) - newStateData.putDouble("containerHeight", height.toFloat().pxToDp().toDouble()) + newStateData.putDouble("containerWidth", widthDp.toDouble()) + newStateData.putDouble("containerHeight", heightDp.toDouble()) sw.updateState(newStateData) } @@ -450,7 +492,12 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : if (viewController.containerView == null) return@post viewController.setupSheetDetentsForSizeChange() - TrueSheetStackManager.updateParentTranslation(this) + // While settling, onSlide drives the parent translation in realtime β€” + // an animated update here would race it frame-by-frame, staggering the + // emitted position + if (!viewController.isSettling) { + TrueSheetStackManager.updateParentTranslation(this) + } } } @@ -478,14 +525,46 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : } } + /** + * Realtime counterpart of updateTranslationForChild, driven by the child's + * onSlide frames so stacked sheets move in lockstep instead of on + * independently-timed animations. + */ + fun syncTranslationForChild(childSheetTop: Int, childMinSheetTop: Int) { + if (!viewController.isSheetVisible || viewController.isExpanded) return + + val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex) + val targetTranslation = maxOf(0, childMinSheetTop - mySheetTop) + val actualTranslation = maxOf(0, childSheetTop - mySheetTop) + val currentTranslation = viewController.currentTranslationY + + // Ride the child's actual position, but only between the current + // translation and the settled target β€” tracks the child frame-by-frame + // when the alignment changes (auto detent resize) while staying put when + // the child moves between detents above its minimum. Returning early on + // no-ops avoids cancelling an in-flight realign animation. + val newTranslation = actualTranslation.coerceIn( + minOf(currentTranslation, targetTranslation), + maxOf(currentTranslation, targetTranslation) + ) + if (newTranslation == currentTranslation) return + + viewController.translateSheet(newTranslation, animated = false) + + val additionalTranslation = newTranslation - currentTranslation + if (additionalTranslation > 0) { + TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation, animated = false) + } + } + /** * Recursively adds translation to this sheet and all parent sheets. */ - private fun addTranslation(amount: Int) { + private fun addTranslation(amount: Int, animated: Boolean = true) { if (viewController.isExpanded) return - viewController.translateSheet(viewController.currentTranslationY + amount) - TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount) + viewController.translateSheet(viewController.currentTranslationY + amount, animated) + TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount, animated) } /** @@ -569,10 +648,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : } override fun viewControllerDidChangeSize(width: Int, height: Int) { - // On android scrollable, we need the actual sheet height to get proper ScrollView height. - // Unlike IOS where ScrollView is pinned to the container. - val effectiveHeight = if (viewController.scrollable) height else viewController.screenHeight - updateState(width, effectiveHeight) + updateState(width, height) } override fun viewControllerWillFocus() { @@ -610,7 +686,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) : viewController.commitKeyboardDetent() } - // When the ScrollView changes (e.g. conditional remount), re-pin the new ScrollView + // When the ScrollView changes (e.g. conditional remount), re-detect the new ScrollView // and request layout so BottomSheetBehavior re-discovers the nested scrolling child. override fun containerViewScrollViewDidChange() { setupScrollable() diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index 044b82b5..31a802c3 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -37,6 +37,7 @@ import com.lodev09.truesheet.core.TrueSheetDimViewDelegate import com.lodev09.truesheet.core.TrueSheetKeyboardObserver import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate import com.lodev09.truesheet.core.TrueSheetStackManager +import com.lodev09.truesheet.utils.Insets import com.lodev09.truesheet.utils.KeyboardUtils import com.lodev09.truesheet.utils.ScreenUtils import com.lodev09.truesheet.utils.TouchEventDeduper @@ -162,6 +163,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : private var pendingDetentIndex: Int = -1 private var interactionState: InteractionState = InteractionState.Idle + + // A content/header/footer size change arrived mid-drag β€” reconfiguring then + // would reset behavior state and kill the gesture, so it's applied on settle. + private var hasPendingSizeChange = false internal var isBeingDismissed = false private set var wasHiddenByScreen = false @@ -221,15 +226,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override var sheetBackgroundColor: Int? = null var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC - var scrollable: Boolean = false - var scrollableOptions: ScrollableOptions? = null set(value) { field = value behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true - if (isPresented) sheetView?.let { updateScrollExpansionPadding(it.top) } } + // React tag of the user-provided scrollable within the content (see the scrollableRef prop) + var scrollableHandle: Int = -1 + override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx() set(value) { field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value @@ -273,9 +278,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : val screenWidth: Int get() = ScreenUtils.getScreenWidth(reactContext) + // Cached per layout/configuration pass β€” the WindowManager and + // rootWindowInsets queries behind these allocate, and they're read many + // times per animation frame (position emission, detent interpolation, + // stacked sheet sync) + private var cachedRealScreenHeight: Int = 0 + private var cachedInsets: Insets? = null + + private fun invalidateScreenMetrics() { + cachedRealScreenHeight = 0 + cachedInsets = null + } + // Includes system bars for accurate positioning override val realScreenHeight: Int - get() = ScreenUtils.getRealScreenHeight(reactContext) + get() { + if (cachedRealScreenHeight == 0) { + cachedRealScreenHeight = ScreenUtils.getRealScreenHeight(reactContext) + } + return cachedRealScreenHeight + } + + private val insets: Insets + get() = cachedInsets ?: ScreenUtils.getInsets(reactContext).also { cachedInsets = it } // Content Measurements // Cached values used during dismiss when container is unmounted @@ -290,9 +315,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : override val headerHeight: Int get() = containerView?.headerHeight ?: cachedHeaderHeight + override var absoluteHeader: Boolean = false + override val footerHeight: Int get() = containerView?.footerHeight ?: cachedFooterHeight + override var absoluteFooter: Boolean = false + override val peekContentHeight: Int get() = containerView?.peekContentHeight ?: cachedPeekContentHeight @@ -314,10 +343,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } val bottomInset: Int - get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0 + get() = if (edgeToEdgeEnabled) insets.bottom else 0 override val topInset: Int - get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0 + get() = if (edgeToEdgeEnabled) insets.top else 0 override val contentBottomInset: Int get() = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) bottomInset else 0 @@ -330,6 +359,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } // Sheet State + val isSettling: Boolean + get() = behavior?.state == BottomSheetBehavior.STATE_SETTLING + val isExpanded: Boolean get() { val sheetTop = sheetView?.top ?: return false @@ -391,6 +423,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : sheetView = null interactionState = InteractionState.Idle + hasPendingSizeChange = false isBeingDismissed = false isPresented = false isSheetVisible = false @@ -439,6 +472,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : // ============================================================================= override fun coordinatorLayoutDidLayout(changed: Boolean) { + if (changed) invalidateScreenMetrics() + // Reposition footer when layout changes if (isPresented && changed) { positionFooter() @@ -446,6 +481,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } override fun coordinatorLayoutDidChangeConfiguration() { + invalidateScreenMetrics() if (!isPresented) return sheetView?.updateGravity() @@ -567,29 +603,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : else -> { } } - updateScrollExpansionPadding(sheetView.top) emitChangePositionDelegate(sheetView.top) - // On older APIs, use onSlide for footer positioning during keyboard transitions - val useLegacyKeyboardHandling = Build.VERSION.SDK_INT < Build.VERSION_CODES.R - if (!isKeyboardTransitioning || useLegacyKeyboardHandling) { - positionFooter(slideOffset) + // Drive the parent's stack translation from our actual frame position β€” + // an independently-timed animation lags behind (e.g. auto detent shrink) + // and reveals the parent above this sheet's top edge. Skipped while + // dragging so the parent stays put during drag-to-dismiss. + if (!isPresentAnimating && interactionState !is InteractionState.Dragging) { + syncParentTranslation(sheetView.top) } + // Position on every slide frame β€” during keyboard transitions the IME + // animation callbacks are sparser than the sheet's settle frames, and the + // footer rides the sheet between them, jittering against the keyboard. + // positionFooter derives from current state, so double-driving it with + // keyboardDidChangeHeight is harmless. + positionFooter(slideOffset) + if (!isKeyboardTransitioning) { updateDimAmount(sheetView.top) } } - private fun updateScrollExpansionPadding(sheetTop: Int) { - if (!scrollable) { - containerView?.contentView?.updateScrollExpansionPadding(0) - return - } - val expandedOffset = behavior?.expandedOffset ?: return - containerView?.contentView?.updateScrollExpansionPadding(maxOf(0, sheetTop - expandedOffset)) - } - private fun handleStateSettled(sheetView: View, newState: Int) { if (interactionState is InteractionState.Reconfiguring) return @@ -597,7 +632,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : // whether settled at the target, interrupted by drag, or superseded. pendingDetentIndex = -1 - val index = detentCalculator.getDetentIndexForState(newState) ?: return + val index = detentCalculator.getDetentIndexForState(newState) ?: run { + // Unmapped settle β€” end any drag anyway so deferred size changes aren't + // dropped forever (a leaked Dragging state skips every reconfigure) + if (interactionState is InteractionState.Dragging) { + interactionState = InteractionState.Idle + } + flushPendingSizeChange() + return + } val position = getPositionDpForView(sheetView) val detentInfo = DetentInfo(index, position) @@ -637,11 +680,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } + // Settled β€” resize the container to the settled detent (applies deferred shrinks) + updateStateDimensions() + if (!isKeyboardTransitioning) { updateDimAmount(animated = true) } TrueSheetStackManager.updateBackgroundAccessibility() + + flushPendingSizeChange() + } + + private fun flushPendingSizeChange() { + if (!hasPendingSizeChange) return + hasPendingSizeChange = false + setupSheetDetentsForSizeChange() } // ============================================================================= @@ -703,6 +757,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : val sheet = this.sheetView ?: return if (isPresented) return + invalidateScreenMetrics() shouldAnimatePresent = animated currentDetentIndex = detentIndex interactionState = InteractionState.Idle @@ -746,6 +801,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } pendingDetentIndex = detentIndex + updateStateDimensions(deferShrink = true) setupDimmedBackground() setStateForDetentIndex(detentIndex) resizePromise?.invoke() @@ -896,7 +952,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : animate = isPresented ) - updateStateDimensions(expandedOffset) + updateStateDimensions() if (isPresented && applyState) { // Prefer the pending target while a resize animation is in flight so a @@ -926,6 +982,16 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } fun setupSheetDetentsForSizeChange() { + // Defer while dragging β€” the size change is driven by the drag itself (the + // container tracks the sheet's visible height), and reconfiguring resets + // behavior state, killing the gesture. Applied on settle so a real content + // change mid-drag (e.g. items removed from a list) isn't lost. + if (interactionState is InteractionState.Dragging) { + hasPendingSizeChange = true + return + } + hasPendingSizeChange = false + setupSheetDetents() positionFooter() } @@ -1010,15 +1076,35 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : val footerView = containerView?.footerView ?: return val sheet = sheetView ?: return + // A relative footer is laid out by Yoga (pinned to the container's bottom + // edge) and stays in the layout flow behind the keyboard β€” only an + // absolute footer floats above it + if (!absoluteFooter) { + footerView.translationY = 0f + return + } + + // The footer's absorbed safe-area inset rides below the keyboard's top + // edge β€” its content stays flush with the keyboard without dropping the + // inset padding from the layout, which would jump at the transition edges + val keyboardShift = if (currentKeyboardInset > 0) { + maxOf(0, currentKeyboardInset + footerKeyboardOffset - footerView.bottomInset) + } else { + 0 + } + val footerHeight = footerView.height val sheetHeight = sheet.height val sheetTop = sheet.top - val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0 var footerY = (sheetHeight - sheetTop - footerHeight - keyboardShift).toFloat() - // Adjust during dismiss animation when slideOffset is negative - if (slideOffset != null && slideOffset < 0) { + // Adjust during dismiss animation when slideOffset is negative. Skipped + // while the keyboard is open β€” the detents collapse to the expanded + // position, so any downward drag reads as a negative slideOffset and the + // adjustment would sink the footer behind the keyboard, snapping back up + // when the keyboard detents reset on release. + if (slideOffset != null && slideOffset < 0 && keyboardShift == 0) { footerY -= (footerHeight * slideOffset) } @@ -1068,9 +1154,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : // If a resize is in flight, restore to its target β€” not the stale current detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex pendingDetentIndex = -1 - setupSheetDetents() + // Commit the target index first so the container grows before the sheet expands currentDetentIndex = detents.size - 1 - setStateForDetentIndex(currentDetentIndex) + setupSheetDetents() + positionFooter() updateDimAmount(animated = true) } @@ -1088,6 +1175,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } + // Same-frame reposition β€” the reconfigure above shrinks the container + // to the target detent, which yanks the bottom-pinned footer up until + // the next keyboard animation frame repositions it. + positionFooter() updateDimAmount( sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex), animated = true @@ -1120,6 +1211,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : detentIndexBeforeKeyboard = currentDetentIndex currentDetentIndex = detents.size - 1 setupSheetDetents() + positionFooter() } } } @@ -1188,6 +1280,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : private fun handleDragChange(sheetView: View) { if (interactionState !is InteractionState.Dragging) return + updateStateDimensionsForDrag(sheetView.top) + val position = getPositionDpForView(sheetView) val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex) delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent) @@ -1228,8 +1322,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : lastEmittedPositionPx = currentTop val visibleHeight = realScreenHeight - currentTop val position = detentCalculator.getPositionDp(visibleHeight) - val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop) - val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop) + val (interpolatedIndex, detent) = detentCalculator.getInterpolatedPositionInfo(currentTop) delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime) } @@ -1256,24 +1349,79 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : behavior.maxWidth = newMaxWidth } - private fun updateStateDimensions(expandedOffset: Int? = null) { - val offset = expandedOffset ?: (realScreenHeight - detentCalculator.getDetentHeight(detents.last())) - val topOffset = if (offset == 0) topInset else 0 - val newHeight = realScreenHeight - offset - topOffset + /** + * Updates the Fabric state with the current/target detent size so Yoga sizes + * the container to the sheet's visible height. + * + * Unlike iOS, resize animations run over multiple frames (ViewDragHelper), so + * shrinking is deferred to settle ([deferShrink]) when a resize animation is + * about to run β€” growing applies immediately so content is laid out before + * the sheet reveals it. + */ + private fun updateStateDimensions(deferShrink: Boolean = false) { + if (detents.isEmpty()) return + + val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex) + .coerceIn(0, detents.size - 1) + val maxAvailableHeight = realScreenHeight - topInset + val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight) + val newWidth = getStateWidth() + + if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return + + setStateDimensions(newWidth, newHeight) + } + + /** + * Tracks the sheet's visible height during drag (and the post-release settle + * animation) so the container resizes with the finger, like iOS. Clamped to the + * smallest detent β€” dragging below it slides the sheet out without resizing. + */ + private fun updateStateDimensionsForDrag(sheetTop: Int) { + if (detents.isEmpty()) return + + val maxAvailableHeight = realScreenHeight - topInset + val minHeight = minOf(detentCalculator.getDetentHeight(detents.first()), maxAvailableHeight) + val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight) + + setStateDimensions(getStateWidth(), newHeight) + } + + private fun getStateWidth(): Int { val applyMaxWidth = maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext) val effectiveMaxWidth = if (applyMaxWidth) maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt() - val newWidth = minOf(screenWidth, effectiveMaxWidth) + return minOf(screenWidth, effectiveMaxWidth) + } - if (lastStateWidth != newWidth || lastStateHeight != newHeight) { - lastStateWidth = newWidth - lastStateHeight = newHeight - delegate?.viewControllerDidChangeSize(newWidth, newHeight) - } + private fun setStateDimensions(width: Int, height: Int) { + if (lastStateWidth == width && lastStateHeight == height) return + + lastStateWidth = width + lastStateHeight = height + delegate?.viewControllerDidChangeSize(width, height) + } + + /** + * Realtime (non-animated) counterpart of TrueSheetStackManager.updateParentTranslation, + * driven by onSlide so the parent tracks this sheet's actual position. + */ + private fun syncParentTranslation(sheetTop: Int) { + val parent = parentSheetView ?: return + val minSheetTop = detentCalculator.getSheetTopForDetentIndex(0) + parent.syncTranslationForChild(sheetTop, minSheetTop) } - fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) { + fun translateSheet(translationY: Int, animated: Boolean = true, onEnd: (() -> Unit)? = null) { val sheet = sheetView ?: return + if (!animated) { + sheet.animate().cancel() + sheet.translationY = translationY.toFloat() + emitChangePositionDelegate(sheet.top + translationY) + onEnd?.invoke() + return + } + sheet.animate() .translationY(translationY.toFloat()) .setDuration(TRANSLATE_ANIMATION_DURATION) diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt index e224fbf7..f89c12e6 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt @@ -194,6 +194,7 @@ class TrueSheetViewManager : @ReactProp(name = "initialDetentIndex", defaultInt = -1) override fun setInitialDetentIndex(view: TrueSheetView, index: Int) { view.initialDetentIndex = index + view.presentInitialIfNeeded() } @ReactProp(name = "initialDetentAnimated", defaultBoolean = true) @@ -236,11 +237,6 @@ class TrueSheetViewManager : view.setInsetAdjustment(insetAdjustment ?: "automatic") } - @ReactProp(name = "scrollable", defaultBoolean = false) - override fun setScrollable(view: TrueSheetView, value: Boolean) { - view.setScrollable(value) - } - @ReactProp(name = "presentation") override fun setPresentation(view: TrueSheetView, value: String?) { // iOS/web-specific prop - no-op on Android @@ -251,6 +247,11 @@ class TrueSheetViewManager : view.setSheetElevation(elevation.toFloat()) } + @ReactProp(name = "scrollableHandle", defaultInt = -1) + override fun setScrollableHandle(view: TrueSheetView, handle: Int) { + view.setScrollableHandle(handle) + } + @ReactProp(name = "scrollableOptions") override fun setScrollableOptions(view: TrueSheetView, options: ReadableMap?) { if (options == null) { @@ -260,13 +261,25 @@ class TrueSheetViewManager : val scrollableOptions = ScrollableOptions( keyboardScrollOffset = if (options.hasKey("keyboardScrollOffset")) options.getDouble("keyboardScrollOffset").toFloat() else 0f, - scrollingExpandsSheet = if (options.hasKey("scrollingExpandsSheet")) options.getBoolean("scrollingExpandsSheet") else true + keyboardOffset = if (options.hasKey("keyboardOffset")) options.getDouble("keyboardOffset").toFloat() else 0f, + scrollingExpandsSheet = if (options.hasKey("scrollingExpandsSheet")) options.getBoolean("scrollingExpandsSheet") else true, + contentInsetAdjustment = !options.hasKey("contentInsetAdjustmentBehavior") || + options.getBoolean("contentInsetAdjustmentBehavior") ) view.setScrollableOptions(scrollableOptions) } + @ReactProp(name = "headerOptions") + override fun setHeaderOptions(view: TrueSheetView, options: ReadableMap?) { + val position = if (options != null && options.hasKey("position")) options.getString("position") else null + view.setAbsoluteHeader(position == "absolute") + } + @ReactProp(name = "footerOptions") override fun setFooterOptions(view: TrueSheetView, options: ReadableMap?) { + val position = if (options != null && options.hasKey("footerPosition")) options.getString("footerPosition") else null + view.setAbsoluteFooter(position == "absolute") + val keyboardOffset = if (options != null && options.hasKey("keyboardOffset")) options.getDouble("keyboardOffset").toFloat() else 0f view.setFooterKeyboardOffset(keyboardOffset) diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt index ac32023e..529ce476 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt @@ -57,7 +57,7 @@ class TrueSheetCoordinatorLayout(context: Context) : private var streamGestureClaimed = false // Whether the sheet owns vertical drags for this stream: the touch target is inside - // the sheet but outside the pinned scrollable (e.g. a header overlaying it), with + // the sheet but outside the detected scrollable (e.g. a header overlaying it), with // nothing vertically scrollable along its chain. Such targets consume the stream // natively (no nested scroll), and BottomSheetBehavior won't intercept because the // scrollable's bounds still contain the point (touchingScrollingChild) β€” intercept @@ -118,15 +118,16 @@ class TrueSheetCoordinatorLayout(context: Context) : override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { // RN's ReactEditText fires this(true) on ACTION_DOWN, killing sheet drag over an input. // Swallow only that case β€” nothing between the touched EditText and the sheet scrolls - // (an overflowing multiline EditText or an input inside a scrollable still scrolls). - // Other children (maps, sliders, scrollables) keep the standard disallow contract. + // (an overflowing multiline EditText or an input inside a scrollable that can actually + // scroll still scrolls). Other children (maps, sliders, scrollables) keep the standard + // disallow contract. if (disallowIntercept && streamDraggableEditText) return super.requestDisallowInterceptTouchEvent(disallowIntercept) } /** * Resolves the stream's touch target (respecting zIndex and pointerEvents) and - * decides whether the sheet may drag from it β€” inside the sheet, outside the pinned + * decides whether the sheet may drag from it β€” inside the sheet, outside the detected * scrollable, and nothing along the target's chain scrolls vertically. */ private fun updateStreamTargetFlags(ev: MotionEvent) { @@ -138,8 +139,16 @@ class TrueSheetCoordinatorLayout(context: Context) : .firstNotNullOfOrNull { it.getView() } ?: return if (target !== sheet && !target.isDescendantOf(sheet)) return + // A detected scrollable that can scroll (or hosts pull-to-refresh) owns its + // touches β€” nested scrolling drags the sheet. When it can't scroll (e.g. + // the sheet is fully expanded and the content fits) it eats the gesture + // instead, so fall through and let the sheet drag from it β€” an EditText + // target needs the disallow-intercept swallow or its stream dies. val scrollView = delegate?.findScrollView() - if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) return + if (scrollView != null && (target === scrollView || target.isDescendantOf(scrollView))) { + val hasRefreshControl = scrollView.parent is SwipeRefreshLayout + if (hasRefreshControl || scrollView.scrollY > 0 || scrollView.canScrollVertically(1)) return + } var view: View? = target while (view != null && view !== this) { diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt index ad370fa8..5e937c34 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt @@ -15,7 +15,9 @@ interface TrueSheetDetentCalculatorDelegate { val detents: MutableList val contentHeight: Int val headerHeight: Int + val absoluteHeader: Boolean val footerHeight: Int + val absoluteFooter: Boolean val peekContentHeight: Int val contentBottomInset: Int val maxContentHeight: Int? @@ -34,6 +36,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val realScreenHeight: Int get() = delegate?.realScreenHeight ?: 0 private val detents: List get() = delegate?.detents ?: emptyList() private val contentHeight: Int get() = delegate?.contentHeight ?: 0 + private val headerHeight: Int get() = delegate?.headerHeight ?: 0 private val footerHeight: Int get() = delegate?.footerHeight ?: 0 private val peekContentHeight: Int get() = delegate?.peekContentHeight ?: 0 @@ -42,13 +45,39 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0 private val topInset: Int get() = delegate?.topInset ?: 0 + /** + * Height for auto (-1.0) detents: content + header + footer height. + * An absolute (floating) header or footer overlaps the content, so it contributes no height. + */ + private val autoDetentHeight: Int + get() = contentHeight + + (if (delegate?.absoluteHeader == true) 0 else headerHeight) + + (if (delegate?.absoluteFooter == true) 0 else footerHeight) + + /** + * Bottom inset for auto detents. A relative footer owns the sheet's bottom + * edge, so it absorbs the inset instead of adding it to the auto height. + */ + private val autoDetentBottomInset: Int + get() = if (footerHeight > 0 && delegate?.absoluteFooter == false) 0 else contentBottomInset + + /** + * Bottom inset for peek detents. An absolute footer counts toward the peek + * height and absorbs the inset, so it isn't added again. + */ + private val peekDetentBottomInset: Int + get() = if (footerHeight > 0 && delegate?.absoluteFooter == true) 0 else contentBottomInset + /** * Height for peek (-2.0) detents: header + footer + peek content height. + * A relative footer is pushed off-screen at peek, so it contributes no height. * Falls back to 150dp when none is present. */ private val peekDetentHeight: Int get() { - val height = headerHeight + footerHeight + peekContentHeight + val height = headerHeight + + (if (delegate?.absoluteFooter == true) footerHeight else 0) + + peekContentHeight return if (height > 0) height else DEFAULT_PEEK_HEIGHT.dpToPx().toInt() } @@ -58,9 +87,9 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { */ fun getDetentHeight(detent: Double, includeKeyboard: Boolean = true): Int { val baseHeight = if (detent == -1.0) { - contentHeight + headerHeight + contentBottomInset + autoDetentHeight + autoDetentBottomInset } else if (detent == -2.0) { - peekDetentHeight + contentBottomInset + peekDetentHeight + peekDetentBottomInset } else { if (detent <= 0.0 || detent > 1.0) { throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1") @@ -106,7 +135,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { if (index < 0 || index >= detents.size) return 0f val value = detents[index] return if (value == -1.0) { - (contentHeight + headerHeight).toFloat() / screenHeight.toFloat() + autoDetentHeight.toFloat() / screenHeight.toFloat() } else if (value == -2.0) { peekDetentHeight.toFloat() / screenHeight.toFloat() } else { @@ -140,8 +169,12 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { */ private fun getDetentStateMap(): Map? = when (detents.size) { + // With one detent halfExpandedRatio matches the peek height, so the + // behavior can settle HALF_EXPANDED at the same position (e.g. an upward + // drag release) β€” map it too, or the settle is unmappable 1 -> mapOf( BottomSheetBehavior.STATE_COLLAPSED to 0, + BottomSheetBehavior.STATE_HALF_EXPANDED to 0, BottomSheetBehavior.STATE_EXPANDED to 0 ) @@ -168,7 +201,7 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { * Find which segment the position falls into for interpolation. * @return Triple(fromIndex, toIndex, progress) where progress is 0-1, or null if no detents */ - fun findSegmentForPosition(positionPx: Int): Triple? { + private fun findSegmentForPosition(positionPx: Int): Triple? { val count = detents.size if (count == 0) return null @@ -209,37 +242,23 @@ class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) { } /** - * Returns continuous index (e.g., 0.5 = halfway between detent 0 and 1). + * Returns interpolated continuous index (e.g., 0.5 = halfway between detent + * 0 and 1) and screen fraction for a position, sharing a single segment + * lookup β€” this runs on every position frame. */ - fun getInterpolatedIndexForPosition(positionPx: Int): Float { - val count = detents.size - if (count == 0) return -1f - - val segment = findSegmentForPosition(positionPx) ?: return 0f - val (fromIndex, _, progress) = segment - - if (fromIndex == -1) return -progress - return fromIndex + progress - } - - /** - * Returns interpolated screen fraction for position. - */ - fun getInterpolatedDetentForPosition(positionPx: Int): Float { - val count = detents.size - if (count == 0) return 0f + fun getInterpolatedPositionInfo(positionPx: Int): Pair { + if (detents.isEmpty()) return Pair(-1f, 0f) - val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0) - val (fromIndex, toIndex, progress) = segment + val (fromIndex, toIndex, progress) = findSegmentForPosition(positionPx) + ?: return Pair(0f, getDetentValueForIndex(0)) if (fromIndex == -1) { - val firstDetent = getDetentValueForIndex(0) - return maxOf(0f, firstDetent * (1 - progress)) + return Pair(-progress, maxOf(0f, getDetentValueForIndex(0) * (1 - progress))) } val fromDetent = getDetentValueForIndex(fromIndex) val toDetent = getDetentValueForIndex(toIndex) - return fromDetent + progress * (toDetent - fromDetent) + return Pair(fromIndex + progress, fromDetent + progress * (toDetent - fromDetent)) } companion object { diff --git a/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt b/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt index 20da9384..8a854b3c 100644 --- a/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +++ b/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt @@ -15,13 +15,6 @@ fun View.isDescendantOf(ancestor: View): Boolean { return false } -fun ViewGroup.smoothScrollBy(dx: Int, dy: Int) { - when (this) { - is ScrollView -> smoothScrollBy(dx, dy) - is NestedScrollView -> smoothScrollBy(dx, dy) - } -} - fun ViewGroup.smoothScrollTo(x: Int, y: Int) { when (this) { is ScrollView -> smoothScrollTo(x, y) diff --git a/android/src/main/jni/TrueSheetSpec.h b/android/src/main/jni/TrueSheetSpec.h index 9569f3d3..aa9068f2 100644 --- a/android/src/main/jni/TrueSheetSpec.h +++ b/android/src/main/jni/TrueSheetSpec.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include namespace facebook { diff --git a/android/src/main/jni/TrueSheetStateUpdater.cpp b/android/src/main/jni/TrueSheetStateUpdater.cpp new file mode 100644 index 00000000..c9056707 --- /dev/null +++ b/android/src/main/jni/TrueSheetStateUpdater.cpp @@ -0,0 +1,96 @@ +// +// Created by Jovanni Lo (@lodev09) +// Copyright (c) 2024-present. All rights reserved. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +static std::shared_ptr getStateFromWrapper(jni::alias_ref stateWrapper) { + static const auto stateWrapperImplClass = + jni::findClassStatic(StateWrapperImpl::StateWrapperImplJavaDescriptor); + if (!stateWrapper->isInstanceOf(stateWrapperImplClass)) { + return nullptr; + } + + auto impl = jni::static_ref_cast(stateWrapper); + return impl->cthis()->getState(); +} + +// Kotlin's StateWrapper.updateState is hardcoded async. This bridges to +// ConcreteState::updateState with unstable_Immediate so the commit β€” and, when +// called from the UI thread, the mount β€” run synchronously, letting Yoga resize +// the container in the same frame as the sheet (parity with iOS). +static jboolean updateStateImmediate( + jni::alias_ref /*clazz*/, + jni::alias_ref stateWrapper, + jfloat containerWidth, + jfloat containerHeight) { + auto state = getStateFromWrapper(stateWrapper); + if (!state) { + return JNI_FALSE; + } + + auto concreteState = std::static_pointer_cast>(state); + + // Copy the latest data so fields this bridge doesn't set survive the update + TrueSheetViewState newState = concreteState->getData(); + newState.containerWidth = containerWidth; + newState.containerHeight = containerHeight; + concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate); + + return JNI_TRUE; +} + +// Same synchronous bridge for the footer's bottom inset β€” the footer must be +// padded (and resized) before detents are configured, otherwise the behavior +// is set up against the unpadded footer height and the auto detent lands an +// inset short. +static jboolean updateFooterStateImmediate( + jni::alias_ref /*clazz*/, + jni::alias_ref stateWrapper, + jfloat bottomInset) { + auto state = getStateFromWrapper(stateWrapper); + if (!state) { + return JNI_FALSE; + } + + auto concreteState = + std::static_pointer_cast>(state); + + // Copy the latest data so fields this bridge doesn't set survive the update + TrueSheetFooterViewState newState = concreteState->getData(); + newState.bottomInset = bottomInset; + newState.initialized = true; + concreteState->updateState(std::move(newState), EventQueue::UpdateMode::unstable_Immediate); + + return JNI_TRUE; +} + +// Publishes the precalculated bottom safe-area inset so a late-mounted +// footer's first layout is already padded (see TrueSheetFooterViewShadowNode). +static void setBottomSafeArea(jni::alias_ref /*clazz*/, jfloat insetDp) { + TrueSheetInsets::setBottomSafeArea(insetDp); +} + +} // namespace facebook::react + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { + return facebook::jni::initialize(vm, [] { + facebook::jni::findClassStatic("com/lodev09/truesheet/TrueSheetStateUpdater") + ->registerNatives({ + makeNativeMethod("nativeUpdateState", facebook::react::updateStateImmediate), + makeNativeMethod("nativeUpdateFooterState", facebook::react::updateFooterStateImmediate), + makeNativeMethod("nativeSetBottomSafeArea", facebook::react::setBottomSafeArea), + }); + }); +} diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h new file mode 100644 index 00000000..ef9ef6c8 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace facebook::react { + +/* + * Descriptor for component. + */ +class TrueSheetContentViewComponentDescriptor final + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + auto &concreteShadowNode = + static_cast(shadowNode); + concreteShadowNode.adjustLayoutWithState(); + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp new file mode 100644 index 00000000..850c69bb --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp @@ -0,0 +1,89 @@ +#include "TrueSheetContentViewShadowNode.h" + +#include + +#include +#include +#include + +namespace facebook::react { + +using namespace yoga; + +extern const char TrueSheetContentViewComponentName[] = "TrueSheetContentView"; + +// measure() lays out a clone of this node, which re-enters layout() on the +// clone β€” guard so the measurement pass doesn't measure again recursively. +static thread_local bool gIsMeasuringNaturalHeight = false; + +void TrueSheetContentViewShadowNode::adjustLayoutWithState() { + ensureUnsealed(); + + auto state = std::static_pointer_cast< + const TrueSheetContentViewShadowNode::ConcreteState>(getState()); + auto stateData = state->getData(); + + auto &props = getConcreteProps(); + + // When a ScrollView is detected under an auto detent, fill the container so + // descendant flex layouts (flex:1 wrappers, the ScrollView itself) resolve + // against a definite height and the viewport is bounded β€” natural layout is + // circular there since the sheet height derives from the content's natural + // height instead (see updateNaturalHeightIfNeeded). For fixed detents, + // content lays out naturally like a regular view. + auto flexGrow = stateData.scrollableBounded + ? FloatOptional{1.0f} + : props.yogaStyle.flexGrow(); + auto flexShrink = stateData.scrollableBounded + ? FloatOptional{1.0f} + : props.yogaStyle.flexShrink(); + + if (yogaNode_.style().flexGrow() != flexGrow || + yogaNode_.style().flexShrink() != flexShrink) { + yoga::Style adjustedStyle = props.yogaStyle; + adjustedStyle.setFlexGrow(flexGrow); + adjustedStyle.setFlexShrink(flexShrink); + yogaNode_.setStyle(adjustedStyle); + yogaNode_.setDirty(true); + } +} + +void TrueSheetContentViewShadowNode::layout(LayoutContext layoutContext) { + ConcreteViewShadowNode::layout(layoutContext); + updateNaturalHeightIfNeeded(layoutContext); +} + +// The natural height is the height the content wants when unbounded β€” the +// source for the auto detent. Once the container bounds the content +// (scrollableBounded), the committed layout no longer reflects it, so lay out +// a clone of the subtree with an unconstrained height. Yoga respects the +// user's styles: an explicit-height ScrollView keeps its height while a +// flexible one expands to its content β€” no ScrollView discovery involved. +void TrueSheetContentViewShadowNode::updateNaturalHeightIfNeeded( + const LayoutContext &layoutContext) { + if (gIsMeasuringNaturalHeight) { + return; + } + + auto stateData = getStateData(); + auto size = getLayoutMetrics().frame.size; + + Float naturalHeight = size.height; + if (stateData.scrollableBounded) { + auto constraints = LayoutConstraints{}; + constraints.minimumSize = {size.width, 0}; + constraints.maximumSize = {size.width, std::numeric_limits::infinity()}; + constraints.layoutDirection = getLayoutMetrics().layoutDirection; + + gIsMeasuringNaturalHeight = true; + naturalHeight = measure(layoutContext, constraints).height; + gIsMeasuringNaturalHeight = false; + } + + if (stateData.naturalHeight != naturalHeight) { + stateData.naturalHeight = naturalHeight; + setStateData(std::move(stateData)); + } +} + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h new file mode 100644 index 00000000..30941e9b --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char TrueSheetContentViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +class JSI_EXPORT TrueSheetContentViewShadowNode final + : public ConcreteViewShadowNode< + TrueSheetContentViewComponentName, + TrueSheetContentViewProps, + TrueSheetContentViewEventEmitter, + TrueSheetContentViewState> { + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + public: + void adjustLayoutWithState(); + +#pragma mark - LayoutableShadowNode + + void layout(LayoutContext layoutContext) override; + + private: + void updateNaturalHeightIfNeeded(const LayoutContext &layoutContext); +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp new file mode 100644 index 00000000..e8acf970 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp @@ -0,0 +1,12 @@ +#include "TrueSheetContentViewState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic TrueSheetContentViewState::getDynamic() const { + return folly::dynamic::object("scrollableBounded", scrollableBounded)( + "naturalHeight", naturalHeight); +} +#endif + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h new file mode 100644 index 00000000..13b17cdd --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#ifdef ANDROID +#include +#include +#include +#endif + +namespace facebook::react { + +/* + * State for component. + * `scrollableBounded` is set from native when a ScrollView is detected so the + * shadow node bounds the content to the container via flexShrink. + * `naturalHeight` is measured by the shadow node during layout β€” the height + * the content wants when unbounded (see TrueSheetContentViewShadowNode). + */ +class TrueSheetContentViewState final { + public: + TrueSheetContentViewState() = default; + +#ifdef ANDROID + TrueSheetContentViewState( + TrueSheetContentViewState const &previousState, + folly::dynamic data) + : scrollableBounded( + data.getDefault("scrollableBounded", previousState.scrollableBounded) + .getBool()), + naturalHeight(previousState.naturalHeight) {} +#endif + + bool scrollableBounded{false}; + Float naturalHeight{0}; + +#ifdef ANDROID + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + } +#endif +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h new file mode 100644 index 00000000..2d81f202 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace facebook::react { + +/* + * Descriptor for component. + */ +class TrueSheetFooterViewComponentDescriptor final + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + auto &concreteShadowNode = + static_cast(shadowNode); + concreteShadowNode.adjustLayoutWithState(); + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp new file mode 100644 index 00000000..758642d9 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp @@ -0,0 +1,63 @@ +#include "TrueSheetFooterViewShadowNode.h" + +#include + +#include "TrueSheetInsets.h" + +namespace facebook::react { + +using namespace yoga; + +extern const char TrueSheetFooterViewComponentName[] = "TrueSheetFooterView"; + +void TrueSheetFooterViewShadowNode::adjustLayoutWithState() { + ensureUnsealed(); + + auto state = std::static_pointer_cast< + const TrueSheetFooterViewShadowNode::ConcreteState>(getState()); + auto stateData = state->getData(); + + auto &props = getConcreteProps(); + + // Until native pushes the real inset (which requires this node to already + // exist), seed a late-mounted footer's first layout with the precalculated + // inset β€” otherwise it lays out unpadded and forces a second sheet resize. + float bottomInset = stateData.bottomInset; + if (!stateData.initialized && props.autoBottomInset) { + bottomInset = TrueSheetInsets::bottomSafeArea(); + if (bottomInset != stateData.bottomInset) { + // Record the seeded value so the mounted view reads exactly the inset + // baked into this layout β€” the global may change before mount. + auto seededData = stateData; + seededData.bottomInset = bottomInset; + setStateData(std::move(seededData)); + } + } + + // A footer pinned to the sheet's bottom edge owns the bottom safe-area + // inset β€” pad it on top of any style padding so its content clears the + // home indicator while the background fills the inset. + auto padding = props.yogaStyle.padding(Edge::Bottom); + if (bottomInset > 0) { + // Base bottom padding from the style β€” Edge::Bottom wins over + // Vertical/All, mirroring Yoga's own resolution. Points only. + float base = 0; + for (auto edge : {Edge::Bottom, Edge::Vertical, Edge::All}) { + auto length = props.yogaStyle.padding(edge); + if (length.isDefined()) { + base = length.isPoints() ? length.value().unwrap() : 0; + break; + } + } + padding = StyleLength::points(base + bottomInset); + } + + if (yogaNode_.style().padding(Edge::Bottom) != padding) { + yoga::Style adjustedStyle = props.yogaStyle; + adjustedStyle.setPadding(Edge::Bottom, padding); + yogaNode_.setStyle(adjustedStyle); + yogaNode_.setDirty(true); + } +} + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h new file mode 100644 index 00000000..758ceeeb --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char TrueSheetFooterViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +class JSI_EXPORT TrueSheetFooterViewShadowNode final + : public ConcreteViewShadowNode< + TrueSheetFooterViewComponentName, + TrueSheetFooterViewProps, + TrueSheetFooterViewEventEmitter, + TrueSheetFooterViewState> { + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + public: + void adjustLayoutWithState(); +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp new file mode 100644 index 00000000..e447e61b --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp @@ -0,0 +1,11 @@ +#include "TrueSheetFooterViewState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic TrueSheetFooterViewState::getDynamic() const { + return folly::dynamic::object("bottomInset", bottomInset); +} +#endif + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h new file mode 100644 index 00000000..147fb295 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h @@ -0,0 +1,41 @@ +#pragma once + +#ifdef ANDROID +#include +#include +#include +#endif + +namespace facebook::react { + +/* + * State for component. + * Set from native with the sheet's bottom safe-area inset so the shadow node + * pads the footer's bottom edge (see TrueSheetFooterViewShadowNode). + */ +class TrueSheetFooterViewState final { + public: + TrueSheetFooterViewState() = default; + +#ifdef ANDROID + TrueSheetFooterViewState( + TrueSheetFooterViewState const &previousState, + folly::dynamic data) + : bottomInset(static_cast(data["bottomInset"].getDouble())), + initialized(true) {} +#endif + + float bottomInset{0}; + // True once native has pushed a real inset β€” until then the shadow node + // seeds the layout from TrueSheetInsets (see TrueSheetFooterViewShadowNode) + bool initialized{false}; + +#ifdef ANDROID + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + } +#endif +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp new file mode 100644 index 00000000..f9c9ecdb --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp @@ -0,0 +1,18 @@ +#include "TrueSheetInsets.h" + +#include + +namespace facebook::react { + +// Written from the UI thread, read from the layout thread +static std::atomic gBottomSafeArea{0}; + +void TrueSheetInsets::setBottomSafeArea(float inset) { + gBottomSafeArea.store(inset, std::memory_order_relaxed); +} + +float TrueSheetInsets::bottomSafeArea() { + return gBottomSafeArea.load(std::memory_order_relaxed); +} + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h new file mode 100644 index 00000000..caccedc3 --- /dev/null +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h @@ -0,0 +1,16 @@ +#pragma once + +namespace facebook::react { + +/* + * Bottom safe-area inset published by the host platform (points/dp) so a + * late-mounted footer's first layout can be padded before any Fabric state + * lands (see TrueSheetFooterViewShadowNode). + */ +class TrueSheetInsets { + public: + static void setBottomSafeArea(float inset); + static float bottomSafeArea(); +}; + +} // namespace facebook::react diff --git a/docs/docs/guides/footer.mdx b/docs/docs/guides/footer.mdx index bcf6b23f..b6a23054 100644 --- a/docs/docs/guides/footer.mdx +++ b/docs/docs/guides/footer.mdx @@ -1,12 +1,12 @@ --- title: Adding Footer -description: Add a floating footer component to the bottom sheet. +description: Add a footer component pinned to the bottom of the sheet. keywords: [bottom sheet footer, bottom sheet floating footer, bottom sheet sticky footer] --- -Do you need a FAB (Floating Action Button) on the sheet? Or perhaps a small banner at the bottom? We've got you covered! +Do you need action buttons on the sheet? Or perhaps a small banner at the bottom? We've got you covered! -`TrueSheet` provides first-class support for a `Footer` component. This component will float at the bottom of the sheet. +`TrueSheet` provides first-class support for a `Footer` component. The footer is pinned to the bottom of the sheet, takes up space below the content, and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. ## How? @@ -57,52 +57,73 @@ const App = () => { ## Safe Area Handling -The footer is pinned to the bottom edge of the sheet. The sheet height automatically includes the bottom safe area on both iOS and Android. To ensure your footer extends properly into the safe area, add bottom padding: +The footer owns the sheet's bottom edge, so it automatically absorbs the bottom safe-area inset as padding when [`insetAdjustment`](../reference/configuration#insetadjustment) is `"automatic"` (the default). Your footer content stays above the home indicator while its background fills the inset β€” no manual padding needed. ```tsx -import { Platform } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -const isIPad = Platform.OS === 'ios' && Platform.isPad; - const MyFooter = () => { - const insets = useSafeAreaInsets(); - const bottomInset = isIPad ? 0 : insets.bottom; - return ( - - - Footer Content - + + Footer Content ); }; // Usage -}> +} footerStyle={{ backgroundColor: '#333' }}> {/* content */} ``` -This ensures the footer background extends into the safe area while keeping the content above the home indicator. +The inset is applied to the footer component itself, so set your background via [`footerStyle`](../reference/configuration#footerstyle) (or on your footer's root view) for it to fill the safe area. -When the keyboard opens, that bottom padding leaves a gap above the keyboard. Pass `-insets.bottom` to [`footerOptions.keyboardOffset`](../reference/configuration#footeroptions) to tuck it behind the keyboard β€” see [Keyboard Handling](./keyboard#footer-behavior). +When the keyboard opens, an absolute footer rises above it and the inset is skipped automatically β€” no gap above the keyboard. A relative footer stays in the layout flow and keeps its inset. See [Keyboard Handling](./keyboard#footer-behavior). :::note -On iPad, the sheet is displayed as a floating modal, so bottom padding is not needed. +On iPad, the sheet is displayed as a floating modal, so no inset is applied. Set `insetAdjustment="never"` to opt out entirely. +::: + +## Floating Footer + +By default, the footer takes up space below the content and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. Set `footerOptions.position` to `'absolute'` to float the footer over the content instead β€” it stays pinned to the bottom edge but no longer adds to the `auto` detent height. An absolute footer absorbs the bottom safe-area inset the same way a relative one does β€” see [Safe Area Handling](#safe-area-handling). + +```tsx {5-5} +const App = () => { + return ( + } + > + } + /> + + ) +} +``` + +:::tip +Since the footer overlaps the content, add bottom padding to your content (e.g. `contentContainerStyle`) so it ends above the footer. +::: + +:::info +A relative footer is laid out below the content β€” if your content is taller than the sheet's visible height (e.g. fixed detents), bound it with `flex: 1` so the footer stays visible. Use `'absolute'` if you want the footer to float regardless of the content's height. ::: ## Collapsing to the Footer -The footer height counts toward the [`"peek"`](../reference/types#sheetdetent) detent β€” collapse the sheet down to just the [`header`](header), footer, and any peeking content. +An **absolute** footer's height counts toward the [`"peek"`](../reference/types#sheetdetent) detent β€” collapse the sheet down to just the [`header`](header), footer, and any peeking content. -```tsx {4-4} +```tsx {4-6} const App = () => { return ( } + footerOptions={{ position: 'absolute' }} > @@ -110,4 +131,4 @@ const App = () => { } ``` -Since the footer floats, it stays visible at every detent. See [Peeking Content](peeking) for the full guide. +Since an absolute footer floats over the content, it stays visible at every detent. A relative footer is laid out below the content, so it's pushed off-screen at the peek detent and is excluded from the peek height. See [Peeking Content](peeking) for the full guide. diff --git a/docs/docs/guides/header.mdx b/docs/docs/guides/header.mdx index 06e6214a..26e5ee85 100644 --- a/docs/docs/guides/header.mdx +++ b/docs/docs/guides/header.mdx @@ -65,9 +65,35 @@ const App = () => { When using `header` with `FlatList` or `ScrollView`, the content area height is automatically adjusted to account for the header height. This ensures proper scrolling behavior on both iOS and Android. ::: +## Floating Header + +By default, the header takes up space above the content and its height is included in the [`auto`](../reference/types#sheetdetent) detent calculation. Set `headerOptions.position` to `'absolute'` to float the header over the content instead β€” it gets pinned to the top edge and no longer adds to the `auto` detent height. + +```tsx {5-5} +const App = () => { + return ( + } + > + } + /> + + ) +} +``` + +:::tip +Since the header overlaps the content, add top padding to your content (e.g. `contentContainerStyle`) so it starts below the header. +::: + ## Collapsing to the Header -Use the [`"peek"`](../reference/types#sheetdetent) detent to collapse the sheet down to just the header (plus [`footer`](footer), if provided). Great for map-style sheets with a compact summary that expands into full content. +Use the [`"peek"`](../reference/types#sheetdetent) detent to collapse the sheet down to just the header (plus an [absolute `footer`](footer#floating-footer), if provided). Great for map-style sheets with a compact summary that expands into full content. ```tsx {4-4} const App = () => { @@ -77,7 +103,7 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable + footerOptions={{ position: 'absolute' }} > diff --git a/docs/docs/guides/jest.mdx b/docs/docs/guides/jest.mdx index df665856..415234c5 100644 --- a/docs/docs/guides/jest.mdx +++ b/docs/docs/guides/jest.mdx @@ -23,6 +23,11 @@ jest.mock('@lodev09/react-native-true-sheet/navigation', () => require('@lodev09/react-native-true-sheet/navigation/mock') ); +// Expo Router navigation (if using) +jest.mock('@lodev09/react-native-true-sheet/navigation/expo-router', () => + require('@lodev09/react-native-true-sheet/navigation/expo-router/mock') +); + // Reanimated (if using) jest.mock('@lodev09/react-native-true-sheet/reanimated', () => require('@lodev09/react-native-true-sheet/reanimated/mock') @@ -57,6 +62,13 @@ The `transformIgnorePatterns` config is required because the mock files use ESM ### Navigation Module (`/navigation/mock`) - `createTrueSheetNavigator` - Mocked navigator factory +- `createTrueSheetScreen` - Mocked static API screen factory +- `TrueSheetActions` - Mocked action creators +- `useTrueSheetNavigation` - Hook returning mocked navigation object + +### Expo Router Module (`/navigation/expo-router/mock`) + +- `Sheet` - Pass-through layout with mocked `Screen` and `Protected` - `TrueSheetActions` - Mocked action creators - `useTrueSheetNavigation` - Hook returning mocked navigation object diff --git a/docs/docs/guides/keyboard.mdx b/docs/docs/guides/keyboard.mdx index 22390d6a..d03f55ac 100644 --- a/docs/docs/guides/keyboard.mdx +++ b/docs/docs/guides/keyboard.mdx @@ -36,12 +36,12 @@ const App = () => { ## Scrollable + Keyboard -When using [`scrollable`](../reference/configuration#scrollable), keyboard handling works out of the box. If a `TextInput` inside a `ScrollView` is focused, the sheet automatically scrolls to keep the input visible above the keyboard β€” no extra setup needed. +When your sheet contains a `ScrollView` or `FlatList` (provided via [`scrollableRef`](../reference/configuration#scrollableref)), keyboard handling works out of the box. If a `TextInput` inside a `ScrollView` is focused, the sheet automatically scrolls to keep the input visible above the keyboard β€” no extra setup needed. You can use `keyboardScrollOffset` in [`scrollableOptions`](../reference/configuration#scrollableoptions) to add extra spacing between the focused input and the keyboard: ```tsx - + @@ -52,21 +52,33 @@ You can use `keyboardScrollOffset` in [`scrollableOptions`](../reference/configu This is especially useful for forms with multiple inputs β€” as the user taps between fields, the scroll view adjusts automatically to keep the active input in view. +While the keyboard is open, the scrollable gets a bottom inset matching the keyboard height. If your content renders its own bottom padding (e.g. you opted out of [`contentInsetAdjustmentBehavior`](../guides/scrolling#bottom-safe-area-inset) and applied safe-area padding via `paddingBottom`), that padding stacks on top of the inset and leaves a gap above the keyboard. Use `keyboardOffset` to adjust the inset β€” pass a negative value to cancel out the padding: + +```tsx +const insets = useSafeAreaInsets() + + + + + + +``` + ## Footer Behavior -When using a [`footer`](../reference/configuration#footer) component, it automatically repositions above the keyboard, staying visible while the user types. +A relative [`footer`](../reference/configuration#footer) (the default) stays in the layout flow β€” it doesn't float above the keyboard. The keyboard simply covers it, and it's revealed again when the keyboard hides. -If your footer renders its own bottom inset (e.g. safe-area padding for the home indicator), that padding leaves a gap above the keyboard. Use `keyboardOffset` in [`footerOptions`](../reference/configuration#footeroptions) to adjust the rise β€” pass `-insets.bottom` to tuck the inset behind the keyboard: +An absolute footer automatically repositions above the keyboard, staying visible while the user types. The automatic scroll accounts for the footer's height β€” focused inputs clear both the keyboard and the footer. Its built-in [safe-area inset](./footer#safe-area-handling) is skipped while the keyboard is open β€” the footer hugs the keyboard with no gap, and the inset is restored when the keyboard hides. -```tsx -const insets = useSafeAreaInsets() +If your absolute footer renders its own bottom padding, that padding leaves a gap above the keyboard. Use `keyboardOffset` in [`footerOptions`](../reference/configuration#footeroptions) to adjust the rise β€” pass a negative value to tuck it behind the keyboard: -} footerOptions={{ keyboardOffset: -insets.bottom }}> +```tsx +} footerOptions={{ position: 'absolute', keyboardOffset: -16 }}> {/* ... */} ``` -The footer slides up by the keyboard height plus `keyboardOffset`. A negative value reduces the rise so the inset sits behind the keyboard; a positive value raises the footer higher. +The footer slides up by the keyboard height plus `keyboardOffset`. A negative value reduces the rise so the padding sits behind the keyboard; a positive value raises the footer higher. ## Autofocus Limitation diff --git a/docs/docs/guides/navigation.mdx b/docs/docs/guides/navigation.mdx index 37aa7ba7..6aed95da 100644 --- a/docs/docs/guides/navigation.mdx +++ b/docs/docs/guides/navigation.mdx @@ -16,10 +16,12 @@ You can use the [Sheet Navigator](#sheet-navigator) to present screens as sheets ## Sheet Navigator -TrueSheet provides a custom navigator for React Navigation. The first screen (or `initialRouteName`) is the base content, while other screens are presented as sheets. +TrueSheet provides a custom navigator built on [`standard-navigation`](https://github.com/react-navigation/standard-navigation), so the same implementation works with both React Navigation and [Expo Router](#expo-router). The first screen (or `initialRouteName`) is the base content, while other screens are presented as sheets. + +Install `@react-navigation/native` (version `7.3.0` or higher) and `standard-navigation`. Both are declared as optional peer dependencies. ```bash -npm install @react-navigation/native +npm install @react-navigation/native standard-navigation ``` ### Basic Usage @@ -51,6 +53,27 @@ function App() { } ``` +### Static API + +The navigator also supports React Navigation's [static API](https://reactnavigation.org/docs/static-configuration) via `createTrueSheetScreen`: + +```tsx +import { + createTrueSheetNavigator, + createTrueSheetScreen, +} from '@lodev09/react-native-true-sheet/navigation'; + +const Sheet = createTrueSheetNavigator({ + screens: { + Main: MainScreen, + Details: createTrueSheetScreen({ + screen: DetailsSheet, + options: { detents: ['auto', 1], cornerRadius: 16 }, + }), + }, +}); +``` + ### Wrapping Existing Navigation Wrap your root navigator to present sheets from anywhere: @@ -214,7 +237,13 @@ See [Lifecycle Events](../reference/events) for more details. ## Expo Router -The [Sheet Navigator](#sheet-navigator) works with Expo Router using `withLayoutContext`. All [navigator features](#sheet-navigator) above (screen options, reanimated, dynamic header/footer, listeners) apply here too. +TrueSheet ships a ready-to-use `Sheet` layout for Expo Router via the `/navigation/expo-router` entry point. It integrates with Expo Router's built-in navigation directly β€” no `@react-navigation/*` install needed. All [navigator features](#sheet-navigator) above (screen options, reanimated, dynamic header/footer, listeners) apply here too. + +Requires Expo SDK 57+ (`expo-router` version `57.0.0` or higher). Install `standard-navigation`, declared as an optional peer dependency: + +```bash +npm install standard-navigation +``` ``` app/ @@ -225,24 +254,7 @@ app/ ```tsx // app/_layout.tsx -import { withLayoutContext } from 'expo-router'; -import { - createTrueSheetNavigator, - type TrueSheetNavigationEventMap, - type TrueSheetNavigationOptions, - type TrueSheetNavigationState, -} from '@lodev09/react-native-true-sheet/navigation'; - -type ParamListBase = Record; - -const { Navigator } = createTrueSheetNavigator(); - -const Sheet = withLayoutContext< - TrueSheetNavigationOptions, - typeof Navigator, - TrueSheetNavigationState, - TrueSheetNavigationEventMap ->(Navigator); +import { Sheet } from '@lodev09/react-native-true-sheet/navigation/expo-router'; export default function SheetLayout() { return ( @@ -260,11 +272,13 @@ export default function SheetLayout() { } ``` -See [Expo Router docs](https://docs.expo.dev/router/introduction/) for more information. +Inside sheet screens, import `useTrueSheetNavigation` from the same entry point: -:::note -The Sheet Navigator works with Expo Router SDK 56+ without installing `@react-navigation/native`. Under SDK 56, Expo Router no longer allows app code to import directly from `@react-navigation/*` β€” import navigation types/utilities from `expo-router` (or `expo-router/react-navigation`) instead. See the [Expo Router SDK 55 β†’ 56 migration guide](https://docs.expo.dev/router/migrate/sdk-55-to-56/). -::: +```tsx +import { useTrueSheetNavigation } from '@lodev09/react-native-true-sheet/navigation/expo-router'; +``` + +See [Expo Router docs](https://docs.expo.dev/router/introduction/) for more information. ## Navigating from Sheets diff --git a/docs/docs/guides/peeking.mdx b/docs/docs/guides/peeking.mdx index c99a9d0d..448c94be 100644 --- a/docs/docs/guides/peeking.mdx +++ b/docs/docs/guides/peeking.mdx @@ -8,7 +8,7 @@ Building a map-style sheet with a compact summary that expands into full content ## The `"peek"` Detent -Add `"peek"` to your [`detents`](../reference/configuration#detents) to get a collapsed size based on the measured [`header`](header) and [`footer`](footer) heights. +Add `"peek"` to your [`detents`](../reference/configuration#detents) to get a collapsed size based on the measured [`header`](header) height, plus an [absolute footer](footer#floating-footer) if provided β€” a relative footer is laid out below the content, so it's pushed off-screen at peek and doesn't count. ```tsx {4-4} const App = () => { @@ -18,7 +18,7 @@ const App = () => { initialDetentIndex={0} header={} footer={} - scrollable + footerOptions={{ position: 'absolute' }} > @@ -26,7 +26,7 @@ const App = () => { } ``` -The peek height automatically follows the measured header and footer sizes. When neither `header` nor `footer` is provided, it falls back to a fixed height of `150`. +The peek height automatically follows the measured header and footer sizes. When no `header`, absolute `footer`, or peeking content is provided, it falls back to a fixed height of `150`. ## Peeking Part of the Content diff --git a/docs/docs/guides/scrolling.mdx b/docs/docs/guides/scrolling.mdx index 37332129..b26eda01 100644 --- a/docs/docs/guides/scrolling.mdx +++ b/docs/docs/guides/scrolling.mdx @@ -6,24 +6,24 @@ keywords: [bottom sheet scrolling, bottom sheet scrollview, bottom sheet flatlis import scrolling from './assets/scrolling.gif' -Scrolling content within the sheet requires some special configuration on both iOS and Android. Let's admit, some things just don't work out of the box. - -Follow the guide below so you can scroll your content using `TrueSheet`. +Scrolling content within the sheet works like a regular view β€” bound the scroll view's height with `flex: 1`, plug a `ScrollView` or `FlatList` in, and point the sheet at it with [`scrollableRef`](../reference/configuration#scrollableref). scrolling ## How? -Enable the [`scrollable`](../reference/configuration#scrollable) prop on your `TrueSheet` to properly handle scrolling within the sheet. +Pass a ref of your scroll view (including `ScrollView` and `FlatList`) to the sheet via the [`scrollableRef`](../reference/configuration#scrollableref) prop. Keyboard handling and nested scrolling are then wired for you. + +Like any scroll view in React Native, it needs a bounded height to scroll. Pass `flex: 1` via the sheet's [`style`](../reference/configuration#style) prop so the content fills the sheet's visible height per detent β€” resizing in realtime while dragging: -```tsx {5-7} +```tsx const App = () => { const sheet = useRef(null) + const scrollableRef = useRef(null) return ( - - {/* Header content */} - + + @@ -31,35 +31,50 @@ const App = () => { } ``` +The scroll view doesn't have to be a direct child β€” wrapping it in a container `View` works too: + +```tsx + + + {/* Header content */} + + + +``` + :::info -By default, `scrollable` is `false`. Set it to `true` to enable automatic ScrollView fitting. +On Android, nested scrolling is managed internally β€” any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored. `RefreshControl` is also fully supported. ::: -:::warning -The [`auto`](../reference/types#sheetdetent) detent is not supported with `scrollable`. Use fixed fractional detents (e.g., `0.5`, `0.8`, `1`) instead. +:::info +The [`auto`](../reference/types#sheetdetent) detent works with scrolling content too β€” no `flex: 1` needed. The sheet sizes to the scroll view's content height and resizes as content grows or shrinks, while the viewport is automatically bounded to the sheet's visible height. ::: -### iOS +## Bottom Safe-Area Inset -On iOS, `scrollable` automatically pins scroll views (including `ScrollView` and `FlatList`) to fit within the sheet's available space. The ScrollView's top edge will be pinned below any top sibling views, and its left, right, and bottom edges will be pinned to the container. +The sheet applies the bottom safe-area inset to your scroll content automatically while the content can scroll β€” the last item clears the home indicator (iOS) and navigation bar (Android): -The scroll view detection supports up to 2 levels deep in the view hierarchy, so you can wrap your `ScrollView` or `FlatList` in a container `View` if needed: +- **iOS**: The scroll view's native [`contentInsetAdjustmentBehavior`](https://developer.apple.com/documentation/uikit/uiscrollview/contentinsetadjustmentbehavior) is set to `automatic` while it's plugged into the sheet β€” no need to set React Native's iOS-only prop yourself. +- **Android**: The inset is applied as content padding, mirroring iOS's `automatic` behavior β€” parity React Native's iOS-only prop can't provide. +- **Web**: The content is lifted above the safe area (`env(safe-area-inset-bottom)`). + +To opt out and manage the padding yourself, set `contentInsetAdjustmentBehavior` to `false` in [`scrollableOptions`](../reference/configuration#scrollableoptions): ```tsx - - - {/* Header content */} - - +const insets = useSafeAreaInsets() + + + + + ``` -### Android - -On Android, `scrollable` ensures the scroll view fills the available sheet space. Nested scrolling is automatically enabled on the detected scroll view, so you don't need to set `nestedScrollEnabled` manually. - :::info -When `scrollable` is enabled, any `nestedScrollEnabled` prop on your `ScrollView` or `FlatList` is ignored β€” TrueSheet manages nested scrolling internally. `RefreshControl` is also fully supported. +This only applies while the sheet's [`insetAdjustment`](../reference/configuration#insetadjustment) is `'automatic'`. If the sheet has a relative [`footer`](footer), the footer absorbs the bottom inset instead β€” pad your content for the footer's height, not the safe area. ::: ## Preventing Scroll-to-Expand @@ -68,11 +83,11 @@ By default, scrolling content to the top edge expands the sheet to the next dete ```tsx - + @@ -88,7 +103,7 @@ Set [`topScrollEdgeEffect`](../reference/types#scrollableoptions) and/or [`botto ```tsx } footer={