Compare commits

..

3 Commits

Author SHA1 Message Date
Marcos Rodriguez Velez
58c0ff31e6
make anchorViewTag optional 2024-07-18 13:47:13 -04:00
Marcos Rodriguez Vélez
7642126df6
Merge branch 'main' into popover 2024-07-16 23:56:54 -04:00
Marcos Rodriguez Velez
924f6b22b8
add popover support for iOS 2024-07-16 20:55:40 -04:00
364 changed files with 13046 additions and 33657 deletions

View File

@ -1,14 +0,0 @@
---
BasedOnStyle: Google
IndentWidth: 2
ColumnLimit: 120
ContinuationIndentWidth: 2
AlignAfterOpenBracket: DontAlign
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
ObjCBlockIndentWidth: 2
ObjCSpaceAfterProperty: true
ObjCSpaceBeforeProtocolList: true
PointerAlignment: Right
SpaceBeforeParens: ControlStatements

5
.eslintignore Normal file
View File

@ -0,0 +1,5 @@
lib
build
src/__mocks__
docs/build
docs/.docusaurus

View File

@ -1,89 +0,0 @@
name: Bug Report
description: Report a reproducible bug or regression in this library.
labels: [bug]
body:
- type: markdown
attributes:
value: |
# Bug Report
Thanks for taking the time to report a bug!
Please fill out the following carefully before opening a new issue.
**Issues missing required information may be closed without investigation.**
- type: checkboxes
attributes:
label: Before submitting a new issue
options:
- label: I tested using the **latest version** of the library.
required: true
- label: I tested using a [supported version](https://github.com/reactwg/react-native-releases/blob/main/docs/support.md) of React Native.
required: true
- label: I checked for [existing issues](https://github.com/lodev09/react-native-true-sheet/issues) that might answer my question.
required: true
- type: textarea
id: summary
attributes:
label: Bug Summary
description: Provide a clear and concise description of what the bug is.
placeholder: When I do X, I expect Y to happen, but Z happens instead.
validations:
required: true
- type: checkboxes
id: platforms
attributes:
label: Affected Platforms
options:
- label: iOS
- label: Android
- label: Web
- label: Other
- type: input
id: library-version
attributes:
label: Library Version
description: What version of @lodev09/react-native-true-sheet are you using?
placeholder: "x.x.x"
validations:
required: true
- type: textarea
id: react-native-info
attributes:
label: Environment Info
description: Run `npx react-native info` in your terminal and paste the results here.
render: shell
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Provide a clear list of steps to reproduce the problem.
placeholder: |
1. Open the app
2. Navigate to...
3. Tap on...
4. See error
validations:
required: true
- type: input
id: reproducible-example
attributes:
label: Repro
description: A link to a GitHub repository, Expo Snack, CodeSandbox, or StackBlitz.
placeholder: https://github.com/username/repo
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, screenshots, or screen recordings about the problem here.

View File

@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request 💡
url: https://github.com/lodev09/react-native-true-sheet/discussions/new?category=ideas
about: If you have a feature request, please create a new discussion on GitHub.
- name: Discussions on GitHub 💬
url: https://github.com/lodev09/react-native-true-sheet/discussions
about: If this library works as promised but you need help, please ask questions there.

View File

@ -1,25 +0,0 @@
## Summary
<!-- Describe what this PR does and why -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Test Plan
<!-- How did you test these changes? -->
## Screenshots / Videos
<!-- Add screenshots or videos if applicable -->
## Checklist
- [ ] I tested on iOS
- [ ] I tested on Android
- [ ] I tested on Web
- [ ] I updated the documentation (if needed)

View File

@ -5,13 +5,17 @@ runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Restore dependencies
- name: Enable Corepack
run: corepack enable
shell: bash
- name: Cache dependencies
id: yarn-cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@v4
with:
path: |
**/node_modules
@ -25,12 +29,3 @@ runs:
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
shell: bash
- name: Cache dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
**/node_modules
.yarn/install-state.gz
key: ${{ steps.yarn-cache.outputs.cache-primary-key }}

View File

@ -1,135 +0,0 @@
name: Build
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-android:
runs-on: ubuntu-latest
env:
TURBO_CACHE_DIR: .turbo/android
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup
uses: ./.github/actions/setup
- name: Cache turborepo for Android
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.TURBO_CACHE_DIR }}
key: ${{ runner.os }}-turborepo-android-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-turborepo-android-
- name: Check turborepo cache for Android
run: |
TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:android').cache.status")
if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then
echo "turbo_cache_hit=1" >> $GITHUB_ENV
fi
- name: Install JDK
if: env.turbo_cache_hit != 1
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: Finalize Android SDK
if: env.turbo_cache_hit != 1
run: |
/bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null"
- name: Cache Gradle
if: env.turbo_cache_hit != 1
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.gradle/wrapper
~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('example/bare/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build example for Android
env:
JAVA_OPTS: "-XX:MaxHeapSize=6g"
run: |
yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}"
build-ios:
runs-on: macos-latest
env:
XCODE_VERSION: latest-stable
TURBO_CACHE_DIR: .turbo/ios
RCT_USE_RN_DEP: 1
RCT_USE_PREBUILT_RNCORE: 1
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup
uses: ./.github/actions/setup
- name: Cache turborepo for iOS
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.TURBO_CACHE_DIR }}
key: ${{ runner.os }}-turborepo-ios-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-turborepo-ios-
- name: Check turborepo cache for iOS
run: |
TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:ios').cache.status")
if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then
echo "turbo_cache_hit=1" >> $GITHUB_ENV
fi
- name: Use appropriate Xcode version
if: env.turbo_cache_hit != 1
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: ${{ env.XCODE_VERSION }}
- name: Cache iOS build
if: env.turbo_cache_hit != 1
id: ios-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/Library/Caches/ccache
~/Library/Developer/Xcode/DerivedData
example/bare/ios/Pods
key: ${{ runner.os }}-ios-${{ hashFiles('example/bare/ios/Podfile.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-ios-${{ hashFiles('example/bare/ios/Podfile.lock') }}-
${{ runner.os }}-ios-
- name: Install ccache
if: env.turbo_cache_hit != 1
run: brew install ccache
- name: Install cocoapods
if: env.turbo_cache_hit != 1 && steps.ios-cache.outputs.cache-hit != 'true'
run: |
cd example/bare
bundle install
bundle exec pod install --project-directory=ios
- name: Build example for iOS
run: |
yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}"

View File

@ -1,133 +0,0 @@
name: Check for Repro
on:
issues:
types: [opened, edited, labeled]
issue_comment:
types: [created, edited]
jobs:
check-repro:
runs-on: ubuntu-latest
if: >
github.event.issue.pull_request == null && (
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'needs repro') ||
(github.event_name == 'issues' && github.event.action != 'labeled') ||
github.event_name == 'issue_comment'
)
permissions:
issues: write
steps:
- name: Check for reproduction link
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const comment = context.payload.comment;
const author = issue.user.login;
const user = comment ? comment.user.login : author;
const isComment = !!comment;
const body = comment ? comment.body : issue.body || '';
// Only accept repos owned by the issue author or commenter
const reproPatterns = [
new RegExp(`https?://github\\.com/${user}/[^/\\s]+/?`, 'i'),
new RegExp(`https?://snack\\.expo\\.(dev|io)/[^\\s]+`, 'i'),
new RegExp(`https?://codesandbox\\.io/[^\\s]+`, 'i'),
new RegExp(`https?://stackblitz\\.com/[^\\s]+`, 'i'),
];
let hasRepro = false;
if (isComment) {
// For comments, check the entire comment body
hasRepro = reproPatterns.some(pattern => pattern.test(body));
} else {
// For issues, extract and check the Repro field specifically
const reproFieldMatch = body.match(/### Repro\s+([\s\S]*?)(?=###|$)/i);
const reproField = reproFieldMatch ? reproFieldMatch[1].trim() : '';
const invalidRepro = /^(n\/?a|none|no|nothing|-|\.+)?$/i.test(reproField);
hasRepro = !invalidRepro && reproPatterns.some(pattern => pattern.test(reproField));
}
const labels = issue.labels.map(l => l.name);
const hasNeedsReproLabel = labels.includes('needs repro');
const hasReproProvidedLabel = labels.includes('repro provided');
if (hasRepro) {
// Valid repro found - add repro-provided label, remove needs-repro
if (!hasReproProvidedLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['repro provided']
});
}
if (hasNeedsReproLabel) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'needs repro'
});
} catch (e) {
if (!e.message.includes('Label does not exist')) throw e;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `Thank you for providing a repro! We'll take a look at this issue soon.`
});
}
} else {
// Only post warning comment on issue events, not comments
if (context.eventName !== 'issues') {
return;
}
const warningBody = `Hey @${author}! Thanks for opening the issue.
It looks like your issue is **missing a valid reproduction link**.
A minimal reproduction helps us investigate and fix the issue faster. Without one, we may not be able to help.
**Please provide one of the following:**
- A GitHub repository **under your username**
- An [Expo Snack](https://snack.expo.dev)
See ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example) for more guidance.
You can edit your original issue or leave a comment with the repro link. **Issues without reproductions may be closed after 14 days.**`;
// Check if we already commented
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
});
if (comments.data.some(c => c.body.includes('missing a valid reproduction link'))) {
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: warningBody
});
if (!hasNeedsReproLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['needs repro']
});
}
}

View File

@ -1,4 +1,4 @@
name: Checks
name: CI
on:
push:
branches:
@ -6,21 +6,13 @@ on:
pull_request:
branches:
- main
merge_group:
types:
- checks_requested
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
@ -33,10 +25,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
@ -44,12 +35,23 @@ jobs:
- name: Run unit tests
run: yarn test --maxWorkers=2 --coverage
build-library:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- name: Run expo doctor
run: yarn example doctor
build-library:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup

View File

@ -1,41 +0,0 @@
name: Close Stale Issues
on:
schedule:
- cron: '0 0 * * *' # Runs daily at midnight UTC
workflow_dispatch: # Allows manual triggering
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions!'
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions!'
close-issue-message: 'This issue has been automatically closed due to inactivity. Feel free to reopen if this is still relevant.'
close-pr-message: 'This pull request has been automatically closed due to inactivity. Feel free to reopen if this is still relevant.'
days-before-stale: 30
days-before-close: 7
stale-issue-label: 'stale'
stale-pr-label: 'stale'
exempt-issue-labels: 'pinned,security,enhancement'
exempt-pr-labels: 'pinned,security'
stale-needs-repro:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
only-labels: 'needs repro'
stale-issue-message: 'This issue is missing a reproduction and has been marked as stale. It will be closed if no reproduction is provided.'
close-issue-message: 'This issue has been closed due to missing reproduction. Feel free to reopen with a minimal repro.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: 'stale'
exempt-issue-labels: 'pinned,security'

22
.gitignore vendored
View File

@ -5,7 +5,8 @@
# XDE
.expo/
# VSCode
# Editors
*.sublime-*
.vscode/
jsconfig.json
@ -28,7 +29,6 @@ DerivedData
*.ipa
*.xcuserstate
project.xcworkspace
**/.xcode.env.local
# Android/IJ
#
@ -43,10 +43,10 @@ android.iml
# Cocoapods
#
example/bare/ios/Pods
example/ios/Pods
# Ruby
example/bare/vendor/
example/vendor/
# node.js
#
@ -72,18 +72,10 @@ android/keystores/debug.keystore
# Expo
.expo/
# Turborepo
.turbo/
# generated by bob
lib/
# React Native Codegen
ios/generated
android/generated
# Docs
# Example
example/ios
example/android
.vercel
# Test coverage
coverage

2
.nvmrc
View File

@ -1 +1 @@
v22.20.0
v18

View File

@ -1,4 +1 @@
lib
build
docs/build
docs/.docusaurus
.eslintignore

View File

@ -1,75 +0,0 @@
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index 51f021831aed26a4eed3c85014020423b7b3108b..268fa69dfee2b20d8b5a66c77c1b4cbd8c831573 100644
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -640,8 +640,10 @@ RNS_IGNORE_SUPER_CALL_END
// This check is for external modals that are not owned by this stack. They can prevent the dismissal of the modal by
// extending RNSDismissibleModalProtocol and returning NO from isDismissible method.
- if (![firstModalToBeDismissed conformsToProtocol:@protocol(RNSDismissibleModalProtocol)] ||
- [(id<RNSDismissibleModalProtocol>)firstModalToBeDismissed isDismissible]) {
+ BOOL shouldDismissFirstModal = ![firstModalToBeDismissed conformsToProtocol:@protocol(RNSDismissibleModalProtocol)] ||
+ [(id<RNSDismissibleModalProtocol>)firstModalToBeDismissed isDismissible];
+
+ if (shouldDismissFirstModal) {
if (firstModalToBeDismissed != nil) {
const BOOL firstModalToBeDismissedIsOwned = [firstModalToBeDismissed isKindOfClass:RNSScreen.class];
const BOOL firstModalToBeDismissedIsOwnedByThisStack =
@@ -699,6 +701,33 @@ RNS_IGNORE_SUPER_CALL_END
return;
}
}
+ } else {
+ // Modal is non-dismissible (e.g., third-party modal like TrueSheet)
+ // Check if the external modal provides a presenting controller
+ if (firstModalToBeDismissed != nil) {
+ id<RNSDismissibleModalProtocol> dismissibleModal = (id<RNSDismissibleModalProtocol>)firstModalToBeDismissed;
+ UIViewController *presentingController = nil;
+
+ // Check if the external modal implements the optional method
+ if ([dismissibleModal respondsToSelector:@selector(newPresentingViewController)]) {
+ presentingController = [dismissibleModal newPresentingViewController];
+ }
+
+ // Only handle the non-dismissible modal if it provides a presenting controller
+ if (presentingController != nil) {
+ changeRootController = presentingController;
+
+ // Check if the presenting controller has presented modals that need to be dismissed
+ UIViewController *modalPresentedByController = presentingController.presentedViewController;
+ if (modalPresentedByController != nil && ![modalPresentedByController isBeingDismissed] &&
+ [_presentedModals containsObject:modalPresentedByController]) {
+ // The presenting controller has presented one of our modals
+ // We need to dismiss it before presenting new ones
+ [presentingController dismissViewControllerAnimated:YES completion:finish];
+ return;
+ }
+ }
+ }
}
// We didn't detect any controllers for dismissal, thus we start presenting new VCs
diff --git a/ios/integrations/RNSDismissibleModalProtocol.h b/ios/integrations/RNSDismissibleModalProtocol.h
index 006f809d104c1d4fbdf6eccca89d6c6e190cca71..89e297f1b7a9582fee3e19237dfba8d4c87a352f 100644
--- a/ios/integrations/RNSDismissibleModalProtocol.h
+++ b/ios/integrations/RNSDismissibleModalProtocol.h
@@ -1,3 +1,5 @@
+#import <UIKit/UIKit.h>
+
NS_ASSUME_NONNULL_BEGIN
@protocol RNSDismissibleModalProtocol <NSObject>
@@ -6,6 +8,13 @@ NS_ASSUME_NONNULL_BEGIN
// Use it on your own responsibility, as it can lead to unexpected behavior.
- (BOOL)isDismissible;
+@optional
+// If the modal is non-dismissible, it can optionally provide a view controller
+// that should be used as the presenting controller for subsequent modals.
+// This gives the external modal implementation control over the presentation chain.
+// If not implemented or returns nil, the non-dismissible modal itself will be used.
+- (nullable UIViewController *)newPresentingViewController;
+
@end
NS_ASSUME_NONNULL_END

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,6 @@
nodeLinker: node-modules
nmHoistingLimits: workspaces
yarnPath: .yarn/releases/yarn-4.11.0.cjs
nodeLinker: node-modules
plugins:
spec: "@yarnpkg/plugin-workspace-tools"

View File

@ -1,7 +0,0 @@
{
"file_scan_exclusions": [
"**/.git",
"**/node_modules",
"**/.DS_Store"
]
}

150
AGENTS.md
View File

@ -1,150 +0,0 @@
# Agent Instructions
## Rules
1. YOU MUST NOT do builds unless you are told to.
2. YOU MUST NOT commit changes yourself until I explicitly tell you to.
3. YOU MUST NOT create summary documents unless you are told to.
4. YOU MUST NOT add code comments that are obvious.
## Project Overview
React Native Fabric (New Architecture) bottom sheet library for iOS and Android.
- **Fabric** - No bridge, direct C++ communication
- **Codegen** - Auto-generates native interfaces from TypeScript specs
- **C++ Shared Code** - State and shadow nodes shared between platforms
## Project Structure
```
src/
├── fabric/ # Native component specs (codegen input)
│ ├── TrueSheetViewNativeComponent.ts
│ ├── TrueSheetContainerViewNativeComponent.ts
│ ├── TrueSheetContentViewNativeComponent.ts
│ ├── TrueSheetHeaderViewNativeComponent.ts
│ └── TrueSheetFooterViewNativeComponent.ts
├── specs/ # TurboModule spec
│ └── NativeTrueSheetModule.ts
├── reanimated/ # Reanimated integration
│ ├── ReanimatedTrueSheet.tsx
│ ├── ReanimatedTrueSheet.web.tsx
│ ├── ReanimatedTrueSheetProvider.tsx
│ ├── useReanimatedPositionChangeHandler.ts
│ ├── useReanimatedPositionChangeHandler.web.ts
│ └── index.ts
├── navigation/ # React Navigation integration
│ ├── createTrueSheetNavigator.tsx
│ ├── TrueSheetRouter.ts
│ ├── TrueSheetView.tsx
│ ├── useTrueSheetNavigation.ts
│ ├── types.ts
│ ├── index.ts
│ └── screen/ # Screen components for navigator
│ ├── TrueSheetScreen.tsx
│ ├── ReanimatedTrueSheetScreen.tsx
│ ├── useSheetScreenState.ts
│ ├── types.ts
│ └── index.ts
├── mocks/ # Testing mocks
│ ├── navigation.ts
│ ├── reanimated.ts
│ └── index.ts
├── __tests__/ # Unit tests
│ ├── TrueSheet.test.tsx
│ └── TrueSheetMocks.test.tsx
├── TrueSheet.tsx # Main React component
├── TrueSheet.web.tsx # Web implementation
├── TrueSheetProvider.tsx
├── TrueSheetProvider.web.tsx
├── TrueSheet.types.ts
└── index.ts
ios/
├── TrueSheetView.mm/.h # Host view (Fabric component)
├── TrueSheetViewController.mm/.h # UIViewController for sheet presentation
├── TrueSheetModule.mm/.h # TurboModule
├── TrueSheetContainerView.mm/.h # Container view
├── TrueSheetContentView.mm/.h # Content view
├── TrueSheetHeaderView.mm/.h # Header view
├── TrueSheetFooterView.mm/.h # Footer view
├── TrueSheetComponentDescriptor.h
├── core/
│ ├── TrueSheetGrabberView.mm/.h
│ ├── TrueSheetBlurView.mm/.h
│ └── TrueSheetDetentCalculator.mm/.h
├── events/
│ ├── TrueSheetLifecycleEvents.mm/.h
│ ├── TrueSheetStateEvents.mm/.h
│ ├── TrueSheetDragEvents.mm/.h
│ └── TrueSheetFocusEvents.mm/.h
└── utils/
├── LayoutUtil.mm/.h
├── GestureUtil.mm/.h
└── WindowUtil.mm/.h
android/src/main/java/com/lodev09/truesheet/
├── TrueSheetView.kt # Host view
├── TrueSheetViewController.kt # BottomSheet controller
├── TrueSheetModule.kt # TurboModule
├── TrueSheetContainerView.kt # Container view
├── TrueSheetContentView.kt # Content view
├── TrueSheetHeaderView.kt # Header view
├── TrueSheetFooterView.kt # Footer view
├── TrueSheetViewManager.kt # View manager for TrueSheetView
├── TrueSheetContainerViewManager.kt
├── TrueSheetContentViewManager.kt
├── TrueSheetHeaderViewManager.kt
├── TrueSheetFooterViewManager.kt
├── TrueSheetPackage.kt
├── core/
│ ├── TrueSheetBottomSheetView.kt
│ ├── TrueSheetCoordinatorLayout.kt
│ ├── TrueSheetDetentCalculator.kt
│ ├── TrueSheetStackManager.kt
│ ├── TrueSheetDimView.kt
│ ├── TrueSheetGrabberView.kt
│ ├── TrueSheetKeyboardObserver.kt
│ └── RNScreensFragmentObserver.kt
├── events/
│ ├── TrueSheetDragEvents.kt
│ ├── TrueSheetFocusEvents.kt
│ ├── TrueSheetLifecycleEvents.kt
│ └── TrueSheetStateEvents.kt
└── utils/
└── ScreenUtils.kt
common/cpp/react/renderer/components/TrueSheetSpec/
├── TrueSheetViewState.cpp/.h
├── TrueSheetViewShadowNode.cpp/.h
└── TrueSheetViewComponentDescriptor.h
```
## View Hierarchy
```
TrueSheetView (host view - hidden, manages state)
└── TrueSheetContainerView (fills controller's view)
├── TrueSheetHeaderView (optional)
├── TrueSheetContentView
└── TrueSheetFooterView (optional)
```
## Common Tasks
### Adding a new prop
1. Add to `src/fabric/TrueSheetViewNativeComponent.ts`
2. Build the app (runs codegen)
3. Implement in `TrueSheetView.mm` (iOS) and `TrueSheetViewManager.kt` (Android)
### Adding a new event
1. Add `DirectEventHandler` to native component spec
2. Create event class in `ios/events/` and `android/.../events/`
3. Emit from native view
## Commands
See `package.json` scripts.

View File

@ -9,83 +9,41 @@ We want this community to be friendly and respectful to each other. Please follo
This project is a monorepo managed using [Yarn workspaces](https://yarnpkg.com/features/workspaces). It contains the following packages:
- The library package in the root directory.
- A bare React Native example app in `example/bare/`.
- An Expo example app in `example/expo/`.
- Shared example code in `example/shared/`.
- An example app in the `example/` directory.
To get started with the project, make sure you have the correct version of [Node.js](https://nodejs.org/) installed. See the [`.nvmrc`](./.nvmrc) file for the version used in this project.
Run `yarn` in the root directory to install the required dependencies for each package:
To get started with the project, run `yarn` in the root directory to install the required dependencies for each package:
```sh
yarn
```
> Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development without manually migrating.
> Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development.
This will check that all required tools and dependencies are installed and configured correctly. If any issues are found, follow the recommended fixes or refer to the [React Native environment setup guide](https://reactnative.dev/docs/environment-setup).
The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make.
The example apps demonstrate usage of the library. You need to run them to test any changes you make.
It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app.
They are configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example apps. Changes to the library's JavaScript code will be reflected without a rebuild, but native code changes will require a rebuild.
If you want to use Android Studio or XCode to edit the native code, you can open the `example/android` or `example/ios` directories respectively in those editors. To edit the Objective-C or Swift files, open `example/ios/TrueSheetExample.xcworkspace` in XCode and find the source files at `Pods > Development Pods > TrueSheet`.
### Bare React Native Example
Before running the bare example, verify that your development environment is properly configured by running:
```sh
yarn bare doctor
```
If you want to use Android Studio or Xcode to edit the native code, you can open `example/bare/android` or `example/bare/ios` respectively. To edit Objective-C files, open `example/bare/ios/TrueSheetExample.xcworkspace` in Xcode and find the source files at `Pods > Development Pods > react-native-true-sheet`.
To edit Kotlin files, open `example/bare/android` in Android Studio and find the source files at `react-native-true-sheet` under `Android`.
### Expo Example
The Expo example requires prebuilding before running on a device:
```sh
yarn expo prebuild
```
To edit the Java or Kotlin files, open `example/android` in Android studio and find the source files at `react-native-true-sheet` under `Android`.
You can use various commands from the root directory to work with the project.
To start the packager for the bare example:
To start the packager:
To run the example app on Android:
```sh
yarn bare start
yarn example android
```
To run the bare example on Android:
To run the example app on iOS:
```sh
yarn bare android
yarn example ios
```
To run the bare example on iOS:
```sh
yarn bare ios
```
Similarly, for the Expo example:
```sh
yarn expo start
yarn expo android
yarn expo ios
```
To confirm that the app is running with the new architecture, you can check the Metro logs for a message like this:
```sh
Running "TrueSheetExample" with {"fabric":true,"initialProps":{"concurrentRoot":true},"rootTag":1}
```
Note the `"fabric":true` and `"concurrentRoot":true` properties.
Make sure your code passes TypeScript and ESLint. Run the following to verify and fix:
Make sure your code passes TypeScript and ESLint. Run the following to verify:
```sh
yarn tidy
@ -104,12 +62,19 @@ We follow the [conventional commits specification](https://www.conventionalcommi
- `fix`: bug fixes, e.g. fix crash due to deprecated method.
- `feat`: new features, e.g. add new method to the module.
- `refactor`: code refactor, e.g. migrate from class components to hooks.
- `docs`: changes into documentation, e.g. add usage example for the module.
- `docs`: changes into documentation, e.g. add usage example for the module..
- `test`: adding or updating tests, e.g. add integration tests using detox.
- `chore`: tooling changes, e.g. change CI config.
Our pre-commit hooks verify that your commit message matches this format when committing.
### Linting and tests
[ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/)
We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing.
Our pre-commit hooks verify that the linter and tests pass when committing.
### Publishing to npm
@ -121,21 +86,19 @@ To publish new versions, run the following:
yarn release
```
### Scripts
The `package.json` file contains various scripts for common tasks:
- `yarn`: setup project by installing dependencies.
- `yarn typecheck`: type-check files with TypeScript.
- `yarn lint`: lint files with [ESLint](https://eslint.org/).
- `yarn test`: run unit tests with [Jest](https://jestjs.io/).
- `yarn bare start`: start the Metro server for the bare example.
- `yarn bare android`: run the bare example on Android.
- `yarn bare ios`: run the bare example on iOS.
- `yarn expo start`: start the Metro server for the Expo example.
- `yarn expo android`: run the Expo example on Android.
- `yarn expo ios`: run the Expo example on iOS.
- `yarn lint`: lint files with ESLint.
- `yarn format`: format files with Prettier.
- `yarn test`: run unit tests with Jest.
- `yarn tidy`: run `typecheck`, `lint`, and `format`.
- `yarn example start`: start the Metro server for the example app.
- `yarn example android`: run the example app on Android.
- `yarn example ios`: run the example app on iOS.
### Sending a pull request

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 lodev09
Copyright (c) 2024 lodev09
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@ -1,59 +1,43 @@
# React Native True Sheet
[![CI](https://github.com/lodev09/react-native-true-sheet/actions/workflows/checks.yml/badge.svg)](https://github.com/lodev09/react-native-true-sheet/actions/workflows/checks.yml)
[![CI](https://github.com/lodev09/react-native-true-sheet/actions/workflows/ci.yml/badge.svg)](https://github.com/lodev09/react-native-true-sheet/actions/workflows/ci.yml)
[![Maintainability](https://api.codeclimate.com/v1/badges/0bd49973c6c61d85e2be/maintainability)](https://codeclimate.com/github/lodev09/react-native-true-sheet/maintainability)
[![NPM Downloads](https://img.shields.io/npm/d18m/%40lodev09%2Freact-native-true-sheet)](https://www.npmjs.com/package/@lodev09/react-native-true-sheet)
> [!NOTE]
> 🎉 **Version 3.0 is here!** Completely rebuilt for Fabric with new features like automatic ScrollView detection, native headers/footers, sheet stacking, and more. [Read the announcement](https://sheet.lodev09.com/blog/release-3-0)
The true native bottom sheet experience for your React Native Apps. 💩
<img alt="React Native True Sheet - IOS" src="docs/static/img/preview-ios.gif" width="248" height="500" /><img alt="React Native True Sheet - Android" src="docs/static/img/preview-android.gif" width="248" height="500" /><img alt="React Native True Sheet - Web" src="docs/static/img/preview-web.gif" width="248" height="500" />
<img alt="React Native True Sheet - IOS" src="docs/static/img/preview.gif" width="300" height="600" /><img alt="React Native True Sheet - Android" src="docs/static/img/preview-2.gif" width="300" height="600" />
## Features
* ⚡ **Powered by Fabric** - Built on React Native's new architecture for maximum performance
* 🚀 **Fully Native** - Implemented in the native realm, zero JS hacks
* ♿ **Accessible** - Native accessibility and screen reader support out of the box
* 🔄 **Flexible API** - Use [imperative methods](https://sheet.lodev09.com/reference/methods#ref-methods) or [lifecycle events](https://sheet.lodev09.com/reference/events)
* 🪟 **Liquid Glass** - [iOS 26+ Liquid Glass](https://sheet.lodev09.com/guides/liquid-glass) support out of the box, featured in [Expo Blog](https://expo.dev/blog/how-to-create-apple-maps-style-liquid-glass-sheets)
* 🐎 **Reanimated** - First-class support for [react-native-reanimated](https://sheet.lodev09.com/guides/reanimated)
* 🧭 **React Navigation** - Built-in [sheet navigator](https://sheet.lodev09.com/guides/navigation) for seamless navigation integration
* 🌐 **Web Support** - Full [web support](https://sheet.lodev09.com/guides/web) out of the box
* Implemented in the native realm.
* Clean, fast, and lightweight.
* Asynchronus `ref` [methods](https://sheet.lodev09.com/reference/methods#ref-methods).
* Bonus! [Blur](https://sheet.lodev09.com/reference/types#blurtint) support on IOS 😎
## Installation
> [!IMPORTANT]
> **Version 3.0+ requires React Native's New Architecture (Fabric)**
> For the old architecture, use version 2.x. See the [Migration Guide](https://sheet.lodev09.com/migration) for upgrading.
### Prerequisites
- React Native >= 0.76 (Expo SDK 52+)
- New Architecture enabled (default in RN 0.76+)
### Expo
```sh
npx expo install @lodev09/react-native-true-sheet
```
### Bare React Native
You can install the package by using either `yarn` or `npm`.
```sh
yarn add @lodev09/react-native-true-sheet
```
```sh
npm i @lodev09/react-native-true-sheet
```
Next, run the following to install it on IOS.
```sh
cd ios && pod install
```
## Documentation
- [Example](example)
- [Configuration](https://sheet.lodev09.com/reference/configuration)
- [Lifecycle Events](https://sheet.lodev09.com/reference/events)
- [React Navigation](https://sheet.lodev09.com/guides/navigation)
- [Guides](https://sheet.lodev09.com/category/guides)
- [Reference](https://sheet.lodev09.com/category/reference)
- [Troubleshooting](https://sheet.lodev09.com/troubleshooting)
- [Testing with Jest](https://sheet.lodev09.com/guides/jest)
- [Migrating to v3](https://sheet.lodev09.com/migration)
## Usage
@ -80,7 +64,8 @@ export const App = () => {
<Button onPress={present} title="Present" />
<TrueSheet
ref={sheet}
detents={['auto', 1]}
sizes={['auto', 'large']}
cornerRadius={24}
>
<Button onPress={dismiss} title="Dismiss" />
</TrueSheet>
@ -89,31 +74,6 @@ export const App = () => {
}
```
## Testing
TrueSheet exports mocks for easy testing:
```tsx
// Main component
jest.mock('@lodev09/react-native-true-sheet', () =>
require('@lodev09/react-native-true-sheet/mock')
);
// Navigation (if using)
jest.mock('@lodev09/react-native-true-sheet/navigation', () =>
require('@lodev09/react-native-true-sheet/navigation/mock')
);
// Reanimated (if using)
jest.mock('@lodev09/react-native-true-sheet/reanimated', () =>
require('@lodev09/react-native-true-sheet/reanimated/mock')
);
```
All methods (`present`, `dismiss`, `resize`) are mocked as Jest functions, allowing you to test your components without native dependencies.
**[Full Testing Guide](https://sheet.lodev09.com/guides/jest)**
## Contributing
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.

View File

@ -1,24 +0,0 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
Pod::Spec.new do |s|
s.name = "RNTrueSheet"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = package["author"]
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/lodev09/react-native-true-sheet.git", :tag => "#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}", "common/cpp/**/*.{cpp,h}"
s.private_header_files = "ios/**/*.h"
s.pod_target_xcconfig = {
"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/common/cpp\""
}
install_modules_dependencies(s)
end

41
TrueSheet.podspec Normal file
View File

@ -0,0 +1,41 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
Pod::Spec.new do |s|
s.name = "TrueSheet"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = package["author"]
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/lodev09/react-native-true-sheet.git", :tag => "#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,swift}"
# Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
# See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
if respond_to?(:install_modules_dependencies, true)
install_modules_dependencies(s)
else
s.dependency "React-Core"
# Don't install the dependencies when we run `pod install` in the old architecture.
if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
s.pod_target_xcconfig = {
"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
"OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
"CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
}
s.dependency "React-RCTFabric"
s.dependency "React-Codegen"
s.dependency "RCT-Folly"
s.dependency "RCTRequired"
s.dependency "RCTTypeSafety"
end
end
end

View File

@ -1,7 +1,6 @@
buildscript {
ext.getExtOrDefault = {name ->
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['TrueSheet_' + name]
}
// Buildscript is evaluated before everything else so we can't use getExtOrDefault
def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["TrueSheet_kotlinVersion"]
repositories {
google()
@ -9,26 +8,31 @@ buildscript {
}
dependencies {
classpath "com.android.tools.build:gradle:8.7.2"
classpath "com.android.tools.build:gradle:7.2.1"
// noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
def isNewArchitectureEnabled() {
return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
apply plugin: "com.facebook.react"
if (isNewArchitectureEnabled()) {
apply plugin: "com.facebook.react"
}
def getExtOrDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["TrueSheet_" + name]
}
def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["TrueSheet_" + name]).toInteger()
}
def getEdgeToEdgeEnabled() {
def edgeToEdge = project.findProperty("edgeToEdgeEnabled")
return edgeToEdge != null ? edgeToEdge.toBoolean() : false
}
def supportsNamespace() {
def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')
def major = parsed[0].toInteger()
@ -54,7 +58,7 @@ android {
defaultConfig {
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "EDGE_TO_EDGE_ENABLED", "${getEdgeToEdgeEnabled()}"
}
buildTypes {
@ -68,25 +72,8 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
}
sourceSets {
main {
java.srcDirs += [
"generated/java",
"generated/jni"
]
}
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
@ -98,8 +85,11 @@ repositories {
def kotlin_version = getExtOrDefault("kotlinVersion")
dependencies {
implementation "com.facebook.react:react-android"
// For < 0.71, this will be from the local maven repo
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "com.google.android.material:material:1.12.0"
implementation "com.google.android.material:material:1.11.0"
}

View File

@ -1,5 +1,5 @@
TrueSheet_kotlinVersion=2.0.21
TrueSheet_minSdkVersion=24
TrueSheet_targetSdkVersion=34
TrueSheet_compileSdkVersion=35
TrueSheet_ndkVersion=27.1.12297006
TrueSheet_kotlinVersion=1.7.0
TrueSheet_minSdkVersion=21
TrueSheet_targetSdkVersion=31
TrueSheet_compileSdkVersion=31
TrueSheet_ndkversion=21.4.7075529

View File

@ -1,106 +0,0 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import android.view.View
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
interface TrueSheetContainerViewDelegate {
fun containerViewContentDidChangeSize(width: Int, height: Int)
fun containerViewHeaderDidChangeSize(width: Int, height: Int)
fun containerViewFooterDidChangeSize(width: Int, height: Int)
}
/**
* Container view that manages the sheet's content, header, and footer views.
* Size changes are forwarded to the delegate for sheet reconfiguration.
*/
@SuppressLint("ViewConstructor")
class TrueSheetContainerView(reactContext: ThemedReactContext) :
ReactViewGroup(reactContext),
TrueSheetContentViewDelegate,
TrueSheetHeaderViewDelegate,
TrueSheetFooterViewDelegate {
var delegate: TrueSheetContainerViewDelegate? = null
var contentView: TrueSheetContentView? = null
var headerView: TrueSheetHeaderView? = null
var footerView: TrueSheetFooterView? = null
var contentHeight: Int = 0
var headerHeight: Int = 0
var footerHeight: Int = 0
init {
// Allow footer to position outside container bounds
clipChildren = false
clipToPadding = false
}
override fun addView(child: View?, index: Int) {
super.addView(child, index)
when (child) {
is TrueSheetContentView -> {
child.delegate = this
contentView = child
}
is TrueSheetHeaderView -> {
child.delegate = this
headerView = child
}
is TrueSheetFooterView -> {
child.delegate = this
footerView = child
}
}
}
override fun removeViewAt(index: Int) {
when (val view = getChildAt(index)) {
is TrueSheetContentView -> {
view.delegate = null
contentView = null
contentViewDidChangeSize(0, 0)
}
is TrueSheetHeaderView -> {
view.delegate = null
headerView = null
headerViewDidChangeSize(0, 0)
}
is TrueSheetFooterView -> {
view.delegate = null
footerView = null
footerViewDidChangeSize(0, 0)
}
}
super.removeViewAt(index)
}
// ==================== Delegate Implementations ====================
override fun contentViewDidChangeSize(width: Int, height: Int) {
contentHeight = height
delegate?.containerViewContentDidChangeSize(width, height)
}
override fun headerViewDidChangeSize(width: Int, height: Int) {
headerHeight = height
delegate?.containerViewHeaderDidChangeSize(width, height)
}
override fun footerViewDidChangeSize(width: Int, height: Int) {
footerHeight = height
delegate?.containerViewFooterDidChangeSize(width, height)
}
companion object {
const val TAG_NAME = "TrueSheet"
}
}

View File

@ -1,21 +0,0 @@
package com.lodev09.truesheet
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
/**
* ViewManager for TrueSheetContainerView
* Container that holds content and footer views
*/
@ReactModule(name = TrueSheetContainerViewManager.REACT_CLASS)
class TrueSheetContainerViewManager : ViewGroupManager<TrueSheetContainerView>() {
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContainerView = TrueSheetContainerView(reactContext)
companion object {
const val REACT_CLASS = "TrueSheetContainerView"
}
}

View File

@ -1,38 +0,0 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
/**
* Delegate interface for content view size changes
*/
interface TrueSheetContentViewDelegate {
fun contentViewDidChangeSize(width: Int, height: Int)
}
/**
* Content view that holds the main sheet content
* This is the first child of TrueSheetContainerView
*/
@SuppressLint("ViewConstructor")
class TrueSheetContentView(context: ThemedReactContext) : ReactViewGroup(context) {
var delegate: TrueSheetContentViewDelegate? = null
private var lastWidth = 0
private var lastHeight = 0
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)
}
}
companion object {
const val TAG_NAME = "TrueSheet"
}
}

View File

@ -1,21 +0,0 @@
package com.lodev09.truesheet
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
/**
* ViewManager for TrueSheetContentView
* Manages the main content area of the sheet
*/
@ReactModule(name = TrueSheetContentViewManager.REACT_CLASS)
class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext)
companion object {
const val REACT_CLASS = "TrueSheetContentView"
}
}

View File

@ -0,0 +1,330 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lodev09.truesheet.core.KeyboardManager
import com.lodev09.truesheet.core.RootSheetView
import com.lodev09.truesheet.core.Utils
data class SizeInfo(val index: Int, val value: Float)
@SuppressLint("ClickableViewAccessibility")
class TrueSheetDialog(private val reactContext: ThemedReactContext, private val rootSheetView: RootSheetView) :
BottomSheetDialog(reactContext) {
private var keyboardManager = KeyboardManager(reactContext)
private var sheetView: ViewGroup
private var windowAnimation: Int = 0
/**
* Specify whether the sheet background is dimmed.
* Set to `false` to allow interaction with the background components.
*/
var dimmed = true
/**
* The size index that the sheet should start to dim the background.
* This is ignored if `dimmed` is set to `false`.
*/
var dimmedIndex = 0
/**
* The maximum window height
*/
var maxScreenHeight = 0
var contentHeight = 0
var footerHeight = 0
var maxSheetHeight: Int? = null
var dismissible: Boolean = true
set(value) {
field = value
setCanceledOnTouchOutside(value)
setCancelable(value)
behavior.isHideable = value
}
var footerView: ViewGroup? = null
var sizes: Array<Any> = arrayOf("medium", "large")
init {
setContentView(rootSheetView)
sheetView = rootSheetView.parent as ViewGroup
sheetView.setBackgroundColor(Color.TRANSPARENT)
// Setup window params to adjust layout based on Keyboard state
window?.apply {
// Store current windowAnimation value to toggle later
windowAnimation = attributes.windowAnimations
}
// Update the usable sheet height
maxScreenHeight = Utils.screenHeight(reactContext)
}
/**
* Setup dimmed sheet.
* `dimmedIndex` will further customize the dimming behavior.
*/
fun setupDimmedBackground(sizeIndex: Int) {
window?.apply {
val view = findViewById<View>(com.google.android.material.R.id.touch_outside)
if (dimmed && sizeIndex >= dimmedIndex) {
// Remove touch listener
view.setOnTouchListener(null)
// Add the dimmed background
setFlags(
WindowManager.LayoutParams.FLAG_DIM_BEHIND,
WindowManager.LayoutParams.FLAG_DIM_BEHIND
)
setCanceledOnTouchOutside(dismissible)
} else {
// Override the background touch and pass it to the components outside
view.setOnTouchListener { v, event ->
event.setLocation(event.rawX - v.x, event.rawY - v.y)
reactContext.currentActivity?.dispatchTouchEvent(event)
false
}
// Remove the dimmed background
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setCanceledOnTouchOutside(false)
}
}
}
fun resetAnimation() {
window?.apply {
setWindowAnimations(windowAnimation)
}
}
/**
* Present the sheet.
*/
fun present(sizeIndex: Int, animated: Boolean = true) {
setupDimmedBackground(sizeIndex)
if (isShowing) {
setStateForSizeIndex(sizeIndex)
} else {
configure()
setStateForSizeIndex(sizeIndex)
if (!animated) {
// Disable animation
window?.setWindowAnimations(0)
}
show()
}
}
fun positionFooter() {
footerView?.apply {
y = (maxScreenHeight - sheetView.top - footerHeight).toFloat()
}
}
/**
* Set the state based for the given size index.
*/
private fun setStateForSizeIndex(index: Int) {
behavior.state = getStateForSizeIndex(index)
}
/**
* Get the height value based on the size config value.
*/
private fun getSizeHeight(size: Any): Int {
val height =
when (size) {
is Double -> Utils.toPixel(size)
is Int -> Utils.toPixel(size.toDouble())
is String -> {
when (size) {
"auto" -> contentHeight + footerHeight
"large" -> maxScreenHeight
"medium" -> (maxScreenHeight * 0.50).toInt()
"small" -> (maxScreenHeight * 0.25).toInt()
else -> {
if (size.endsWith('%')) {
val percent = size.trim('%').toDoubleOrNull()
if (percent == null) {
0
} else {
((percent / 100) * maxScreenHeight).toInt()
}
} else {
val fixedHeight = size.toDoubleOrNull()
if (fixedHeight == null) {
0
} else {
Utils.toPixel(fixedHeight)
}
}
}
}
}
else -> (maxScreenHeight * 0.5).toInt()
}
return maxSheetHeight?.let { minOf(height, it, maxScreenHeight) } ?: minOf(height, maxScreenHeight)
}
/**
* Determines the state based from the given size index.
*/
private fun getStateForSizeIndex(index: Int) =
when (sizes.size) {
1 -> BottomSheetBehavior.STATE_EXPANDED
2 -> {
when (index) {
0 -> BottomSheetBehavior.STATE_COLLAPSED
1 -> BottomSheetBehavior.STATE_EXPANDED
else -> BottomSheetBehavior.STATE_HIDDEN
}
}
3 -> {
when (index) {
0 -> BottomSheetBehavior.STATE_COLLAPSED
1 -> BottomSheetBehavior.STATE_HALF_EXPANDED
2 -> BottomSheetBehavior.STATE_EXPANDED
else -> BottomSheetBehavior.STATE_HIDDEN
}
}
else -> BottomSheetBehavior.STATE_HIDDEN
}
/**
* Handle keyboard state changes and adjust maxScreenHeight (sheet max height) accordingly.
* Also update footer's Y position.
*/
fun registerKeyboardManager() {
keyboardManager.registerKeyboardListener(object : KeyboardManager.OnKeyboardChangeListener {
override fun onKeyboardStateChange(isVisible: Boolean, visibleHeight: Int?) {
maxScreenHeight = when (isVisible) {
true -> visibleHeight ?: 0
else -> Utils.screenHeight(reactContext)
}
positionFooter()
}
})
}
fun setOnSizeChangeListener(listener: RootSheetView.OnSizeChangeListener) {
rootSheetView.setOnSizeChangeListener(listener)
}
/**
* Remove keyboard listener.
*/
fun unregisterKeyboardManager() {
keyboardManager.unregisterKeyboardListener()
}
/**
* Configure the sheet based from the size preference.
*/
fun configure() {
// Configure sheet sizes
behavior.apply {
skipCollapsed = false
isFitToContents = true
// m3 max width 640dp
maxWidth = Utils.toPixel(640.0)
when (sizes.size) {
1 -> {
maxHeight = getSizeHeight(sizes[0])
skipCollapsed = true
}
2 -> {
setPeekHeight(getSizeHeight(sizes[0]), isShowing)
maxHeight = getSizeHeight(sizes[1])
}
3 -> {
// Enables half expanded
isFitToContents = false
setPeekHeight(getSizeHeight(sizes[0]), isShowing)
halfExpandedRatio = minOf(getSizeHeight(sizes[1]).toFloat() / maxScreenHeight.toFloat(), 1.0f)
maxHeight = getSizeHeight(sizes[2])
}
}
}
}
/**
* Get the SizeInfo data by state.
*/
fun getSizeInfoForState(state: Int): SizeInfo? =
when (sizes.size) {
1 -> {
when (state) {
BottomSheetBehavior.STATE_EXPANDED -> SizeInfo(0, Utils.toDIP(behavior.maxHeight))
else -> null
}
}
2 -> {
when (state) {
BottomSheetBehavior.STATE_COLLAPSED -> SizeInfo(0, Utils.toDIP(behavior.peekHeight))
BottomSheetBehavior.STATE_EXPANDED -> SizeInfo(1, Utils.toDIP(behavior.maxHeight))
else -> null
}
}
3 -> {
when (state) {
BottomSheetBehavior.STATE_COLLAPSED -> SizeInfo(0, Utils.toDIP(behavior.peekHeight))
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
val height = behavior.halfExpandedRatio * maxScreenHeight
SizeInfo(1, Utils.toDIP(height.toInt()))
}
BottomSheetBehavior.STATE_EXPANDED -> SizeInfo(2, Utils.toDIP(behavior.maxHeight))
else -> null
}
}
else -> null
}
/**
* Get SizeInfo data for given size index.
*/
fun getSizeInfoForIndex(index: Int) = getSizeInfoForState(getStateForSizeIndex(index)) ?: SizeInfo(0, 0f)
companion object {
const val TAG = "TrueSheetView"
}
}

View File

@ -1,103 +0,0 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import com.facebook.react.uimanager.JSPointerDispatcher
import com.facebook.react.uimanager.JSTouchDispatcher
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.view.ReactViewGroup
/**
* Delegate interface for footer view size changes
*/
interface TrueSheetFooterViewDelegate {
fun footerViewDidChangeSize(width: Int, height: Int)
}
/**
* Footer view that holds the footer content
* This is the second child of TrueSheetContainerView
* Positioned absolutely at the bottom of the sheet
*
* Implements RootView to handle touch events when positioned outside parent bounds.
*/
@SuppressLint("ViewConstructor")
class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
ReactViewGroup(reactContext),
RootView {
var delegate: TrueSheetFooterViewDelegate? = null
var eventDispatcher: EventDispatcher? = null
private var lastWidth = 0
private var lastHeight = 0
private val jsTouchDispatcher = JSTouchDispatcher(this)
private var jsPointerDispatcher: JSPointerDispatcher? = null
init {
jsPointerDispatcher = JSPointerDispatcher(this)
}
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?.footerViewDidChangeSize(w, h)
}
}
// ==================== RootView Implementation ====================
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { dispatcher ->
jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
jsPointerDispatcher?.handleMotionEvent(event, dispatcher, true)
}
return super.onInterceptTouchEvent(event)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { dispatcher ->
jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
jsPointerDispatcher?.handleMotionEvent(event, dispatcher, false)
}
super.onTouchEvent(event)
return true
}
override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { jsPointerDispatcher?.handleMotionEvent(event, it, true) }
return super.onHoverEvent(event)
}
override fun onHoverEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { jsPointerDispatcher?.handleMotionEvent(event, it, false) }
return super.onHoverEvent(event)
}
override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
eventDispatcher?.let { dispatcher ->
jsTouchDispatcher.onChildStartedNativeGesture(ev, dispatcher)
jsPointerDispatcher?.onChildStartedNativeGesture(childView, ev, dispatcher)
}
}
override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
eventDispatcher?.let { jsTouchDispatcher.onChildEndedNativeGesture(ev, it) }
jsPointerDispatcher?.onChildEndedNativeGesture()
}
override fun handleException(t: Throwable) {
reactContext.reactApplicationContext.handleException(RuntimeException(t))
}
companion object {
const val TAG_NAME = "TrueSheet"
}
}

View File

@ -1,21 +0,0 @@
package com.lodev09.truesheet
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
/**
* ViewManager for TrueSheetFooterView
* Manages the footer area of the sheet
*/
@ReactModule(name = TrueSheetFooterViewManager.REACT_CLASS)
class TrueSheetFooterViewManager : ViewGroupManager<TrueSheetFooterView>() {
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetFooterView = TrueSheetFooterView(reactContext)
companion object {
const val REACT_CLASS = "TrueSheetFooterView"
}
}

View File

@ -1,38 +0,0 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
/**
* Delegate interface for header view size changes
*/
interface TrueSheetHeaderViewDelegate {
fun headerViewDidChangeSize(width: Int, height: Int)
}
/**
* Header view that holds the header content
* Positioned at the top of the sheet content area
*/
@SuppressLint("ViewConstructor")
class TrueSheetHeaderView(context: ThemedReactContext) : ReactViewGroup(context) {
var delegate: TrueSheetHeaderViewDelegate? = null
private var lastWidth = 0
private var lastHeight = 0
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?.headerViewDidChangeSize(w, h)
}
}
companion object {
const val TAG_NAME = "TrueSheet"
}
}

View File

@ -1,21 +0,0 @@
package com.lodev09.truesheet
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
/**
* ViewManager for TrueSheetHeaderView
* Manages the header area of the sheet
*/
@ReactModule(name = TrueSheetHeaderViewManager.REACT_CLASS)
class TrueSheetHeaderViewManager : ViewGroupManager<TrueSheetHeaderView>() {
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetHeaderView = TrueSheetHeaderView(reactContext)
companion object {
const val REACT_CLASS = "TrueSheetHeaderView"
}
}

View File

@ -1,170 +0,0 @@
package com.lodev09.truesheet
import android.os.Handler
import android.os.Looper
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.turbomodule.core.interfaces.TurboModule
import com.facebook.react.uimanager.UIManagerHelper
import com.lodev09.truesheet.core.TrueSheetStackManager
import java.util.concurrent.ConcurrentHashMap
/**
* TurboModule for TrueSheet imperative API
* Provides promise-based async operations using view references
*/
@ReactModule(name = TrueSheetModule.NAME)
class TrueSheetModule(reactContext: ReactApplicationContext) :
com.facebook.react.bridge.ReactContextBaseJavaModule(reactContext),
TurboModule {
override fun getName(): String = NAME
override fun invalidate() {
super.invalidate()
// Clear all registered views and observer on module invalidation
synchronized(viewRegistry) {
viewRegistry.clear()
}
TrueSheetStackManager.clear()
}
/**
* Present a sheet by reference
*
* @param viewTag Native view tag of the sheet component
* @param index Detent index to present at
* @param promise Promise that resolves when sheet is fully presented
* @throws VIEW_NOT_FOUND if the view with the given tag is not found
* @throws INVALID_VIEW_TYPE if the view is not a TrueSheetView
* @throws OPERATION_FAILED if the operation fails for any other reason
*/
@ReactMethod
fun presentByRef(viewTag: Double, index: Double, animated: Boolean, promise: Promise) {
val tag = viewTag.toInt()
val detentIndex = index.toInt()
withTrueSheetView(tag, promise) { view ->
view.present(detentIndex, animated) {
promise.resolve(null)
}
}
}
/**
* Dismiss a sheet by reference
*
* @param viewTag Native view tag of the sheet component
* @param promise Promise that resolves when sheet is fully dismissed
* @throws VIEW_NOT_FOUND if the view with the given tag is not found
* @throws INVALID_VIEW_TYPE if the view is not a TrueSheetView
* @throws OPERATION_FAILED if the operation fails for any other reason
*/
@ReactMethod
fun dismissByRef(viewTag: Double, animated: Boolean, promise: Promise) {
val tag = viewTag.toInt()
withTrueSheetView(tag, promise) { view ->
view.dismiss(animated) {
promise.resolve(null)
}
}
}
/**
* Resize a sheet to a different index by reference
*
* @param viewTag Native view tag of the sheet component
* @param index New detent index
* @param promise Promise that resolves when resize is complete
* @throws VIEW_NOT_FOUND if the view with the given tag is not found
* @throws INVALID_VIEW_TYPE if the view is not a TrueSheetView
* @throws OPERATION_FAILED if the operation fails for any other reason
*/
@ReactMethod
fun resizeByRef(viewTag: Double, index: Double, promise: Promise) {
val tag = viewTag.toInt()
val detentIndex = index.toInt()
withTrueSheetView(tag, promise) { view ->
view.resize(detentIndex) {
promise.resolve(null)
}
}
}
/**
* Helper method to get TrueSheetView by tag and execute closure
*/
private fun withTrueSheetView(tag: Int, promise: Promise, closure: (view: TrueSheetView) -> Unit) {
Handler(Looper.getMainLooper()).post {
try {
// First try to get from registry (faster)
var view = getSheetByTag(tag)
// Fallback to UIManager resolution
if (view == null) {
val manager = UIManagerHelper.getUIManagerForReactTag(reactApplicationContext, tag)
val resolvedView = manager?.resolveView(tag)
if (resolvedView is TrueSheetView) {
view = resolvedView
} else if (resolvedView != null) {
promise.reject(
"INVALID_VIEW_TYPE",
"View with tag $tag is not a TrueSheetView (got ${resolvedView::class.simpleName})"
)
return@post
}
}
if (view == null) {
promise.reject("VIEW_NOT_FOUND", "TrueSheetView with tag $tag not found")
return@post
}
closure(view)
} catch (e: Exception) {
promise.reject("OPERATION_FAILED", "Failed to execute operation: ${e.message}", e)
}
}
}
companion object {
const val NAME = "TrueSheetModule"
/**
* Registry to keep track of TrueSheetView instances by their view tag
* This provides fast lookup for ref-based operations
*/
private val viewRegistry = ConcurrentHashMap<Int, TrueSheetView>()
/**
* Register a TrueSheetView instance
* Called automatically by TrueSheetView during initialization
*/
@JvmStatic
fun registerView(view: TrueSheetView, tag: Int) {
viewRegistry[tag] = view
}
/**
* Unregister a TrueSheetView instance
* Called automatically by TrueSheetView during cleanup
*/
@JvmStatic
fun unregisterView(tag: Int) {
viewRegistry.remove(tag)
}
/**
* Get a TrueSheetView by its tag
* @param tag - The React native tag of the view
* @return The TrueSheetView instance, or null if not found
*/
@JvmStatic
fun getSheetByTag(tag: Int): TrueSheetView? = viewRegistry[tag]
}
}

View File

@ -1,45 +1,12 @@
package com.lodev09.truesheet
import com.facebook.react.TurboReactPackage
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.uimanager.ViewManager
/**
* TrueSheet package for Fabric architecture
* Registers all view managers and the TurboModule
*/
class TrueSheetPackage : TurboReactPackage() {
class TrueSheetPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> = listOf(TrueSheetViewModule(reactContext))
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
when (name) {
TrueSheetModule.NAME -> TrueSheetModule(reactContext)
else -> null
}
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
ReactModuleInfoProvider {
mapOf(
TrueSheetModule.NAME to ReactModuleInfo(
TrueSheetModule.NAME,
TrueSheetModule::class.java.name,
false, // canOverrideExistingModule
false, // needsEagerInit
true, // hasConstants
false, // isCxxModule
true // isTurboModule
)
)
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
listOf(
TrueSheetViewManager(),
TrueSheetContainerViewManager(),
TrueSheetContentViewManager(),
TrueSheetHeaderViewManager(),
TrueSheetFooterViewManager()
)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = listOf(TrueSheetViewManager())
}

View File

@ -1,500 +1,320 @@
package com.lodev09.truesheet
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.accessibility.AccessibilityEvent
import androidx.annotation.UiThread
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.uimanager.PixelUtil.pxToDp
import com.facebook.react.uimanager.StateWrapper
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.util.RNLog
import com.facebook.react.views.view.ReactViewGroup
import com.lodev09.truesheet.core.GrabberOptions
import com.lodev09.truesheet.core.TrueSheetStackManager
import com.lodev09.truesheet.events.*
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.lodev09.truesheet.core.RootSheetView
import com.lodev09.truesheet.events.DismissEvent
import com.lodev09.truesheet.events.MountEvent
import com.lodev09.truesheet.events.PresentEvent
import com.lodev09.truesheet.events.SizeChangeEvent
/**
* Main TrueSheet host view that manages the sheet and dispatches events to JavaScript.
* This view is hidden (GONE) and delegates all rendering to TrueSheetViewController
* using a CoordinatorLayout approach (no separate dialog window).
*/
@SuppressLint("ViewConstructor")
class TrueSheetView(private val reactContext: ThemedReactContext) :
ReactViewGroup(reactContext),
LifecycleEventListener,
TrueSheetViewControllerDelegate,
TrueSheetContainerViewDelegate {
class TrueSheetView(context: Context) :
ViewGroup(context),
LifecycleEventListener {
private var eventDispatcher: EventDispatcher? = null
companion object {
const val TAG_NAME = "TrueSheet"
}
private val reactContext: ThemedReactContext
get() = context as ThemedReactContext
// ==================== Properties ====================
private val surfaceId: Int
get() = UIManagerHelper.getSurfaceId(this)
internal val viewController: TrueSheetViewController = TrueSheetViewController(reactContext)
var initialIndex: Int = -1
var initialIndexAnimated: Boolean = true
private val containerView: TrueSheetContainerView?
get() = viewController.getChildAt(0) as? TrueSheetContainerView
/**
* Current activeIndex.
*/
private var currentSizeIndex: Int = 0
var eventDispatcher: EventDispatcher? = null
/**
* Promise callback to be invoked after `present` is called.
*/
private var presentPromise: (() -> Unit)? = null
// Initial present configuration (set by ViewManager before mount)
var initialDetentIndex: Int = -1
var initialDetentAnimated: Boolean = true
private var didInitiallyPresent: Boolean = false
/**
* Promise callback to be invoked after `dismiss` is called.
*/
private var dismissPromise: (() -> Unit)? = null
var stateWrapper: StateWrapper? = null
set(value) {
// On first state wrapper assignment, immediately update state with screen dimensions.
// This ensures Yoga has initial width/height for content layout before presenting.
if (field == null && value != null) {
updateState(viewController.screenWidth, viewController.screenHeight)
}
field = value
}
/**
* The main BottomSheetDialog instance.
*/
private val sheetDialog: TrueSheetDialog
private var lastContainerWidth: Int = 0
private var lastContainerHeight: Int = 0
// Debounce flag to coalesce rapid layout changes into a single sheet update
private var isSheetUpdatePending: Boolean = false
// Root container for the coordinator layout (activity or Modal dialog content view)
private var rootContainerView: ViewGroup? = null
// ==================== Initialization ====================
/**
* React root view placeholder.
*/
private val rootSheetView: RootSheetView
init {
reactContext.addLifecycleEventListener(this)
viewController.delegate = this
eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
// Hide the host view - actual content is rendered in the dialog window
visibility = GONE
rootSheetView = RootSheetView(context)
rootSheetView.eventDispatcher = eventDispatcher
sheetDialog = TrueSheetDialog(reactContext, rootSheetView)
// Configure Sheet Dialog
sheetDialog.apply {
// Setup listener when the dialog has been presented.
setOnShowListener {
registerKeyboardManager()
// Initialize footer y
UiThreadUtil.runOnUiThread {
positionFooter()
}
// Re-enable animation
resetAnimation()
// Resolve the present promise
presentPromise?.let { promise ->
promise()
presentPromise = null
}
// Dispatch onPresent event
eventDispatcher?.dispatchEvent(PresentEvent(surfaceId, id, sheetDialog.getSizeInfoForIndex(currentSizeIndex)))
}
// Setup listener when the dialog has been dismissed.
setOnDismissListener {
unregisterKeyboardManager()
// Resolve the dismiss promise
dismissPromise?.let { promise ->
promise()
dismissPromise = null
}
// Dispatch onDismiss event
eventDispatcher?.dispatchEvent(DismissEvent(surfaceId, id))
}
// Configure sheet behavior events
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(sheetView: View, slideOffset: Float) {
footerView?.let {
val y = (maxScreenHeight - sheetView.top - footerHeight).toFloat()
if (slideOffset >= 0) {
// Sheet is expanding
it.y = y
} else {
// Sheet is collapsing
it.y = y - footerHeight * slideOffset
}
}
}
override fun onStateChanged(view: View, newState: Int) {
if (!isShowing) return
val sizeInfo = getSizeInfoForState(newState)
if (sizeInfo == null || sizeInfo.index == currentSizeIndex) return
// Invoke promise when sheet resized programmatically
presentPromise?.let { promise ->
promise()
presentPromise = null
}
currentSizeIndex = sizeInfo.index
setupDimmedBackground(sizeInfo.index)
// Dispatch onSizeChange event
eventDispatcher?.dispatchEvent(SizeChangeEvent(surfaceId, id, sizeInfo))
}
}
)
}
}
// ==================== ReactViewGroup Overrides ====================
override fun dispatchProvideStructure(structure: ViewStructure) {
super.dispatchProvideStructure(structure)
rootSheetView.dispatchProvideStructure(structure)
}
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int
l: Int,
t: Int,
r: Int,
b: Int
) {
// No-op: layout is managed by React Native's UIManager
// Do nothing as we are laid out by UIManager
}
override fun setId(id: Int) {
super.setId(id)
viewController.id = id
TrueSheetModule.registerView(this, id)
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
sheetDialog.dismiss()
}
// ==================== View Hierarchy Management ====================
override fun addView(child: View, index: Int) {
// Hide this host view
visibility = GONE
override fun onAttachedToWindow() {
super.onAttachedToWindow()
(child as ViewGroup).let {
// rootView's first child is the Container View
rootSheetView.addView(it, index)
if (initialDetentIndex >= 0 && !didInitiallyPresent) {
didInitiallyPresent = true
if (initialDetentAnimated) {
present(initialDetentIndex, true) { }
} else {
post { present(initialDetentIndex, false) { } }
// Initialize content
UiThreadUtil.runOnUiThread {
// 1st child is the content view
val contentView = it.getChildAt(0) as ViewGroup?
setContentHeight(contentView?.height ?: 0)
// 2nd child is the footer view
val footerView = it.getChildAt(1) as ViewGroup?
sheetDialog.footerView = footerView
setFooterHeight(footerView?.height ?: 0)
if (initialIndex >= 0) {
currentSizeIndex = initialIndex
sheetDialog.present(initialIndex, initialIndexAnimated)
}
// Dispatch onMount event
eventDispatcher?.dispatchEvent(MountEvent(surfaceId, id))
}
}
}
override fun addView(child: View?, index: Int) {
viewController.addView(child, index)
if (child is TrueSheetContainerView) {
child.delegate = this
viewController.createSheet()
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(MountEvent(surfaceId, id))
}
override fun getChildCount(): Int {
// This method may be called by the parent constructor
// before rootView is initialized.
return rootSheetView.childCount
}
override fun getChildCount(): Int = viewController.childCount
override fun getChildAt(index: Int): View = rootSheetView.getChildAt(index)
override fun getChildAt(index: Int): View? = viewController.getChildAt(index)
override fun removeView(child: View) {
rootSheetView.removeView(child)
}
override fun removeViewAt(index: Int) {
val child = getChildAt(index)
if (child is TrueSheetContainerView) {
child.delegate = null
}
viewController.removeView(child)
rootSheetView.removeView(child)
}
// Accessibility: delegate to dialog's host view since this view is hidden
override fun addChildrenForAccessibility(outChildren: ArrayList<View>) {}
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = false
override fun addChildrenForAccessibility(outChildren: ArrayList<View>) {
// Explicitly override this to prevent accessibility events being passed down to children
// Those will be handled by the rootView which lives in the dialog
}
// ==================== Lifecycle ====================
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean {
// Explicitly override this to prevent accessibility events being passed down to children
// Those will be handled by the rootView which lives in the dialog
return false
}
override fun onHostResume() {
viewController.reapplyHiddenState()
finalizeUpdates()
// do nothing
}
override fun onHostPause() {}
override fun onHostPause() {
// do nothing
}
override fun onHostDestroy() {
onDropInstance()
}
fun onDropInstance() {
// Drop the instance if the host is destroyed which will dismiss the dialog
reactContext.removeLifecycleEventListener(this)
if (viewController.isPresented) {
viewController.dismiss(animated = false)
}
TrueSheetModule.unregisterView(id)
TrueSheetStackManager.removeSheet(this)
viewController.delegate = null
didInitiallyPresent = false
sheetDialog.dismiss()
}
/**
* Called by the ViewManager after all properties are set.
* Reconfigures the sheet if it's currently presented.
*/
fun finalizeUpdates() {
if (viewController.isPresented) {
viewController.sheetView?.setupBackground()
viewController.sheetView?.setupGrabber()
updateSheetIfNeeded()
private fun configureIfShowing() {
if (sheetDialog.isShowing) {
sheetDialog.configure()
sheetDialog.positionFooter()
}
}
// ==================== Property Setters ====================
fun setMaxHeight(height: Int) {
if (viewController.maxSheetHeight == height) return
viewController.maxSheetHeight = height
if (sheetDialog.maxSheetHeight == height) return
sheetDialog.maxSheetHeight = height
configureIfShowing()
}
fun setContentHeight(height: Int) {
if (sheetDialog.contentHeight == height) return
sheetDialog.contentHeight = height
configureIfShowing()
}
fun setFooterHeight(height: Int) {
if (sheetDialog.footerHeight == height) return
sheetDialog.footerHeight = height
configureIfShowing()
}
fun setDimmed(dimmed: Boolean) {
if (viewController.dimmed == dimmed) return
viewController.dimmed = dimmed
if (viewController.isPresented) {
viewController.setupDimmedBackground(viewController.currentDetentIndex)
viewController.updateDimAmount()
if (sheetDialog.dimmed == dimmed) return
sheetDialog.dimmed = dimmed
if (sheetDialog.isShowing) {
sheetDialog.setupDimmedBackground(currentSizeIndex)
}
}
fun setDimmedDetentIndex(index: Int) {
if (viewController.dimmedDetentIndex == index) return
viewController.dimmedDetentIndex = index
if (viewController.isPresented) {
viewController.setupDimmedBackground(viewController.currentDetentIndex)
viewController.updateDimAmount()
fun setDimmedIndex(index: Int) {
if (sheetDialog.dimmedIndex == index) return
sheetDialog.dimmedIndex = index
if (sheetDialog.isShowing) {
sheetDialog.setupDimmedBackground(currentSizeIndex)
}
}
fun setCornerRadius(radius: Float) {
if (viewController.sheetCornerRadius == radius) return
viewController.sheetCornerRadius = radius
}
fun setSheetBackgroundColor(color: Int?) {
if (viewController.sheetBackgroundColor == color) return
viewController.sheetBackgroundColor = color
fun setSoftInputMode(mode: Int) {
sheetDialog.window?.apply {
this.setSoftInputMode(mode)
}
}
fun setDismissible(dismissible: Boolean) {
viewController.dismissible = dismissible
sheetDialog.dismissible = dismissible
}
fun setDraggable(draggable: Boolean) {
viewController.draggable = draggable
}
fun setGrabber(grabber: Boolean) {
viewController.grabber = grabber
}
fun setGrabberOptions(options: GrabberOptions?) {
viewController.grabberOptions = options
}
fun setSheetElevation(elevation: Float) {
viewController.sheetElevation = elevation
}
fun setDetents(newDetents: MutableList<Double>) {
viewController.detents = newDetents
}
fun setInsetAdjustment(insetAdjustment: String) {
viewController.insetAdjustment = insetAdjustment
}
// ==================== State Management ====================
/**
* Updates the Fabric state with container dimensions for Yoga layout.
* Converts pixel values to density-independent pixels (dp).
*/
fun updateState(width: Int, height: Int) {
if (width == lastContainerWidth && height == lastContainerHeight) return
lastContainerWidth = width
lastContainerHeight = height
val sw = stateWrapper ?: return
val newStateData = WritableNativeMap()
newStateData.putDouble("containerWidth", width.toFloat().pxToDp().toDouble())
newStateData.putDouble("containerHeight", height.toFloat().pxToDp().toDouble())
sw.updateState(newStateData)
}
// ==================== Sheet Actions ====================
@UiThread
fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
if (!viewController.isPresented) {
// Attach coordinator to the root container
rootContainerView = findRootContainerView()
viewController.coordinatorLayout?.let { rootContainerView?.addView(it) }
// Register with observer to track sheet stack hierarchy
viewController.parentSheetView = TrueSheetStackManager.onSheetWillPresent(this, detentIndex)
}
viewController.presentPromise = promiseCallback
viewController.present(detentIndex, animated)
}
@UiThread
fun dismiss(animated: Boolean = true, promiseCallback: () -> Unit) {
// iOS-like behavior: calling dismiss on a presenting controller dismisses
// its presented controller (and everything above it), but NOT itself.
// See: https://developer.apple.com/documentation/uikit/uiviewcontroller/1621505-dismiss
val sheetsAbove = TrueSheetStackManager.getSheetsAbove(this)
if (sheetsAbove.isNotEmpty()) {
for (sheet in sheetsAbove) {
sheet.viewController.dismiss(animated)
}
promiseCallback()
return
}
viewController.dismissPromise = promiseCallback
viewController.dismiss(animated)
}
@UiThread
fun resize(detentIndex: Int, promiseCallback: () -> Unit) {
if (!viewController.isPresented) {
RNLog.w(reactContext, "TrueSheet: Cannot resize. Sheet is not presented.")
promiseCallback()
return
}
present(detentIndex, true, promiseCallback)
fun setSizes(newSizes: Array<Any>) {
sheetDialog.sizes = newSizes
configureIfShowing()
}
/**
* Debounced sheet update to handle rapid content/header size changes.
* Uses post() to ensure all layout passes complete before reconfiguring.
* Present the sheet at given size index.
*/
fun updateSheetIfNeeded() {
if (!viewController.isPresented) return
if (isSheetUpdatePending) return
isSheetUpdatePending = true
viewController.post {
isSheetUpdatePending = false
viewController.setupSheetDetentsForSizeChange()
TrueSheetStackManager.onSheetSizeChanged(this)
}
}
// ==================== Sheet Stack Translation ====================
/**
* Updates this sheet's translation and disables dragging when a child sheet is presented.
* Parent sheets slide down to create a stacked appearance.
* Propagates additional translation to parent so the entire stack stays visually consistent.
*/
fun updateTranslationForChild(childSheetTop: Int) {
if (!viewController.isSheetVisible || viewController.isExpanded) return
viewController.sheetView?.behavior?.isDraggable = false
val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex)
val newTranslation = maxOf(0, childSheetTop - mySheetTop)
val additionalTranslation = newTranslation - viewController.currentTranslationY
viewController.translateSheet(newTranslation)
// Propagate any additional translation up the stack
if (additionalTranslation > 0) {
TrueSheetStackManager.getParentSheet(this)?.addTranslation(additionalTranslation)
}
}
/**
* Recursively adds translation to this sheet and all parent sheets.
*/
private fun addTranslation(amount: Int) {
if (viewController.isExpanded) return
viewController.translateSheet(viewController.currentTranslationY + amount)
TrueSheetStackManager.getParentSheet(this)?.addTranslation(amount)
}
/**
* Resets this sheet's translation and restores dragging when it becomes topmost.
* Parent recalculates its translation based on this sheet's position.
*/
fun resetTranslation() {
viewController.sheetView?.behavior?.isDraggable = viewController.draggable
viewController.translateSheet(0)
// Parent should recalculate its translation based on this sheet's position
val mySheetTop = viewController.detentCalculator.getSheetTopForDetentIndex(viewController.currentDetentIndex)
TrueSheetStackManager.getParentSheet(this)?.updateTranslationForChild(mySheetTop)
}
// ==================== TrueSheetViewControllerDelegate ====================
override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(WillPresentEvent(surfaceId, id, index, position, detent))
// Enable touch event dispatching to React Native while sheet is visible
viewController.eventDispatcher = eventDispatcher
containerView?.footerView?.eventDispatcher = eventDispatcher
}
override fun viewControllerDidPresent(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DidPresentEvent(surfaceId, id, index, position, detent))
}
override fun viewControllerWillDismiss() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(WillDismissEvent(surfaceId, id))
// Disable touch event dispatching when sheet is dismissing
viewController.eventDispatcher = null
containerView?.footerView?.eventDispatcher = null
}
override fun viewControllerDidDismiss(hadParent: Boolean) {
// Detach coordinator from the root container view
viewController.coordinatorLayout?.let { rootContainerView?.removeView(it) }
rootContainerView = null
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DidDismissEvent(surfaceId, id))
TrueSheetStackManager.onSheetDidDismiss(this, hadParent)
}
override fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DetentChangeEvent(surfaceId, id, index, position, detent))
}
override fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DragBeginEvent(surfaceId, id, index, position, detent))
}
override fun viewControllerDidDragChange(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DragChangeEvent(surfaceId, id, index, position, detent))
}
override fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(DragEndEvent(surfaceId, id, index, position, detent))
}
override fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, realtime: Boolean) {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(PositionChangeEvent(surfaceId, id, index, position, detent, realtime))
}
override fun viewControllerDidChangeSize(width: Int, height: Int) {
updateState(width, height)
}
override fun viewControllerWillFocus() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(WillFocusEvent(surfaceId, id))
}
override fun viewControllerDidFocus() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(FocusEvent(surfaceId, id))
}
override fun viewControllerWillBlur() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(WillBlurEvent(surfaceId, id))
}
override fun viewControllerDidBlur() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(BlurEvent(surfaceId, id))
}
override fun viewControllerDidBackPress() {
val surfaceId = UIManagerHelper.getSurfaceId(this)
eventDispatcher?.dispatchEvent(BackPressEvent(surfaceId, id))
}
// ==================== TrueSheetContainerViewDelegate ====================
override fun containerViewContentDidChangeSize(width: Int, height: Int) {
updateSheetIfNeeded()
}
override fun containerViewHeaderDidChangeSize(width: Int, height: Int) {
updateSheetIfNeeded()
}
override fun containerViewFooterDidChangeSize(width: Int, height: Int) {
// Footer changes don't affect detents, only reposition it
viewController.positionFooter()
}
// ==================== Private Helpers ====================
/**
* Find the root container view for presenting the sheet.
* This traverses up the view hierarchy to find the content view (android.R.id.content)
* of whichever window this view is in - whether that's the activity's window or a
* Modal's dialog window.
*/
private fun findRootContainerView(): ViewGroup? {
var current: android.view.ViewParent? = parent
while (current != null) {
if (current is ViewGroup && current.id == android.R.id.content) {
return current
}
current = current.parent
fun present(sizeIndex: Int, promiseCallback: () -> Unit) {
if (!sheetDialog.isShowing) {
currentSizeIndex = sizeIndex
}
return reactContext.currentActivity?.findViewById(android.R.id.content)
presentPromise = promiseCallback
sheetDialog.present(sizeIndex)
}
/**
* Dismisses the sheet.
*/
fun dismiss(promiseCallback: () -> Unit) {
dismissPromise = promiseCallback
sheetDialog.dismiss()
}
companion object {
const val TAG = "TrueSheetView"
}
}

View File

@ -1,210 +1,102 @@
package com.lodev09.truesheet
import android.util.Log
import android.view.WindowManager
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.PixelUtil.dpToPx
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.StateWrapper
import com.facebook.react.bridge.ReadableType
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.TrueSheetViewManagerDelegate
import com.facebook.react.viewmanagers.TrueSheetViewManagerInterface
import com.lodev09.truesheet.core.GrabberOptions
import com.lodev09.truesheet.events.*
import com.lodev09.truesheet.core.Utils
import com.lodev09.truesheet.events.DismissEvent
import com.lodev09.truesheet.events.MountEvent
import com.lodev09.truesheet.events.PresentEvent
import com.lodev09.truesheet.events.SizeChangeEvent
/**
* ViewManager for TrueSheetView - Fabric architecture
* Main sheet component that manages the bottom sheet dialog
*/
@ReactModule(name = TrueSheetViewManager.REACT_CLASS)
class TrueSheetViewManager :
ViewGroupManager<TrueSheetView>(),
TrueSheetViewManagerInterface<TrueSheetView> {
private val delegate: ViewManagerDelegate<TrueSheetView> = TrueSheetViewManagerDelegate(this)
override fun getName(): String = REACT_CLASS
class TrueSheetViewManager : ViewGroupManager<TrueSheetView>() {
override fun getName() = TAG
override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetView = TrueSheetView(reactContext)
override fun onDropViewInstance(view: TrueSheetView) {
super.onDropViewInstance(view)
view.onDropInstance()
view.onHostDestroy()
}
override fun onAfterUpdateTransaction(view: TrueSheetView) {
super.onAfterUpdateTransaction(view)
view.finalizeUpdates()
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any>? =
MapBuilder.builder<String, Any>()
.put(MountEvent.EVENT_NAME, MapBuilder.of("registrationName", "onMount"))
.put(PresentEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPresent"))
.put(DismissEvent.EVENT_NAME, MapBuilder.of("registrationName", "onDismiss"))
.put(SizeChangeEvent.EVENT_NAME, MapBuilder.of("registrationName", "onSizeChange"))
.build()
@ReactProp(name = "maxHeight")
fun setMaxHeight(view: TrueSheetView, height: Double) {
view.setMaxHeight(Utils.toPixel(height))
}
override fun addEventEmitters(reactContext: ThemedReactContext, view: TrueSheetView) {
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
view.eventDispatcher = dispatcher
}
override fun updateState(view: TrueSheetView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
view.stateWrapper = stateWrapper
return null
}
override fun getDelegate(): ViewManagerDelegate<TrueSheetView> = delegate
/**
* Export custom direct event types for Fabric
* Uses Kotlin native collections with decoupled event classes
*/
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
MountEvent.EVENT_NAME to hashMapOf("registrationName" to MountEvent.REGISTRATION_NAME),
WillPresentEvent.EVENT_NAME to hashMapOf("registrationName" to WillPresentEvent.REGISTRATION_NAME),
DidPresentEvent.EVENT_NAME to hashMapOf("registrationName" to DidPresentEvent.REGISTRATION_NAME),
WillDismissEvent.EVENT_NAME to hashMapOf("registrationName" to WillDismissEvent.REGISTRATION_NAME),
DidDismissEvent.EVENT_NAME to hashMapOf("registrationName" to DidDismissEvent.REGISTRATION_NAME),
DetentChangeEvent.EVENT_NAME to hashMapOf("registrationName" to DetentChangeEvent.REGISTRATION_NAME),
DragBeginEvent.EVENT_NAME to hashMapOf("registrationName" to DragBeginEvent.REGISTRATION_NAME),
DragChangeEvent.EVENT_NAME to hashMapOf("registrationName" to DragChangeEvent.REGISTRATION_NAME),
DragEndEvent.EVENT_NAME to hashMapOf("registrationName" to DragEndEvent.REGISTRATION_NAME),
PositionChangeEvent.EVENT_NAME to hashMapOf("registrationName" to PositionChangeEvent.REGISTRATION_NAME),
WillFocusEvent.EVENT_NAME to hashMapOf("registrationName" to WillFocusEvent.REGISTRATION_NAME),
FocusEvent.EVENT_NAME to hashMapOf("registrationName" to FocusEvent.REGISTRATION_NAME),
WillBlurEvent.EVENT_NAME to hashMapOf("registrationName" to WillBlurEvent.REGISTRATION_NAME),
BlurEvent.EVENT_NAME to hashMapOf("registrationName" to BlurEvent.REGISTRATION_NAME),
BackPressEvent.EVENT_NAME to hashMapOf("registrationName" to BackPressEvent.REGISTRATION_NAME)
)
// ==================== Props ====================
@ReactProp(name = "detents")
override fun setDetents(view: TrueSheetView, value: ReadableArray?) {
if (value == null || value.size() == 0) {
view.setDetents(mutableListOf(0.5, 1.0))
return
}
val detents = mutableListOf<Double>()
IntProgression
.fromClosedRange(0, value.size() - 1, 1)
.asSequence()
.map { idx -> value.getDouble(idx) }
.toCollection(detents)
view.setDetents(detents)
}
@ReactProp(name = "backgroundColor", customType = "Color")
override fun setBackgroundColor(view: TrueSheetView, color: Int?) {
view.setSheetBackgroundColor(color)
}
@ReactProp(name = "cornerRadius", defaultDouble = -1.0)
override fun setCornerRadius(view: TrueSheetView, radius: Double) {
view.setCornerRadius(radius.dpToPx())
}
@ReactProp(name = "grabber", defaultBoolean = true)
override fun setGrabber(view: TrueSheetView, grabber: Boolean) {
view.setGrabber(grabber)
}
@ReactProp(name = "grabberOptions")
override fun setGrabberOptions(view: TrueSheetView, options: ReadableMap?) {
if (options == null) {
view.setGrabberOptions(null)
return
}
val grabberOptions = GrabberOptions(
width = if (options.hasKey("width")) options.getDouble("width").toFloat() else null,
height = if (options.hasKey("height")) options.getDouble("height").toFloat() else null,
topMargin = if (options.hasKey("topMargin")) options.getDouble("topMargin").toFloat() else null,
cornerRadius = if (options.hasKey("cornerRadius") &&
options.getDouble("cornerRadius") >= 0
) {
options.getDouble("cornerRadius").toFloat()
} else {
null
},
color = if (options.hasKey("color") && !options.isNull("color")) options.getInt("color") else null,
adaptive = if (options.hasKey("adaptive")) options.getBoolean("adaptive") else true
)
view.setGrabberOptions(grabberOptions)
}
@ReactProp(name = "dismissible", defaultBoolean = true)
override fun setDismissible(view: TrueSheetView, dismissible: Boolean) {
@ReactProp(name = "dismissible")
fun setDismissible(view: TrueSheetView, dismissible: Boolean) {
view.setDismissible(dismissible)
}
@ReactProp(name = "draggable", defaultBoolean = true)
override fun setDraggable(view: TrueSheetView, draggable: Boolean) {
view.setDraggable(draggable)
}
@ReactProp(name = "dimmed", defaultBoolean = true)
override fun setDimmed(view: TrueSheetView, dimmed: Boolean) {
@ReactProp(name = "dimmed")
fun setDimmed(view: TrueSheetView, dimmed: Boolean) {
view.setDimmed(dimmed)
}
@ReactProp(name = "dimmedDetentIndex", defaultInt = 0)
override fun setDimmedDetentIndex(view: TrueSheetView, index: Int) {
view.setDimmedDetentIndex(index)
@ReactProp(name = "initialIndex")
fun setInitialIndex(view: TrueSheetView, index: Int) {
view.initialIndex = index
}
@ReactProp(name = "initialDetentIndex", defaultInt = -1)
override fun setInitialDetentIndex(view: TrueSheetView, index: Int) {
view.initialDetentIndex = index
@ReactProp(name = "initialIndexAnimated")
fun setInitialIndexAnimated(view: TrueSheetView, animate: Boolean) {
view.initialIndexAnimated = animate
}
@ReactProp(name = "initialDetentAnimated", defaultBoolean = true)
override fun setInitialDetentAnimated(view: TrueSheetView, animate: Boolean) {
view.initialDetentAnimated = animate
}
@ReactProp(name = "maxHeight", defaultDouble = 0.0)
override fun setMaxHeight(view: TrueSheetView, height: Double) {
if (height > 0) {
view.setMaxHeight(height.dpToPx().toInt())
@ReactProp(name = "keyboardMode")
fun setKeyboardMode(view: TrueSheetView, mode: String) {
val softInputMode = when (mode) {
"pan" -> WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
else -> WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
}
view.setSoftInputMode(softInputMode)
}
@ReactProp(name = "backgroundBlur")
override fun setBackgroundBlur(view: TrueSheetView, tint: String?) {
// iOS-specific prop - no-op on Android
@ReactProp(name = "dimmedIndex")
fun setDimmedIndex(view: TrueSheetView, index: Int) {
view.setDimmedIndex(index)
}
@ReactProp(name = "blurOptions")
override fun setBlurOptions(view: TrueSheetView, options: ReadableMap?) {
// iOS-specific prop - no-op on Android
@ReactProp(name = "contentHeight")
fun setContentHeight(view: TrueSheetView, height: Double) {
view.setContentHeight(Utils.toPixel(height))
}
@ReactProp(name = "insetAdjustment")
override fun setInsetAdjustment(view: TrueSheetView, insetAdjustment: String?) {
view.setInsetAdjustment(insetAdjustment ?: "automatic")
@ReactProp(name = "footerHeight")
fun setFooterHeight(view: TrueSheetView, height: Double) {
view.setFooterHeight(Utils.toPixel(height))
}
@ReactProp(name = "scrollable", defaultBoolean = false)
override fun setScrollable(view: TrueSheetView, value: Boolean) {
// iOS-specific prop - no-op on Android
}
@ReactProp(name = "sizes")
fun setSizes(view: TrueSheetView, sizes: ReadableArray) {
val result = ArrayList<Any>()
for (i in 0 until minOf(sizes.size(), 3)) {
when (sizes.getType(i)) {
ReadableType.Number -> result.add(sizes.getDouble(i))
ReadableType.String -> result.add(sizes.getString(i))
else -> Log.d(TAG, "Invalid type")
}
}
@ReactProp(name = "pageSizing", defaultBoolean = true)
override fun setPageSizing(view: TrueSheetView, value: Boolean) {
// iOS-specific prop - no-op on Android
}
@ReactProp(name = "elevation", defaultDouble = -1.0)
override fun setElevation(view: TrueSheetView, elevation: Double) {
view.setSheetElevation(elevation.toFloat())
view.setSizes(result.toArray())
}
companion object {
const val REACT_CLASS = "TrueSheetView"
const val TAG_NAME = "TrueSheet"
const val TAG = "TrueSheetView"
}
}

View File

@ -0,0 +1,63 @@
package com.lodev09.truesheet
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.UIManagerHelper
import com.lodev09.truesheet.core.Utils
@ReactModule(name = TrueSheetViewModule.TAG)
class TrueSheetViewModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = TAG
private fun withTrueSheetView(tag: Int, closure: (trueSheetView: TrueSheetView) -> Unit) {
UiThreadUtil.runOnUiThread {
try {
val manager = UIManagerHelper.getUIManagerForReactTag(reactApplicationContext, tag)
val view = manager?.resolveView(tag)
if (view == null) {
Log.d(TAG, "Tag $tag not found")
return@runOnUiThread
}
if (view is TrueSheetView) {
closure(view)
} else {
Log.d(TAG, "Tag $tag does not match")
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@ReactMethod
fun present(tag: Int, index: Int, promise: Promise) {
withTrueSheetView(tag) {
it.present(index) {
Utils.withPromise(promise) {
return@withPromise null
}
}
}
}
@ReactMethod
fun dismiss(tag: Int, promise: Promise) {
withTrueSheetView(tag) {
it.dismiss {
Utils.withPromise(promise) {
return@withPromise null
}
}
}
}
companion object {
const val TAG = "TrueSheetView"
}
}

View File

@ -0,0 +1,58 @@
package com.lodev09.truesheet.core
import android.content.Context
import android.view.View
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.view.inputmethod.InputMethodManager
import com.facebook.react.bridge.ReactContext
class KeyboardManager(reactContext: ReactContext) {
interface OnKeyboardChangeListener {
fun onKeyboardStateChange(isVisible: Boolean, visibleHeight: Int?)
}
private var contentView: View? = null
private var onGlobalLayoutListener: OnGlobalLayoutListener? = null
private var isKeyboardVisible = false
init {
val activity = reactContext.currentActivity
contentView = activity?.findViewById(android.R.id.content)
}
fun registerKeyboardListener(listener: OnKeyboardChangeListener?) {
contentView?.apply {
unregisterKeyboardListener()
onGlobalLayoutListener = object : OnGlobalLayoutListener {
private var previousHeight = 0
override fun onGlobalLayout() {
val heightDiff = rootView.height - height
if (heightDiff > Utils.toPixel(200.0)) {
// Will ask InputMethodManager.isAcceptingText() to detect if keyboard appeared or not.
val inputManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (height != previousHeight && inputManager.isAcceptingText()) {
listener?.onKeyboardStateChange(true, height)
previousHeight = height
isKeyboardVisible = true
}
} else if (isKeyboardVisible) {
listener?.onKeyboardStateChange(false, null)
previousHeight = 0
isKeyboardVisible = false
}
}
}
getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener)
}
}
fun unregisterKeyboardListener() {
onGlobalLayoutListener?.let {
contentView?.getViewTreeObserver()?.removeOnGlobalLayoutListener(onGlobalLayoutListener)
}
}
}

View File

@ -1,181 +0,0 @@
package com.lodev09.truesheet.core
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.facebook.react.bridge.ReactContext
private const val RN_SCREENS_PACKAGE = "com.swmansion.rnscreens"
/**
* Observes fragment lifecycle to detect react-native-screens modal presentation.
* Automatically notifies when modals are presented/dismissed.
*/
class RNScreensFragmentObserver(
private val reactContext: ReactContext,
private val onModalPresented: () -> Unit,
private val onModalWillDismiss: () -> Unit,
private val onModalDidDismiss: () -> Unit
) {
private var fragmentLifecycleCallback: FragmentManager.FragmentLifecycleCallbacks? = null
private var activityLifecycleObserver: DefaultLifecycleObserver? = null
private val activeModalFragments: MutableSet<Fragment> = mutableSetOf()
private var isActivityInForeground = true
private var pendingDismissRunnable: Runnable? = null
/**
* Start observing fragment lifecycle events.
*/
fun start() {
val activity = reactContext.currentActivity as? AppCompatActivity ?: return
val fragmentManager = activity.supportFragmentManager
// Track activity foreground state to ignore fragment lifecycle events during background/foreground transitions
activityLifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
isActivityInForeground = true
}
override fun onPause(owner: LifecycleOwner) {
isActivityInForeground = false
}
}
activity.lifecycle.addObserver(activityLifecycleObserver!!)
fragmentLifecycleCallback = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
// Ignore if app is resuming from background
if (!isActivityInForeground) return
if (isModalFragment(f) && !activeModalFragments.contains(f)) {
// Cancel any pending dismiss since a modal is being presented
cancelPendingDismiss()
activeModalFragments.add(f)
if (activeModalFragments.size == 1) {
onModalPresented()
}
}
}
override fun onFragmentStopped(fm: FragmentManager, f: Fragment) {
super.onFragmentStopped(fm, f)
// Ignore if app is going to background (fragments stop with activity)
if (!isActivityInForeground) return
// Only trigger when fragment is being removed (not just stopped for navigation)
if (activeModalFragments.contains(f) && f.isRemoving) {
activeModalFragments.remove(f)
if (activeModalFragments.isEmpty()) {
// Post dismiss to allow fragment attach to cancel if navigation is happening
schedulePendingDismiss()
}
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
super.onFragmentDestroyed(fm, f)
if (activeModalFragments.isEmpty() && pendingDismissRunnable == null) {
onModalDidDismiss()
}
}
}
fragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallback!!, true)
}
/**
* Stop observing and cleanup.
*/
fun stop() {
val activity = reactContext.currentActivity as? AppCompatActivity
cancelPendingDismiss()
fragmentLifecycleCallback?.let { callback ->
activity?.supportFragmentManager?.unregisterFragmentLifecycleCallbacks(callback)
}
fragmentLifecycleCallback = null
activityLifecycleObserver?.let { observer ->
activity?.lifecycle?.removeObserver(observer)
}
activityLifecycleObserver = null
activeModalFragments.clear()
}
private fun schedulePendingDismiss() {
val activity = reactContext.currentActivity ?: return
val decorView = activity.window?.decorView ?: return
cancelPendingDismiss()
pendingDismissRunnable = Runnable {
pendingDismissRunnable = null
if (activeModalFragments.isEmpty()) {
onModalWillDismiss()
}
}
decorView.post(pendingDismissRunnable)
}
private fun cancelPendingDismiss() {
val activity = reactContext.currentActivity ?: return
val decorView = activity.window?.decorView ?: return
pendingDismissRunnable?.let {
decorView.removeCallbacks(it)
pendingDismissRunnable = null
}
}
companion object {
/**
* Check if fragment is from react-native-screens.
*/
private fun isScreensFragment(fragment: Fragment): Boolean = fragment.javaClass.name.startsWith(RN_SCREENS_PACKAGE)
/**
* Check if fragment is a react-native-screens modal (fullScreenModal, transparentModal, or formSheet).
* Uses reflection to check the fragment's screen.stackPresentation property.
*/
private fun isModalFragment(fragment: Fragment): Boolean {
val className = fragment.javaClass.name
if (!isScreensFragment(fragment)) {
return false
}
// ScreenModalFragment is always a modal (used for formSheet with BottomSheetDialog)
if (className.contains("ScreenModalFragment")) {
return true
}
// For ScreenStackFragment, check the screen's stackPresentation via reflection
try {
val getScreenMethod = fragment.javaClass.getMethod("getScreen")
val screen = getScreenMethod.invoke(fragment) ?: return false
val getStackPresentationMethod = screen.javaClass.getMethod("getStackPresentation")
val stackPresentation = getStackPresentationMethod.invoke(screen) ?: return false
val presentationName = stackPresentation.toString()
return presentationName == "MODAL" ||
presentationName == "TRANSPARENT_MODAL" ||
presentationName == "FORM_SHEET"
} catch (e: Exception) {
return false
}
}
}
}

View File

@ -0,0 +1,147 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.View
import com.facebook.react.bridge.GuardedRunnable
import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.uimanager.JSPointerDispatcher
import com.facebook.react.uimanager.JSTouchDispatcher
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerModule
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.view.ReactViewGroup
/**
* RootSheetView is the ViewGroup which contains all the children of a Modal. It gets all
* child information forwarded from TrueSheetView and uses that to create children. It is
* also responsible for acting as a RootView and handling touch events. It does this the same way
* as ReactRootView.
*
*
* To get layout to work properly, we need to layout all the elements within the Modal as if
* they can fill the entire window. To do that, we need to explicitly set the styleWidth and
* styleHeight on the LayoutShadowNode to be the window size. This is done through the
* UIManagerModule, and will then cause the children to layout as if they can fill the window.
*/
class RootSheetView(private val context: Context?) :
ReactViewGroup(context),
RootView {
private var hasAdjustedSize = false
private var viewWidth = 0
private var viewHeight = 0
private val jSTouchDispatcher = JSTouchDispatcher(this)
private var jSPointerDispatcher: JSPointerDispatcher? = null
private var sizeChangeListener: OnSizeChangeListener? = null
var eventDispatcher: EventDispatcher? = null
interface OnSizeChangeListener {
fun onSizeChange(width: Int, height: Int)
}
init {
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w
viewHeight = h
updateFirstChildView()
sizeChangeListener?.onSizeChange(w, h)
}
fun setOnSizeChangeListener(listener: OnSizeChangeListener) {
sizeChangeListener = listener
}
private fun updateFirstChildView() {
if (childCount > 0) {
hasAdjustedSize = false
val viewTag = getChildAt(0).id
reactContext.runOnNativeModulesQueueThread(
object : GuardedRunnable(reactContext) {
override fun runGuarded() {
val uiManager: UIManagerModule =
reactContext
.reactApplicationContext
.getNativeModule(UIManagerModule::class.java) ?: return
uiManager.updateNodeSize(viewTag, viewWidth, viewHeight)
}
}
)
} else {
hasAdjustedSize = true
}
}
override fun addView(child: View, index: Int, params: LayoutParams) {
super.addView(child, index, params)
if (hasAdjustedSize) {
updateFirstChildView()
}
}
override fun handleException(t: Throwable) {
reactContext.reactApplicationContext.handleException(RuntimeException(t))
}
private val reactContext: ThemedReactContext
get() = context as ThemedReactContext
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
jSTouchDispatcher.handleTouchEvent(event, eventDispatcher)
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
return super.onInterceptTouchEvent(event)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
jSTouchDispatcher.handleTouchEvent(event, eventDispatcher)
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
super.onTouchEvent(event)
// In case when there is no children interested in handling touch event, we return true from
// the root view in order to receive subsequent events related to that gesture
return true
}
override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
return super.onHoverEvent(event)
}
override fun onHoverEvent(event: MotionEvent): Boolean {
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
return super.onHoverEvent(event)
}
@Deprecated("Deprecated in Java")
override fun onChildStartedNativeGesture(ev: MotionEvent?) {
jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher)
}
override fun onChildStartedNativeGesture(childView: View, ev: MotionEvent) {
jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher)
jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
}
override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
jSTouchDispatcher.onChildEndedNativeGesture(ev, eventDispatcher)
jSPointerDispatcher?.onChildEndedNativeGesture()
}
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
// No-op - override in order to still receive events to onInterceptTouchEvent
// even when some other view disallow that
}
}

View File

@ -1,165 +0,0 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Outline
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.facebook.react.uimanager.PixelUtil.dpToPx
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
interface TrueSheetBottomSheetViewDelegate {
val isTopmostSheet: Boolean
val sheetCornerRadius: Float
val sheetElevation: Float
val sheetBackgroundColor: Int?
val grabber: Boolean
val grabberOptions: GrabberOptions?
}
/**
* The bottom sheet view that holds the content.
* This view has BottomSheetBehavior attached via CoordinatorLayout.LayoutParams.
*
* Touch dispatching to React Native is handled by TrueSheetViewController,
* which is the actual RootView containing the React content.
*/
@SuppressLint("ViewConstructor")
class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext) {
companion object {
private const val GRABBER_TAG = "TrueSheetGrabber"
private const val DEFAULT_CORNER_RADIUS = 16f // dp
private const val DEFAULT_MAX_WIDTH = 640 // dp
private const val DEFAULT_ELEVATION = 4f // dp
}
// =============================================================================
// MARK: - Properties
// =============================================================================
var delegate: TrueSheetBottomSheetViewDelegate? = null
// Behavior reference (set after adding to CoordinatorLayout)
val behavior: BottomSheetBehavior<TrueSheetBottomSheetView>?
get() = (layoutParams as? CoordinatorLayout.LayoutParams)
?.behavior as? BottomSheetBehavior<TrueSheetBottomSheetView>
// =============================================================================
// MARK: - Initialization
// =============================================================================
init {
// Allow content to extend beyond bounds (for footer positioning)
clipChildren = false
clipToPadding = false
}
override fun setTranslationY(translationY: Float) {
// This prevents keyboard inset animations from resetting parent sheet translation
if (translationY == 0f && this.translationY != 0f) {
return
}
super.setTranslationY(translationY)
}
// =============================================================================
// MARK: - Layout
// =============================================================================
/**
* Creates layout params with BottomSheetBehavior attached.
*/
fun createLayoutParams(): CoordinatorLayout.LayoutParams {
val behavior = BottomSheetBehavior<TrueSheetBottomSheetView>().apply {
isHideable = true
maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
}
return CoordinatorLayout.LayoutParams(
CoordinatorLayout.LayoutParams.MATCH_PARENT,
CoordinatorLayout.LayoutParams.MATCH_PARENT
).apply {
this.behavior = behavior
this.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
}
}
// =============================================================================
// MARK: - Background & Styling
// =============================================================================
fun setupBackground() {
val radius = delegate?.sheetCornerRadius ?: DEFAULT_CORNER_RADIUS.dpToPx()
val effectiveRadius = if (radius < 0) DEFAULT_CORNER_RADIUS.dpToPx() else radius
val outerRadii = floatArrayOf(
effectiveRadius,
effectiveRadius, // top-left
effectiveRadius,
effectiveRadius, // top-right
0f,
0f, // bottom-right
0f,
0f // bottom-left
)
val color = delegate?.sheetBackgroundColor ?: getDefaultBackgroundColor()
background = ShapeDrawable(RoundRectShape(outerRadii, null, null)).apply {
paint.color = color
}
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, effectiveRadius)
}
}
clipToOutline = true
}
private fun getDefaultBackgroundColor(): Int {
val typedValue = TypedValue()
return if (reactContext.theme.resolveAttribute(
com.google.android.material.R.attr.colorSurfaceContainerLow,
typedValue,
true
)
) {
typedValue.data
} else {
Color.WHITE
}
}
fun setupElevation() {
val value = delegate?.sheetElevation ?: DEFAULT_ELEVATION
val effectiveElevation = if (value < 0) DEFAULT_ELEVATION else value
elevation = effectiveElevation.dpToPx()
}
// =============================================================================
// MARK: - Grabber
// =============================================================================
fun setupGrabber() {
findViewWithTag<View>(GRABBER_TAG)?.let { removeView(it) }
val isEnabled = delegate?.grabber ?: true
val isDraggable = behavior?.isDraggable ?: true
if (!isEnabled || !isDraggable) return
val grabberView = TrueSheetGrabberView(reactContext, delegate?.grabberOptions).apply {
tag = GRABBER_TAG
}
addView(grabberView, 0)
}
}

View File

@ -1,55 +0,0 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactPointerEventsView
interface TrueSheetCoordinatorLayoutDelegate {
fun coordinatorLayoutDidLayout(changed: Boolean)
}
/**
* Custom CoordinatorLayout that hosts the bottom sheet and dim view.
* Implements ReactPointerEventsView to allow touch events to pass through
* to underlying React Native views when appropriate.
*/
@SuppressLint("ViewConstructor")
class TrueSheetCoordinatorLayout(context: Context) :
CoordinatorLayout(context),
ReactPointerEventsView {
var delegate: TrueSheetCoordinatorLayoutDelegate? = null
init {
// Fill the entire screen
layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
// Ensure we don't clip the sheet during animations
clipChildren = false
clipToPadding = false
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int
) {
super.onLayout(changed, l, t, r, b)
delegate?.coordinatorLayoutDidLayout(changed)
}
/**
* Allow pointer events to pass through to underlying views.
* The DimView and BottomSheetView handle their own touch interception.
*/
override val pointerEvents: PointerEvents
get() = PointerEvents.BOX_NONE
}

View File

@ -1,214 +0,0 @@
package com.lodev09.truesheet.core
import com.facebook.react.uimanager.PixelUtil.pxToDp
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.util.RNLog
import com.google.android.material.bottomsheet.BottomSheetBehavior
/**
* Provides screen dimensions and content measurements for detent calculations.
*/
interface TrueSheetDetentCalculatorDelegate {
val screenHeight: Int
val realScreenHeight: Int
val detents: MutableList<Double>
val contentHeight: Int
val headerHeight: Int
val contentBottomInset: Int
val maxSheetHeight: Int?
val keyboardInset: Int
}
/**
* Handles all detent-related calculations for the bottom sheet.
*/
class TrueSheetDetentCalculator(private val reactContext: ThemedReactContext) {
var delegate: TrueSheetDetentCalculatorDelegate? = null
private val screenHeight: Int get() = delegate?.screenHeight ?: 0
private val realScreenHeight: Int get() = delegate?.realScreenHeight ?: 0
private val detents: List<Double> get() = delegate?.detents ?: emptyList()
private val contentHeight: Int get() = delegate?.contentHeight ?: 0
private val headerHeight: Int get() = delegate?.headerHeight ?: 0
private val contentBottomInset: Int get() = delegate?.contentBottomInset ?: 0
private val maxSheetHeight: Int? get() = delegate?.maxSheetHeight
private val keyboardInset: Int get() = delegate?.keyboardInset ?: 0
/**
* Calculate the height in pixels for a given detent value.
* @param detent The detent value: -1.0 for content-fit, or 0.0-1.0 for screen fraction
*/
fun getDetentHeight(detent: Double): Int {
val baseHeight = if (detent == -1.0) {
contentHeight + headerHeight + contentBottomInset
} else {
if (detent <= 0.0 || detent > 1.0) {
throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
}
(detent * screenHeight).toInt() + contentBottomInset
}
val height = baseHeight + keyboardInset
val maxAllowedHeight = screenHeight + contentBottomInset
return maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
}
/**
* Get the expected sheet top position for a detent index.
*/
fun getSheetTopForDetentIndex(index: Int): Int {
if (index < 0 || index >= detents.size) {
RNLog.w(reactContext, "TrueSheet: Detent index ($index) is out of bounds (0..${detents.size - 1})")
return realScreenHeight
}
return realScreenHeight - getDetentHeight(detents[index])
}
/**
* Calculate visible sheet height from sheet top position.
*/
fun getVisibleSheetHeight(sheetTop: Int): Int = realScreenHeight - sheetTop
/**
* Convert visible sheet height to position in dp.
*/
fun getPositionDp(visibleSheetHeight: Int): Float = (screenHeight - visibleSheetHeight).pxToDp()
/**
* Returns the raw screen fraction for a detent index (without bottomInset).
*/
fun getDetentValueForIndex(index: Int): Float {
if (index < 0 || index >= detents.size) return 0f
val value = detents[index]
return if (value == -1.0) {
(contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
} else {
value.toFloat()
}
}
// ====================================================================
// MARK: - State Mapping
// ====================================================================
/**
* Maps detent index to BottomSheetBehavior state based on detent count.
*/
fun getStateForDetentIndex(index: Int): Int {
val stateMap = getDetentStateMap() ?: return BottomSheetBehavior.STATE_HIDDEN
return stateMap.entries.find { it.value == index }?.key ?: BottomSheetBehavior.STATE_HIDDEN
}
/**
* Maps BottomSheetBehavior state to detent index.
* @return The detent index, or null if state is not mapped
*/
fun getDetentIndexForState(state: Int): Int? {
val stateMap = getDetentStateMap() ?: return null
return stateMap[state]
}
/**
* Returns state-to-index mapping based on detent count.
*/
private fun getDetentStateMap(): Map<Int, Int>? =
when (detents.size) {
1 -> mapOf(
BottomSheetBehavior.STATE_COLLAPSED to 0,
BottomSheetBehavior.STATE_EXPANDED to 0
)
2 -> mapOf(
BottomSheetBehavior.STATE_COLLAPSED to 0,
BottomSheetBehavior.STATE_HALF_EXPANDED to 1,
BottomSheetBehavior.STATE_EXPANDED to 1
)
3 -> mapOf(
BottomSheetBehavior.STATE_COLLAPSED to 0,
BottomSheetBehavior.STATE_HALF_EXPANDED to 1,
BottomSheetBehavior.STATE_EXPANDED to 2
)
else -> null
}
// ====================================================================
// MARK: - Interpolation
// ====================================================================
/**
* 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<Int, Int, Float>? {
val count = detents.size
if (count == 0) return null
val firstPos = getSheetTopForDetentIndex(0)
// Position is below first detent (sheet is being dragged down to dismiss)
if (positionPx > firstPos) {
val range = realScreenHeight - firstPos
val progress = if (range > 0) (positionPx - firstPos).toFloat() / range else 0f
return Triple(-1, 0, progress)
}
if (count == 1) return Triple(0, 0, 0f)
val lastPos = getSheetTopForDetentIndex(count - 1)
// Position is above last detent
if (positionPx < lastPos) {
return Triple(count - 1, count - 1, 0f)
}
// Find the segment containing this position
for (i in 0 until count - 1) {
val pos = getSheetTopForDetentIndex(i)
val nextPos = getSheetTopForDetentIndex(i + 1)
if (positionPx in nextPos..pos) {
val range = pos - nextPos
val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
return Triple(i, i + 1, maxOf(0f, minOf(1f, progress)))
}
}
return Triple(count - 1, count - 1, 0f)
}
/**
* Returns continuous index (e.g., 0.5 = halfway between detent 0 and 1).
*/
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
val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
val (fromIndex, toIndex, progress) = segment
if (fromIndex == -1) {
val firstDetent = getDetentValueForIndex(0)
return maxOf(0f, firstDetent * (1 - progress))
}
val fromDetent = getDetentValueForIndex(fromIndex)
val toDetent = getDetentValueForIndex(toIndex)
return fromDetent + progress * (toDetent - fromDetent)
}
}

View File

@ -1,160 +0,0 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Outline
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactPointerEventsView
import com.facebook.react.uimanager.ThemedReactContext
import com.lodev09.truesheet.utils.ScreenUtils
/**
* Delegate for handling dim view interactions.
*/
interface TrueSheetDimViewDelegate {
fun dimViewDidTap()
}
/**
* Dim view that sits behind the bottom sheet in the CoordinatorLayout.
*
* Key behaviors:
* - When alpha > 0 (dimmed): blocks touches and calls delegate on tap
* - When alpha == 0 (not dimmed): passes touches through to views below
*
* This implements the "dimmedDetentIndex" equivalent functionality:
* the view only becomes interactive when the sheet is at or above the dimmed detent.
*/
@SuppressLint("ViewConstructor", "ClickableViewAccessibility")
class TrueSheetDimView(private val reactContext: ThemedReactContext) :
View(reactContext),
ReactPointerEventsView {
companion object {
private const val MAX_ALPHA = 0.5f
}
var delegate: TrueSheetDimViewDelegate? = null
private var targetView: ViewGroup? = null
/**
* Whether this view should block gestures (when dimmed).
*/
private val blockGestures: Boolean
get() = alpha > 0f
init {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
alpha = 0f
// Handle taps on the dim view
setOnClickListener {
delegate?.dimViewDidTap()
}
}
// =============================================================================
// MARK: - Attachment
// =============================================================================
/**
* Attaches this dim view to a target view group.
* For CoordinatorLayout usage, pass null to use the default (activity's decor view).
* For stacked sheets, pass the parent sheet's bottom sheet view with corner radius.
*/
fun attach(view: ViewGroup? = null, cornerRadius: Float = 0f) {
if (parent != null) return
targetView = view ?: reactContext.currentActivity?.window?.decorView as? ViewGroup
if (cornerRadius > 0f) {
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(v: View, outline: Outline) {
outline.setRoundRect(0, 0, v.width, v.height, cornerRadius)
}
}
clipToOutline = true
} else {
outlineProvider = null
clipToOutline = false
}
targetView?.addView(this)
}
/**
* Attaches this dim view to a CoordinatorLayout at index 0 (behind the sheet).
*/
fun attachToCoordinator(coordinator: TrueSheetCoordinatorLayout) {
if (parent != null) return
targetView = coordinator
outlineProvider = null
clipToOutline = false
coordinator.addView(this, 0)
}
fun detach() {
targetView?.removeView(this)
targetView = null
}
// =============================================================================
// MARK: - Alpha Calculation
// =============================================================================
fun calculateAlpha(sheetTop: Int, dimmedDetentIndex: Int, getSheetTopForDetentIndex: (Int) -> Int): Float {
val realHeight = ScreenUtils.getRealScreenHeight(reactContext)
val dimmedDetentTop = getSheetTopForDetentIndex(dimmedDetentIndex)
val belowDimmedTop = if (dimmedDetentIndex > 0) getSheetTopForDetentIndex(dimmedDetentIndex - 1) else realHeight
return when {
sheetTop <= dimmedDetentTop -> MAX_ALPHA
sheetTop >= belowDimmedTop -> 0f
else -> {
val progress = 1f - (sheetTop - dimmedDetentTop).toFloat() / (belowDimmedTop - dimmedDetentTop)
(progress * MAX_ALPHA).coerceIn(0f, MAX_ALPHA)
}
}
}
fun interpolateAlpha(sheetTop: Int, dimmedDetentIndex: Int, getSheetTopForDetentIndex: (Int) -> Int) {
alpha = calculateAlpha(sheetTop, dimmedDetentIndex, getSheetTopForDetentIndex)
}
// =============================================================================
// MARK: - Touch Handling
// =============================================================================
override fun onTouchEvent(event: MotionEvent): Boolean {
if (blockGestures) {
// When dimmed, consume touch and trigger click on ACTION_UP
if (event.action == MotionEvent.ACTION_UP) {
performClick()
}
return true
}
// When not dimmed, let touches pass through
return false
}
// =============================================================================
// MARK: - ReactPointerEventsView
// =============================================================================
/**
* When dimmed (alpha > 0), intercept touches (AUTO).
* When not dimmed (alpha == 0), pass through (NONE).
*/
override val pointerEvents: PointerEvents
get() = if (blockGestures) PointerEvents.AUTO else PointerEvents.NONE
}

View File

@ -1,88 +0,0 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import androidx.core.graphics.ColorUtils
import com.facebook.react.uimanager.PixelUtil.dpToPx
/**
* Options for customizing the grabber appearance.
*/
data class GrabberOptions(
val width: Float? = null,
val height: Float? = null,
val topMargin: Float? = null,
val cornerRadius: Float? = null,
val color: Int? = null,
val adaptive: Boolean = true
)
/**
* Native grabber (drag handle) view for the bottom sheet.
* Displays a small pill-shaped indicator at the top of the sheet.
*/
@SuppressLint("ViewConstructor")
class TrueSheetGrabberView(context: Context, private val options: GrabberOptions? = null) : View(context) {
companion object {
private const val DEFAULT_WIDTH = 32f // dp
private const val DEFAULT_HEIGHT = 4f // dp
private const val DEFAULT_TOP_MARGIN = 16f // dp
private const val DEFAULT_ALPHA = 0.4f
private val DEFAULT_COLOR = Color.argb((DEFAULT_ALPHA * 255).toInt(), 73, 69, 79) // #49454F @ 40%
}
private val grabberWidth: Float
get() = options?.width ?: DEFAULT_WIDTH
private val grabberHeight: Float
get() = options?.height ?: DEFAULT_HEIGHT
private val grabberTopMargin: Float
get() = options?.topMargin ?: DEFAULT_TOP_MARGIN
private val grabberCornerRadius: Float
get() = options?.cornerRadius ?: (grabberHeight / 2)
private val isAdaptive: Boolean
get() = options?.adaptive ?: true
private val grabberColor: Int
get() = if (isAdaptive) getAdaptiveColor(options?.color) else options?.color ?: DEFAULT_COLOR
init {
layoutParams = FrameLayout.LayoutParams(
grabberWidth.dpToPx().toInt(),
grabberHeight.dpToPx().toInt()
).apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
topMargin = grabberTopMargin.dpToPx().toInt()
}
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = grabberCornerRadius.dpToPx()
setColor(grabberColor)
}
}
private fun getAdaptiveColor(baseColor: Int? = null): Int {
val nightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val isDarkMode = nightMode == Configuration.UI_MODE_NIGHT_YES
val modeColor = if (isDarkMode) Color.WHITE else Color.BLACK
return if (baseColor != null) {
// Blend user color with mode color for adaptive effect
val blendedColor = ColorUtils.blendARGB(baseColor, modeColor, 0.3f)
ColorUtils.setAlphaComponent(blendedColor, (DEFAULT_ALPHA * 255).toInt())
} else {
ColorUtils.setAlphaComponent(modeColor, (DEFAULT_ALPHA * 255).toInt())
}
}
}

View File

@ -1,154 +0,0 @@
package com.lodev09.truesheet.core
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.ViewTreeObserver
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.uimanager.ThemedReactContext
import com.lodev09.truesheet.utils.KeyboardUtils
interface TrueSheetKeyboardObserverDelegate {
fun keyboardWillShow(height: Int)
fun keyboardWillHide()
fun keyboardDidHide()
fun keyboardDidChangeHeight(height: Int)
}
/**
* Tracks keyboard height and notifies delegate on changes.
* Uses WindowInsetsAnimationCompat on API 30+, ViewTreeObserver fallback on older versions.
*/
class TrueSheetKeyboardObserver(private val targetView: View, private val reactContext: ThemedReactContext) {
var delegate: TrueSheetKeyboardObserverDelegate? = null
var currentHeight: Int = 0
private set
var targetHeight: Int = 0
private set
private var isHiding: Boolean = false
private var globalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var activityRootView: View? = null
fun start() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setupAnimationCallback()
} else {
setupLegacyListener()
}
}
fun stop() {
globalLayoutListener?.let { listener ->
activityRootView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
globalLayoutListener = null
activityRootView = null
}
ViewCompat.setWindowInsetsAnimationCallback(targetView, null)
}
private fun updateHeight(from: Int, to: Int, fraction: Float) {
val newHeight = (from + (to - from) * fraction).toInt()
if (currentHeight != newHeight) {
currentHeight = newHeight
delegate?.keyboardDidChangeHeight(newHeight)
}
}
private fun getKeyboardHeight(): Int = KeyboardUtils.getKeyboardHeight(targetView)
private fun setupAnimationCallback() {
ViewCompat.setWindowInsetsAnimationCallback(
targetView,
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
private var startHeight = 0
private var endHeight = 0
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
startHeight = getKeyboardHeight()
}
override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat
): WindowInsetsAnimationCompat.BoundsCompat {
endHeight = getKeyboardHeight()
targetHeight = endHeight
isHiding = endHeight < startHeight
if (endHeight > startHeight) {
delegate?.keyboardWillShow(endHeight)
} else if (isHiding) {
delegate?.keyboardWillHide()
}
return bounds
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
val imeAnimation = runningAnimations.find {
it.typeMask and WindowInsetsCompat.Type.ime() != 0
} ?: return insets
val fraction = imeAnimation.interpolatedFraction
updateHeight(startHeight, endHeight, fraction)
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
val finalHeight = getKeyboardHeight()
updateHeight(startHeight, finalHeight, 1f)
if (isHiding) {
delegate?.keyboardDidHide()
isHiding = false
}
}
}
)
}
private fun setupLegacyListener() {
// Ensure we don't add duplicate listeners
if (globalLayoutListener != null) return
val rootView = reactContext.currentActivity?.window?.decorView?.rootView ?: return
activityRootView = rootView
globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
rootView.getWindowVisibleDisplayFrame(rect)
val screenHeight = rootView.height
val keyboardHeight = screenHeight - rect.bottom
val newHeight = if (keyboardHeight > screenHeight * 0.15) keyboardHeight else 0
// Skip if already at this height
if (targetHeight == newHeight) return@OnGlobalLayoutListener
val previousHeight = currentHeight
targetHeight = newHeight
isHiding = newHeight < previousHeight
if (newHeight > previousHeight) {
delegate?.keyboardWillShow(newHeight)
} else if (isHiding) {
delegate?.keyboardWillHide()
}
// On legacy API, keyboard has already animated - just update immediately
updateHeight(previousHeight, newHeight, 1f)
if (isHiding && newHeight == 0) {
delegate?.keyboardDidHide()
isHiding = false
}
}
rootView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
}
}

View File

@ -1,121 +0,0 @@
package com.lodev09.truesheet.core
import com.lodev09.truesheet.TrueSheetView
/**
* Manages TrueSheet stacking behavior.
* Tracks presented sheets and handles visibility when sheets stack on top of each other.
*/
object TrueSheetStackManager {
private val presentedSheetStack = mutableListOf<TrueSheetView>()
/**
* Called when a sheet is about to be presented.
* Returns the visible parent sheet to stack on, or null if none.
*/
@JvmStatic
fun onSheetWillPresent(sheetView: TrueSheetView, detentIndex: Int): TrueSheetView? {
synchronized(presentedSheetStack) {
val parentSheet = presentedSheetStack.lastOrNull()
?.takeIf { it.viewController.isPresented && it.viewController.isSheetVisible }
val childSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(detentIndex)
parentSheet?.updateTranslationForChild(childSheetTop)
if (!presentedSheetStack.contains(sheetView)) {
presentedSheetStack.add(sheetView)
}
return parentSheet
}
}
/**
* Called when a sheet has been dismissed.
* Resets parent sheet translation if this sheet was stacked on it.
*/
@JvmStatic
fun onSheetDidDismiss(sheetView: TrueSheetView, hadParent: Boolean) {
synchronized(presentedSheetStack) {
presentedSheetStack.remove(sheetView)
if (hadParent) {
presentedSheetStack.lastOrNull()?.resetTranslation()
}
}
}
/**
* Called when a presented sheet's size changes (e.g., after setupSheetDetents).
* Updates parent sheet translations to match the new sheet position.
*/
@JvmStatic
fun onSheetSizeChanged(sheetView: TrueSheetView) {
synchronized(presentedSheetStack) {
val index = presentedSheetStack.indexOf(sheetView)
if (index <= 0) return
val parentSheet = presentedSheetStack[index - 1]
// Post to ensure layout is complete before reading position
sheetView.viewController.post {
val childMinSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(0)
val childCurrentSheetTop = sheetView.viewController.detentCalculator.getSheetTopForDetentIndex(
sheetView.viewController.currentDetentIndex
)
// Cap to minimum detent position
val childSheetTop = maxOf(childMinSheetTop, childCurrentSheetTop)
parentSheet.updateTranslationForChild(childSheetTop)
}
}
}
/**
* Returns all sheets presented on top of the given sheet (children/descendants).
* Returns them in reverse order (top-most first) for proper dismissal.
*/
@JvmStatic
fun getSheetsAbove(sheetView: TrueSheetView): List<TrueSheetView> {
synchronized(presentedSheetStack) {
val index = presentedSheetStack.indexOf(sheetView)
if (index < 0 || index >= presentedSheetStack.size - 1) return emptyList()
return presentedSheetStack.subList(index + 1, presentedSheetStack.size).reversed()
}
}
@JvmStatic
fun removeSheet(sheetView: TrueSheetView) {
synchronized(presentedSheetStack) {
presentedSheetStack.remove(sheetView)
}
}
@JvmStatic
fun clear() {
synchronized(presentedSheetStack) {
presentedSheetStack.clear()
}
}
/**
* Gets the parent sheet of the given sheet, if any.
*/
@JvmStatic
fun getParentSheet(sheetView: TrueSheetView): TrueSheetView? {
synchronized(presentedSheetStack) {
val index = presentedSheetStack.indexOf(sheetView)
if (index <= 0) return null
return presentedSheetStack[index - 1]
}
}
/**
* Returns true if the given sheet is the topmost presented sheet.
*/
@JvmStatic
fun isTopmostSheet(sheetView: TrueSheetView): Boolean {
synchronized(presentedSheetStack) {
return presentedSheetStack.lastOrNull() == sheetView
}
}
}

View File

@ -0,0 +1,63 @@
package com.lodev09.truesheet.core
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.util.DisplayMetrics
import android.view.WindowInsets
import android.view.WindowManager
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.PixelUtil
object Utils {
@SuppressLint("DiscouragedApi")
private fun getIdentifierHeight(context: ReactContext, name: String): Int =
context.resources.getDimensionPixelSize(
context.resources.getIdentifier(name, "dimen", "android")
).takeIf { it > 0 } ?: 0
@SuppressLint("InternalInsetResource", "DiscouragedApi")
fun screenHeight(context: ReactContext): Int {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val displayMetrics = DisplayMetrics()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display?.getRealMetrics(displayMetrics)
} else {
windowManager.defaultDisplay.getMetrics(displayMetrics)
}
val screenHeight = displayMetrics.heightPixels
val statusBarHeight = getIdentifierHeight(context, "status_bar_height")
val hasNavigationBar = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.getSystemService(WindowManager::class.java)
?.currentWindowMetrics
?.windowInsets
?.isVisible(WindowInsets.Type.navigationBars()) ?: false
} else {
context.resources.getIdentifier("navigation_bar_height", "dimen", "android") > 0
}
val navigationBarHeight = if (hasNavigationBar) {
getIdentifierHeight(context, "navigation_bar_height")
} else {
0
}
return screenHeight - statusBarHeight - navigationBarHeight
}
fun toDIP(value: Int): Float = PixelUtil.toDIPFromPixel(value.toFloat())
fun toPixel(value: Double): Int = PixelUtil.toPixelFromDIP(value).toInt()
fun withPromise(promise: Promise, closure: () -> Any?) {
try {
val result = closure()
promise.resolve(result)
} catch (e: Throwable) {
e.printStackTrace()
promise.reject("Error", e.message, e.cause)
}
}
}

View File

@ -0,0 +1,16 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
// onDismiss
class DismissEvent(surfaceId: Int, viewId: Int) : Event<DismissEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "dismiss"
}
}

View File

@ -0,0 +1,16 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
// onMount
class MountEvent(surfaceId: Int, viewId: Int) : Event<MountEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "ready"
}
}

View File

@ -0,0 +1,23 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.lodev09.truesheet.SizeInfo
// onPresent
class PresentEvent(surfaceId: Int, viewId: Int, private val sizeInfo: SizeInfo) : Event<PresentEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
override fun getEventData(): WritableMap {
val data = Arguments.createMap()
data.putInt("index", sizeInfo.index)
data.putDouble("value", sizeInfo.value.toDouble())
return data
}
companion object {
const val EVENT_NAME = "present"
}
}

View File

@ -0,0 +1,23 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.lodev09.truesheet.SizeInfo
// onSizeChange
class SizeChangeEvent(surfaceId: Int, viewId: Int, private val sizeInfo: SizeInfo) : Event<SizeChangeEvent>(surfaceId, viewId) {
override fun getEventName() = EVENT_NAME
override fun getEventData(): WritableMap {
val data = Arguments.createMap()
data.putInt("index", sizeInfo.index)
data.putDouble("value", sizeInfo.value.toDouble())
return data
}
companion object {
const val EVENT_NAME = "sizeChange"
}
}

View File

@ -1,71 +0,0 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
/**
* Fired when dragging begins
* Payload: { index: number, position: number, detent: number }
*/
class DragBeginEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<DragBeginEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topDragBegin"
const val REGISTRATION_NAME = "onDragBegin"
}
}
/**
* Fired continuously during dragging
* Payload: { index: number, position: number, detent: number }
*/
class DragChangeEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<DragChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topDragChange"
const val REGISTRATION_NAME = "onDragChange"
}
}
/**
* Fired when dragging ends
* Payload: { index: number, position: number, detent: number }
*/
class DragEndEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<DragEndEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topDragEnd"
const val REGISTRATION_NAME = "onDragEnd"
}
}

View File

@ -1,65 +0,0 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
/**
* Fired when the sheet is about to regain focus because a sheet on top of it is being dismissed
*/
class WillFocusEvent(surfaceId: Int, viewId: Int) : Event<WillFocusEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topWillFocus"
const val REGISTRATION_NAME = "onWillFocus"
}
}
/**
* Fired when the sheet regains focus after a sheet on top of it is dismissed
*/
class FocusEvent(surfaceId: Int, viewId: Int) : Event<FocusEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topDidFocus"
const val REGISTRATION_NAME = "onDidFocus"
}
}
/**
* Fired when the sheet is about to lose focus because another sheet is being presented on top of it
*/
class WillBlurEvent(surfaceId: Int, viewId: Int) : Event<WillBlurEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topWillBlur"
const val REGISTRATION_NAME = "onWillBlur"
}
}
/**
* Fired when the sheet loses focus because another sheet is presented on top of it
*/
class BlurEvent(surfaceId: Int, viewId: Int) : Event<BlurEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topDidBlur"
const val REGISTRATION_NAME = "onDidBlur"
}
}

View File

@ -1,109 +0,0 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
/**
* Fired when the sheet component is mounted and ready
*/
class MountEvent(surfaceId: Int, viewId: Int) : Event<MountEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topMount"
const val REGISTRATION_NAME = "onMount"
}
}
/**
* Fired before the sheet is presented
* Payload: { index: number, position: number, detent: number }
*/
class WillPresentEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<WillPresentEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topWillPresent"
const val REGISTRATION_NAME = "onWillPresent"
}
}
/**
* Fired after the sheet is presented
* Payload: { index: number, position: number, detent: number }
*/
class DidPresentEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<DidPresentEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topDidPresent"
const val REGISTRATION_NAME = "onDidPresent"
}
}
/**
* Fired before the sheet is dismissed
*/
class WillDismissEvent(surfaceId: Int, viewId: Int) : Event<WillDismissEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topWillDismiss"
const val REGISTRATION_NAME = "onWillDismiss"
}
}
/**
* Fired after the sheet is dismissed
*/
class DidDismissEvent(surfaceId: Int, viewId: Int) : Event<DidDismissEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topDidDismiss"
const val REGISTRATION_NAME = "onDidDismiss"
}
}
/**
* Fired when the hardware back button is pressed (Android only)
*/
class BackPressEvent(surfaceId: Int, viewId: Int) : Event<BackPressEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap = Arguments.createMap()
companion object {
const val EVENT_NAME = "topBackPress"
const val REGISTRATION_NAME = "onBackPress"
}
}

View File

@ -1,56 +0,0 @@
package com.lodev09.truesheet.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
/**
* Fired when the detent changes
* Payload: { index: number, position: number, detent: number }
*/
class DetentChangeEvent(surfaceId: Int, viewId: Int, private val index: Int, private val position: Float, private val detent: Float) :
Event<DetentChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putInt("index", index)
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
}
companion object {
const val EVENT_NAME = "topDetentChange"
const val REGISTRATION_NAME = "onDetentChange"
}
}
/**
* Fired continuously for position updates during drag and animation
* Payload: { index: number, position: number, detent: number, realtime: boolean }
*/
class PositionChangeEvent(
surfaceId: Int,
viewId: Int,
private val index: Float,
private val position: Float,
private val detent: Float,
private val realtime: Boolean = false
) : Event<PositionChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putDouble("index", index.toDouble())
putDouble("position", position.toDouble())
putDouble("detent", detent.toDouble())
putBoolean("realtime", realtime)
}
companion object {
const val EVENT_NAME = "topPositionChange"
const val REGISTRATION_NAME = "onPositionChange"
}
}

View File

@ -1,29 +0,0 @@
package com.lodev09.truesheet.utils
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.uimanager.ThemedReactContext
object KeyboardUtils {
/**
* Dismisses the soft keyboard if currently shown.
*/
fun dismiss(reactContext: ThemedReactContext) {
val imm = reactContext.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
reactContext.currentActivity?.currentFocus?.let { focusedView ->
imm?.hideSoftInputFromWindow(focusedView.windowToken, 0)
}
}
/**
* Gets the current keyboard height from window insets.
*/
fun getKeyboardHeight(view: View): Int {
val insets = ViewCompat.getRootWindowInsets(view)
return insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
}
}

View File

@ -1,117 +0,0 @@
package com.lodev09.truesheet.utils
import android.graphics.Point
import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import com.facebook.react.bridge.ReactContext
import kotlin.math.min
/**
* Data class for top/bottom insets
*/
data class Insets(val top: Int, val bottom: Int)
/**
* Utility object for screen dimension calculations.
* Inset calculation approach inspired by react-native-safe-area-context.
*
* Note: This library requires React Native 0.76+ which has minSdk API 24.
*/
object ScreenUtils {
/**
* Get root window insets for API 30+ (Android R)
*/
private fun getInsetsR(rootView: View): Insets? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val insets = rootView.rootWindowInsets?.getInsets(
WindowInsets.Type.statusBars() or
WindowInsets.Type.displayCutout() or
WindowInsets.Type.navigationBars()
) ?: return null
return Insets(
top = insets.top,
bottom = insets.bottom
)
}
return null
}
/**
* Get root window insets for API 24-29
*/
@Suppress("DEPRECATION")
private fun getInsetsLegacy(rootView: View): Insets? {
val insets = rootView.rootWindowInsets ?: return null
return Insets(
top = insets.systemWindowInsetTop,
// Use min to avoid including soft keyboard
bottom = min(insets.systemWindowInsetBottom, insets.stableInsetBottom)
)
}
/**
* Get safe area insets from ReactContext using the activity's decor view.
*
* @param reactContext The ReactContext to get insets from
* @return Insets with top (status bar) and bottom (navigation bar) values in pixels
*/
fun getInsets(reactContext: ReactContext): Insets {
val activity = reactContext.currentActivity ?: return Insets(0, 0)
val decorView = activity.window?.decorView ?: return Insets(0, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getInsetsR(decorView)
} else {
getInsetsLegacy(decorView)
} ?: Insets(0, 0)
}
/**
* Get the real physical device screen height, including system bars.
* This is consistent across all API levels.
*
* @param reactContext The ReactContext to get context from
* @return Real screen height in pixels
*/
@Suppress("DEPRECATION")
fun getRealScreenHeight(reactContext: ReactContext): Int {
val windowManager = reactContext.getSystemService(WindowManager::class.java)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowManager.currentWindowMetrics.bounds.height()
} else {
val size = Point()
windowManager.defaultDisplay.getRealSize(size)
size.y
}
}
/**
* Get the screen width using the same method as React Native's useWindowDimensions.
*
* @param reactContext The ReactContext to get resources from
* @return Screen width in pixels
*/
fun getScreenWidth(reactContext: ReactContext): Int = reactContext.resources.displayMetrics.widthPixels
/**
* Calculate the screen height using the same method as React Native's useWindowDimensions.
* This returns the window height which automatically accounts for edge-to-edge mode.
*
* @param reactContext The ReactContext to get resources from
* @return Screen height in pixels
*/
fun getScreenHeight(reactContext: ReactContext): Int = reactContext.resources.displayMetrics.heightPixels
/**
* Get the location of a view in screen coordinates
*
* @param view The view to get screen location for
* @return IntArray with [x, y] coordinates in screen space
*/
fun getScreenLocation(view: View): IntArray {
val location = IntArray(2)
view.getLocationOnScreen(location)
return location
}
}

View File

@ -1,63 +0,0 @@
cmake_minimum_required(VERSION 3.13)
set(CMAKE_VERBOSE_MAKEFILE ON)
set(LIB_LITERAL TrueSheetSpec)
set(LIB_TARGET_NAME react_codegen_${LIB_LITERAL})
set(LIB_ANDROID_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
set(LIB_COMMON_DIR ${LIB_ANDROID_DIR}/../common/cpp)
set(LIB_ANDROID_GENERATED_JNI_DIR ${LIB_ANDROID_DIR}/build/generated/source/codegen/jni)
set(LIB_ANDROID_GENERATED_COMPONENTS_DIR ${LIB_ANDROID_GENERATED_JNI_DIR}/react/renderer/components/${LIB_LITERAL})
file(GLOB LIB_CUSTOM_SRCS CONFIGURE_DEPENDS *.cpp ${LIB_COMMON_DIR}/react/renderer/components/${LIB_LITERAL}/*.cpp)
file(GLOB LIB_CODEGEN_SRCS CONFIGURE_DEPENDS ${LIB_ANDROID_GENERATED_JNI_DIR}/*.cpp ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}/*.cpp)
add_library(
${LIB_TARGET_NAME}
SHARED
${LIB_CUSTOM_SRCS}
${LIB_CODEGEN_SRCS}
)
target_include_directories(
${LIB_TARGET_NAME}
PUBLIC
.
${LIB_COMMON_DIR}
${LIB_ANDROID_GENERATED_JNI_DIR}
${LIB_ANDROID_GENERATED_COMPONENTS_DIR}
)
target_link_libraries(
${LIB_TARGET_NAME}
ReactAndroid::reactnative
ReactAndroid::jsi
fbjni::fbjni
)
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 80)
target_compile_reactnative_options(${LIB_TARGET_NAME} PUBLIC)
else()
target_compile_options(
${LIB_TARGET_NAME}
PRIVATE
-fexceptions
-frtti
-std=c++20
-Wall
)
endif()
target_compile_options(
${LIB_TARGET_NAME}
PRIVATE
-Wpedantic
-Wno-gnu-zero-variadic-macro-arguments
-Wno-dollar-in-identifier-extension
)

View File

@ -1,17 +0,0 @@
#pragma once
#include <ReactCommon/JavaTurboModule.h>
#include <ReactCommon/TurboModule.h>
#include <jsi/jsi.h>
#include <react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h>
namespace facebook {
namespace react {
JSI_EXPORT
std::shared_ptr<TurboModule> TrueSheetSpec_ModuleProvider(
const std::string &moduleName,
const JavaTurboModule::InitParams &params);
} // namespace react
} // namespace facebook

View File

@ -1,12 +1,3 @@
module.exports = {
overrides: [
{
exclude: /\/node_modules\//,
presets: ['module:react-native-builder-bob/babel-preset'],
},
{
include: /\/node_modules\//,
presets: ['module:@react-native/babel-preset'],
},
],
presets: ['module:@react-native/babel-preset'],
}

View File

@ -1,24 +0,0 @@
#pragma once
#include <react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h>
#include <react/renderer/core/ConcreteComponentDescriptor.h>
namespace facebook::react {
/*
* Descriptor for <TrueSheetView> component.
*/
class TrueSheetViewComponentDescriptor final
: public ConcreteComponentDescriptor<TrueSheetViewShadowNode> {
using ConcreteComponentDescriptor::ConcreteComponentDescriptor;
void adopt(ShadowNode &shadowNode) const override {
auto &concreteShadowNode =
static_cast<TrueSheetViewShadowNode &>(shadowNode);
concreteShadowNode.adjustLayoutWithState();
ConcreteComponentDescriptor::adopt(shadowNode);
}
};
} // namespace facebook::react

View File

@ -1,48 +0,0 @@
#include "TrueSheetViewShadowNode.h"
#include <react/renderer/components/view/conversions.h>
namespace facebook::react {
using namespace yoga;
extern const char TrueSheetViewComponentName[] = "TrueSheetView";
void TrueSheetViewShadowNode::adjustLayoutWithState() {
ensureUnsealed();
auto state = std::static_pointer_cast<
const TrueSheetViewShadowNode::ConcreteState>(getState());
auto stateData = state->getData();
// If container dimensions are set from native, override Yoga's dimensions
if (stateData.containerWidth > 0 || stateData.containerHeight > 0) {
auto &props = getConcreteProps();
yoga::Style adjustedStyle = props.yogaStyle;
auto currentStyle = yogaNode_.style();
bool needsUpdate = false;
// Set width if provided
if (stateData.containerWidth > 0) {
adjustedStyle.setDimension(yoga::Dimension::Width, StyleSizeLength::points(stateData.containerWidth));
if (adjustedStyle.dimension(yoga::Dimension::Width) != currentStyle.dimension(yoga::Dimension::Width)) {
needsUpdate = true;
}
}
// Set height if provided
if (stateData.containerHeight > 0) {
adjustedStyle.setDimension(yoga::Dimension::Height, StyleSizeLength::points(stateData.containerHeight));
if (adjustedStyle.dimension(yoga::Dimension::Height) != currentStyle.dimension(yoga::Dimension::Height)) {
needsUpdate = true;
}
}
if (needsUpdate) {
yogaNode_.setStyle(adjustedStyle);
yogaNode_.setDirty(true);
}
}
}
} // namespace facebook::react

View File

@ -1,28 +0,0 @@
#pragma once
#include <jsi/jsi.h>
#include <react/renderer/components/TrueSheetSpec/EventEmitters.h>
#include <react/renderer/components/TrueSheetSpec/Props.h>
#include <react/renderer/components/TrueSheetSpec/TrueSheetViewState.h>
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
namespace facebook::react {
JSI_EXPORT extern const char TrueSheetViewComponentName[];
/*
* `ShadowNode` for <TrueSheetView> component.
*/
class JSI_EXPORT TrueSheetViewShadowNode final
: public ConcreteViewShadowNode<
TrueSheetViewComponentName,
TrueSheetViewProps,
TrueSheetViewEventEmitter,
TrueSheetViewState> {
using ConcreteViewShadowNode::ConcreteViewShadowNode;
public:
void adjustLayoutWithState();
};
} // namespace facebook::react

View File

@ -1,11 +0,0 @@
#include "TrueSheetViewState.h"
namespace facebook::react {
#ifdef ANDROID
folly::dynamic TrueSheetViewState::getDynamic() const {
return folly::dynamic::object("containerWidth", containerWidth)("containerHeight", containerHeight);
}
#endif
} // namespace facebook::react

View File

@ -1,42 +0,0 @@
#pragma once
#include <memory>
#ifdef ANDROID
#include <folly/dynamic.h>
#include <react/renderer/mapbuffer/MapBuffer.h>
#include <react/renderer/mapbuffer/MapBufferBuilder.h>
#endif
namespace facebook::react {
/*
* State for <TrueSheetView> component.
* Contains the container dimensions from native.
*/
class TrueSheetViewState final {
public:
using Shared = std::shared_ptr<const TrueSheetViewState>;
TrueSheetViewState() = default;
#ifdef ANDROID
TrueSheetViewState(
TrueSheetViewState const &previousState,
folly::dynamic data)
: containerWidth(static_cast<float>(data["containerWidth"].getDouble())),
containerHeight(static_cast<float>(data["containerHeight"].getDouble())) {}
#endif
float containerWidth{0};
float containerHeight{0};
#ifdef ANDROID
folly::dynamic getDynamic() const;
MapBuffer getMapBuffer() const {
return MapBufferBuilder::EMPTY();
}
#endif
};
} // namespace facebook::react

View File

@ -20,7 +20,7 @@ Many users have asked about presenting the sheet from anywhere within their appl
Introducing [static methods](/reference/methods#global-methods)!
With this update, you can now present the sheet from any part of your code by providing a [`name`](/reference/configuration#name) to your sheet instance. This streamlines the process and makes it easier to manage your sheets across your application.
With this update, you can now present the sheet from any part of your code by providing a [`name`](/reference/props#name) to your sheet instance. This streamlines the process and makes it easier to manage your sheets across your application.
Here's an example:
@ -53,7 +53,7 @@ Check out our [guide](/guides/global-methods) on global static methods for examp
### Truly Automatic `auto` Sizing
True Sheet has long supported [`auto`](/reference/types#detents) sizing, where the sheet's height adjusts dynamically based on its content. However, in previous versions, this feature had some limitations and required re-presenting the sheet to update the size after content changes.
True Sheet has long supported [`auto`](/reference/types#sheetsize) sizing, where the sheet's height adjusts dynamically based on its content. However, in previous versions, this feature had some limitations and required re-presenting the sheet to update the size after content changes.
With version `0.10`, `auto` sizing is now truly automatic. Whenever the content within the sheet changes, the sheet's height will adjust seamlessly without the need for re-presenting. 😎

View File

@ -7,5 +7,3 @@ tags: [bottom-sheet, true-sheet, native-sheet]
---
## Hello World!
{/* truncate */}

View File

@ -1,41 +0,0 @@
---
date: 2025-12-16
slug: android-3-4
title: Android Dimming and Stacking Improvements
authors: [lodev09]
tags: [release, android, stacking, dimming]
---
import preview from './assets/android-3-4.gif'
## Sheet is getting good on Android! 🤖
Version 3.4 brings two major upgrades to the Android experience: silky-smooth dimming and natural sheet stacking — now consistent with iOS! ✨
<img alt="Android Dimming and Stacking" src={preview} width="300" />
{/* truncate */}
## 🌑 Smooth Dimming
Say goodbye to clunky window dim! True Sheet now uses a **custom dim view** with real-time interpolation. As you drag the sheet, the dim smoothly fades in and out based on position.
The `dimmedDetentIndex` prop feels alive — drag past the threshold and watch the background dim; drag back down and it fades away. Buttery smooth. 🧈
Learn more in the [Dimming guide](/guides/dimming).
## 📚 Natural Sheet Stacking
When you present a sheet on top of another, the parent now **slides down** instead of disappearing. This creates a beautiful stacking effect that feels right at home on Android.
Resize the child sheet? The parent follows along in real-time to maintain that perfect visual hierarchy. 🎯
Learn more in the [Stacking guide](/guides/stacking).
## Get It 🚀
```sh
yarn add @lodev09/react-native-true-sheet@^3.4.0
```
Have feedback? [Open an issue](https://github.com/lodev09/react-native-true-sheet/issues)!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 MiB

View File

@ -1,215 +0,0 @@
---
date: 2025-12-01
slug: release-3-0
title: Version 3.0 Release
authors: [lodev09]
tags: [release, fabric, new-architecture]
---
import previewIos from '/img/preview-ios.gif'
import previewAndroid from '/img/preview-android.gif'
## True Sheet 3.0 is here!
We're thrilled to announce the biggest update yet! True Sheet has been **completely rebuilt from the ground up** for React Native's New Architecture (Fabric). This isn't just an update — it's a whole new level of performance and native experience.
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<img alt="React Native True Sheet - iOS" src={previewIos} width="300" />
<img alt="React Native True Sheet - Android" src={previewAndroid} width="300" />
</div>
{/* truncate */}
## Powered by Fabric
Version 3 is built **exclusively** for React Native's New Architecture. Here's what that means for you:
- **No Bridge** — Direct C++ communication between JavaScript and native code
- **Blazing Fast** — Synchronous layout updates with Fabric
- **Shared C++ Core** — State and shadow nodes shared between iOS and Android
- **100% Type-safe** — Full TypeScript support with Codegen-generated native interfaces
**Requirements:**
- React Native >= 0.76 (Expo SDK 52+)
- New Architecture enabled (default in RN 0.76+)
## What's New
### Automatic ScrollView Detection
Say goodbye to `scrollRef`! Scroll views are now **automatically detected** on iOS. Just use the `scrollable` prop and you're good to go:
```tsx
<TrueSheet scrollable>
<ScrollView>{/* Your scrollable content */}</ScrollView>
</TrueSheet>
```
See the [Scrolling guide](/guides/scrolling) for more details.
### Native Header and Footer
Add fixed headers and footers that stay put while your content scrolls:
```tsx
<TrueSheet
header={<MyHeader />}
footer={<MyFooter />}
>
<FlatList data={items} renderItem={renderItem} />
</TrueSheet>
```
See the [Header guide](/guides/header) and [Footer guide](/guides/footer) for more details.
### Sheet Stacking
Stack multiple sheets on top of each other on **both iOS and Android**! New focus events help you manage interactions:
- **`onWillFocus`** / **`onDidFocus`** — Sheet regains focus
- **`onWillBlur`** / **`onDidBlur`** — Sheet loses focus
See the [Stacking guide](/guides/stacking) for more details.
### Rich Event System
Track every moment of your sheet's lifecycle:
| Lifecycle | Drag | Position |
| - | - | - |
| `onMount` | `onDragBegin` | `onPositionChange` |
| `onWillPresent` | `onDragChange` | `onDetentChange` |
| `onDidPresent` | `onDragEnd` | |
| `onWillDismiss` | | |
| `onDidDismiss` | | |
See the [onMount guide](/guides/onmount) for handling sheet readiness.
### Reanimated v4 Integration
Seamless animations with dedicated Reanimated components:
```tsx
import {
ReanimatedTrueSheetProvider,
ReanimatedTrueSheet,
useReanimatedTrueSheet,
} from '@lodev09/react-native-true-sheet/reanimated'
import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated'
const App = () => (
<ReanimatedTrueSheetProvider>
<MySheet />
</ReanimatedTrueSheetProvider>
)
const MySheet = () => (
<ReanimatedTrueSheet detents={[0.3, 0.6, 1]}>
<AnimatedContent />
</ReanimatedTrueSheet>
)
const AnimatedContent = () => {
const { animatedIndex } = useReanimatedTrueSheet()
// Fade in as sheet expands from index 0 to 1
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(animatedIndex.value, [0, 1], [0, 1], Extrapolation.CLAMP)
}))
return (
<Animated.View style={animatedStyle}>
<Text>I fade in as the sheet expands!</Text>
</Animated.View>
)
}
```
See the [Reanimated guide](/guides/reanimated) for complete examples.
### Draggable Control
Need a static sheet? Disable dragging entirely:
```tsx
<TrueSheet draggable={false} detents={[0.5, 1]}>
<Button title="Expand" onPress={() => sheet.current?.resize(1)} />
</TrueSheet>
```
See the [Resizing guide](/guides/resizing) for programmatic control.
### Dimming Control
Customize when the background dims based on detent index:
```tsx
<TrueSheet detents={[0.3, 0.6, 1]} dimmedDetentIndex={1}>
{/* Dims only when at index 1 or above */}
</TrueSheet>
```
See the [Dimming guide](/guides/dimming) for more details.
### Edge-to-Edge Support (Android)
TrueSheet automatically adapts to Android's edge-to-edge mode. The sheet respects the status bar when fully expanded.
See the [Edge-to-Edge guide](/guides/edge-to-edge) for details.
### Global Static Methods
Present sheets from anywhere in your app using static methods:
```tsx
// Register a named sheet
<TrueSheet name="my-sheet">
{/* content */}
</TrueSheet>
// Present from anywhere
TrueSheet.present('my-sheet')
```
See the [Global Methods guide](/guides/global-methods) for more details.
### React Native Screens Integration
Navigate to other screens from within a sheet without any issues! The sheet will remain visible in the background when presenting modals on top.
:::note
This requires changes to `react-native-screens`. There is a [pending PR](https://github.com/software-mansion/react-native-screens/pull/3415) that adds support for this. In the meantime, you can apply the patch from the [example app](https://github.com/lodev09/react-native-true-sheet/blob/main/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch).
:::
See the [React Navigation guide](/guides/navigation) for more details.
## Breaking Changes
Heads up! Version 3 includes some breaking changes:
- **Fabric Required** — Old Paper architecture is no longer supported
- **Prop Renames** — `sizes` → `detents`, `initialIndex` → `initialDetentIndex`, `onPresent` → `onDidPresent`, and more
- **Detent Values** — Use fractional values (`0.5`) instead of percentage strings (`"50%"`)
- **Removed Props** — `scrollRef`, `grabberProps`, `contentContainerStyle`
Check out the [Migration Guide](/migration) for the full list and step-by-step upgrade instructions.
## Get Started
```sh
yarn add @lodev09/react-native-true-sheet@^3.0.0
```
Or with Expo:
```sh
npx expo install @lodev09/react-native-true-sheet
```
Running into issues? Check out the [Troubleshooting guide](/troubleshooting) for common fixes.
## Thank You!
A huge thanks to everyone who contributed, reported issues, and provided feedback. Your support makes True Sheet better with every release!
We're just getting started. Stay tuned for more exciting updates!

View File

@ -1,219 +0,0 @@
---
date: 2025-12-06
slug: sheet-navigator
title: Introducing Sheet Navigator
authors: [lodev09]
tags: [feature, react-navigation, navigation]
---
import navigation from '/docs/guides/assets/navigation.gif'
I'm excited to introduce **Sheet Navigator** — a custom React Navigation navigator that makes presenting sheets as natural as navigating between screens.
<img alt="Sheet Navigator" src={navigation} width="300" />
{/* truncate */}
## The Problem
Bottom sheets are a staple of modern mobile apps. But integrating them with React Navigation has always been... awkward. You'd typically have to:
1. Manage sheet refs manually
2. Call `present()` and `dismiss()` imperatively
3. Sync sheet state with navigation state yourself
4. Handle the back button separately
5. Deal with focus/blur when modals appear on top
It works, but it's not elegant. I wanted sheets to feel like first-class citizens in React Navigation.
## The Solution: Sheet Navigator
Sheet Navigator treats sheets as navigation destinations. The first screen is your base content, and every other screen becomes a sheet:
```tsx
import { NavigationContainer } from '@react-navigation/native';
import { createTrueSheetNavigator } from '@lodev09/react-native-true-sheet/navigation';
const Sheet = createTrueSheetNavigator();
function App() {
return (
<NavigationContainer>
<Sheet.Navigator>
<Sheet.Screen name="Home" component={HomeScreen} />
<Sheet.Screen
name="Details"
component={DetailsSheet}
options={{ detents: ['auto', 1], cornerRadius: 16 }}
/>
</Sheet.Navigator>
</NavigationContainer>
);
}
```
Now you can navigate to sheets like any other screen:
```tsx
navigation.navigate('Details', { itemId: 123 });
```
And dismiss them naturally:
```tsx
navigation.goBack();
```
## Features
### Full Screen Options Support
All TrueSheet props work as screen options. Configure each sheet declaratively:
```tsx
<Sheet.Screen
name="Settings"
component={SettingsSheet}
options={{
detents: [0.5, 1],
cornerRadius: 20,
grabber: true,
dimmedDetentIndex: 1,
backgroundColor: '#1a1a1a',
}}
/>
```
### Programmatic Resizing
Use the `useTrueSheetNavigation` hook to resize sheets from within:
```tsx
import { useTrueSheetNavigation } from '@lodev09/react-native-true-sheet/navigation';
function DetailsSheet() {
const navigation = useTrueSheetNavigation();
return (
<View>
<Button title="Expand" onPress={() => navigation.resize(1)} />
<Button title="Collapse" onPress={() => navigation.resize(0)} />
</View>
);
}
```
### Rich Event System
Listen to sheet lifecycle events at the navigator level:
```tsx
<Sheet.Navigator
screenListeners={{
sheetWillPresent: (e) => console.log('Presenting at index:', e.data.index),
sheetDidDismiss: () => console.log('Sheet dismissed'),
sheetDetentChange: (e) => console.log('Detent changed to:', e.data.index),
sheetPositionChange: (e) => console.log('Position:', e.data.position),
}}
>
```
All 14 events from TrueSheet are available:
| Lifecycle | Drag | Focus |
|-----------|------|-------|
| `sheetWillPresent` | `sheetDragBegin` | `sheetWillFocus` |
| `sheetDidPresent` | `sheetDragChange` | `sheetDidFocus` |
| `sheetWillDismiss` | `sheetDragEnd` | `sheetWillBlur` |
| `sheetDidDismiss` | `sheetPositionChange` | `sheetDidBlur` |
| | `sheetDetentChange` | |
### Wrap Your Existing Navigation
Already have a complex navigation setup? Wrap it with Sheet Navigator to present sheets from anywhere:
```tsx
const Stack = createNativeStackNavigator();
const Sheet = createTrueSheetNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Navigator>
);
}
function App() {
return (
<NavigationContainer>
<Sheet.Navigator>
<Sheet.Screen name="Root" component={RootStack} />
<Sheet.Screen name="QuickActions" component={QuickActionsSheet} />
<Sheet.Screen name="Share" component={ShareSheet} />
</Sheet.Navigator>
</NavigationContainer>
);
}
```
Now any screen in your app can present sheets:
```tsx
// From anywhere in RootStack
navigation.navigate('QuickActions');
```
### Expo Router Support
Using Expo Router? Sheet Navigator works with `withLayoutContext`:
```tsx
// app/_layout.tsx
import { withLayoutContext } from 'expo-router';
import { createTrueSheetNavigator } from '@lodev09/react-native-true-sheet/navigation';
const { Navigator } = createTrueSheetNavigator();
export default withLayoutContext(Navigator);
```
```tsx
// app/details.tsx
export const unstable_settings = {
options: { detents: ['auto', 1], cornerRadius: 16 },
};
export default function DetailsSheet() {
// Sheet content
}
```
## Under the Hood
Sheet Navigator is built on a custom router that extends React Navigation's StackRouter. Here's what makes it work:
**Smart Dismiss Handling** — When you call `goBack()`, the sheet animates out smoothly before the route is removed. No janky state transitions.
**Resize State Management** — The router tracks resize operations with `resizeIndex` and `resizeKey`, ensuring smooth transitions between detents.
**Focus/Blur Tracking** — When modals are presented on top of sheets, focus events are emitted so you can pause/resume operations accordingly.
## Getting Started
Sheet Navigator is included in `@lodev09/react-native-true-sheet` v3.1.0+. Just import from the navigation subpath:
```tsx
import {
createTrueSheetNavigator,
useTrueSheetNavigation,
} from '@lodev09/react-native-true-sheet/navigation';
```
Check out the [full documentation](/guides/navigation) for more examples and API details.
Running into issues? See the [Troubleshooting guide](/troubleshooting) for common fixes.
## That's All
Have feedback or feature requests? [Open an issue](https://github.com/lodev09/react-native-true-sheet/issues) — I'd love to hear from you!

View File

@ -1,4 +1,9 @@
{
"label": "Guides",
"position": 5
"position": 4,
"link": {
"type": "generated-index",
"description": "Guides on how to use True Native Bottom Sheet on your React Native app.",
"keywords": ["bottom sheet guides", "how to add bottom sheet", "bottom sheet recipes", "bottom sheet getting started", "using native bottom sheet"]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

View File

@ -1,108 +0,0 @@
---
title: Dimming the Background
description: Control the bottom sheet's dimming behavior.
keywords: [bottom sheet dimming, bottom sheet background, inline bottom sheet, maps bottom sheet]
---
import dimming from './assets/dimming.gif'
One of the most common use cases for a Bottom Sheet is to present it while still allowing users to interact with background components, such as in a Maps app.
In this guide, you can configure `TrueSheet` to achieve this exact functionality.
<img alt="dimming" src={dimming} width="300"/>
## How?
You can easily disable the dimmed background of the sheet by setting [`dimmed`](/reference/configuration#dimmed) to `false`.
```tsx {5}
export const App = () => {
return (
<TrueSheet
detents={['auto', 0.69, 1]}
dimmed={false}
>
<View />
</TrueSheet>
)
}
```
### Dimmed by Detent Index
To further customize the dimming behavior, [`dimmedDetentIndex`](/reference/configuration#dimmeddetentindex) is also available. Set the [detent](/reference/configuration#detents) `index` at which you want the sheet to start dimming.
```tsx {5}
export const App = () => {
return (
<TrueSheet
detents={['auto', 0.69, 1]}
dimmedDetentIndex={1} // Dim will start at 69% ✅
>
<View />
</TrueSheet>
)
}
```
:::info
`dimmedDetentIndex` is ignored if `dimmed` is set to `false`.
:::
## Customizing Dimming Alpha
You can dynamically control the dimming opacity based on the sheet's position using `ReanimatedTrueSheet` with `animatedPosition`.
:::tip
Learn more about Reanimated integration in the [Reanimated guide](/guides/reanimated).
:::
```tsx {2-3,7,9-11,14-21}
import { ReanimatedTrueSheet, ReanimatedTrueSheetProvider, useReanimatedTrueSheet } from '@lodev09/react-native-true-sheet/reanimated'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { StyleSheet } from 'react-native'
export const App = () => {
return (
<ReanimatedTrueSheetProvider>
<View style={styles.container}>
<CustomBackdrop />
<Sheet />
</View>
</ReanimatedTrueSheetProvider>
)
}
const CustomBackdrop = () => {
const { animatedPosition } = useReanimatedTrueSheet()
const backdropStyle = useAnimatedStyle(() => ({
opacity: animatedPosition.value * 0.5, // Adjust multiplier for desired alpha
}))
return <Animated.View style={[StyleSheet.absoluteFill, styles.backdrop, backdropStyle]} />
}
const Sheet = () => {
return (
<ReanimatedTrueSheet
detents={[0.25, 0.5, 1]}
dimmed={false}
>
<View />
</ReanimatedTrueSheet>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
backdrop: {
backgroundColor: 'black',
},
})
```
This allows you to create custom dimming effects that respond to the sheet's movement in real-time.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

View File

@ -0,0 +1,51 @@
---
title: Dimming the Background
description: Control the bottom sheet's dimming behavior.
keywords: [bottom sheet dimming, bottom sheet background, inline bottom sheet, maps bottom sheet]
---
import dimming from './dimming.gif'
One of the most common use cases for a Bottom Sheet is to present it while still allowing users to interact with background components, such as in a Maps app.
In this guide, you can configure `TrueSheet` to achieve this exact functionality.
<img alt="dimming" src={dimming} width="300"/>
## How?
You can easily disable the dimmed background of the sheet by setting [`dimmed`](/reference/props#dimmed) to `false`.
```tsx {5}
export const App = () => {
return (
<TrueSheet
sizes={['auto', '69%', 'large']}
dimmed={false}
>
<View />
</TrueSheet>
)
}
```
### Dimmed by Size Index
To further customize the dimming behavior, [`dimmedIndex`](/reference/props#dimmedindex) is also available. Set the [size](/reference/props#sizes) `index` at which you want the sheet to start dimming.
```tsx {5}
export const App = () => {
return (
<TrueSheet
sizes={['auto', '69%', 'large']}
dimmedIndex={1} // Dim will start at 69% ✅
>
<View />
</TrueSheet>
)
}
```
:::info
`dimmedIndex` is ignored if `dimmed` is set to `false`.
:::

View File

@ -1,41 +0,0 @@
---
title: Edge-to-Edge
description: Configure edge-to-edge display mode on Android.
keywords: [bottom sheet edge-to-edge, android edge-to-edge, full screen sheet]
---
TrueSheet automatically detects and adapts to Android's edge-to-edge display mode, providing a modern, immersive experience.
## Enabling Edge-to-Edge
Edge-to-edge is supported in React Native 0.81+ via the `edgeToEdgeEnabled` Gradle property.
Enable it in your `android/gradle.properties`:
```gradle title="android/gradle.properties"
edgeToEdgeEnabled=true
```
:::info
This property is **disabled by default** for current Android versions. TrueSheet will auto-detect when you enable it.
Starting with **Android 16+ (API level 36)**, edge-to-edge will be [automatically enabled by default](https://developer.android.com/about/versions/16/behavior-changes-16#edge-to-edge) by the system.
:::
:::tip
**Learn more:**
- [React Native 0.81 edge-to-edge announcement](https://reactnative.dev/blog/2025/08/12/react-native-0.81)
- [Android 16 edge-to-edge enforcement](https://developer.android.com/about/versions/16/behavior-changes-16#edge-to-edge)
:::
## Default Behavior
By default, when edge-to-edge is enabled, the sheet respects the status bar and stops at the bottom of it when fully expanded. This ensures content remains visible and not obscured by the status bar.
```tsx
<TrueSheet ref={sheet}>
<View />
</TrueSheet>
```
TrueSheet works seamlessly with edge-to-edge enabled and provides automatic status bar detection.

View File

@ -22,12 +22,12 @@ const SomeFooter = () => {
}
```
Stick it in the [`footer`](/reference/configuration#footer).
Stick it in the [`FooterComponent`](/reference/props#footercomponent).
```tsx {3}
const App = () => {
return (
<TrueSheet footer={SomeFooter}>
<TrueSheet FooterComponent={SomeFooter}>
<View />
</TrueSheet>
)
@ -42,7 +42,7 @@ const App = () => {
return (
<TrueSheet
ref={sheet}
footer={
FooterComponent={
<View>
<Text>My Foot-er is more awesome.</Text>
</View>
@ -54,38 +54,3 @@ 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:
```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 (
<View style={{ paddingBottom: bottomInset, backgroundColor: '#333' }}>
<View style={{ height: 60, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: '#fff' }}>Footer Content</Text>
</View>
</View>
);
};
// Usage
<TrueSheet detents={['auto']} footer={<MyFooter />}>
{/* content */}
</TrueSheet>
```
This ensures the footer background extends into the safe area while keeping the content above the home indicator.
:::note
On iPad, the sheet is displayed as a floating modal, so bottom padding is not needed.
:::

View File

@ -10,7 +10,7 @@ To resolve this issue, `TrueSheet` provides [global methods](/reference/methods#
## How?
Somewhere in your App, define the sheet with a [`name`](/reference/configuration#name).
Somewhere in your App, define the sheet with a [`name`](/reference/props#name).
```tsx {3}
const App = () => {

View File

@ -1,71 +0,0 @@
---
title: Adding Header
description: Add a header component that stays above the scrollable content.
keywords: [bottom sheet header, bottom sheet fixed header, bottom sheet sticky header]
---
Need a fixed header above your scrollable content? A title bar, search input, or navigation controls? TrueSheet makes it simple with the `header` prop!
## Using the `header` Prop
The recommended way to add a header is using the `header` prop. This creates a native header view that is properly accounted for in layout calculations, ensuring your scrollable content gets the correct available height.
```tsx {4-8}
const App = () => {
return (
<TrueSheet
ref={sheet}
header={
<View style={styles.header}>
<Text>My Header</Text>
</View>
}
>
<ScrollView nestedScrollEnabled>
<View>{/* Your scrollable content */}</View>
</ScrollView>
</TrueSheet>
)
}
const styles = StyleSheet.create({
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
})
```
## With Search Input
A common use case is adding a search bar in the header:
```tsx {4-8}
const App = () => {
return (
<TrueSheet
ref={sheet}
header={
<View style={styles.header}>
<TextInput placeholder="Search..." style={styles.input} />
</View>
}
>
<FlatList
nestedScrollEnabled
data={items}
renderItem={({ item }) => <ItemRow item={item} />}
/>
</TrueSheet>
)
}
```
:::tip
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.
:::
## Platform Support
The `header` prop is supported on both **iOS** and **Android**.

View File

@ -1,133 +1,11 @@
---
title: Testing with Jest
description: Mock the bottom sheet component for testing.
description: Mocking the bottom sheet component using Jest.
keywords: [bottom sheet jest, testing bottom sheet, mocking bottom sheet]
---
Testing components that use `TrueSheet` is straightforward with the built-in Jest mocks.
## Setup
Add the mocks to your Jest setup file:
```js
// jest.setup.js
// Main component
jest.mock('@lodev09/react-native-true-sheet', () =>
require('@lodev09/react-native-true-sheet/mock')
);
// Navigation (if using)
jest.mock('@lodev09/react-native-true-sheet/navigation', () =>
require('@lodev09/react-native-true-sheet/navigation/mock')
);
// Reanimated (if using)
jest.mock('@lodev09/react-native-true-sheet/reanimated', () =>
require('@lodev09/react-native-true-sheet/reanimated/mock')
);
```
Configure Jest to use the setup file in your `package.json`:
```json
{
"jest": {
"setupFilesAfterEnv": ["<rootDir>/jest.setup.js"]
}
}
```
## Available Mocks
### Main Module (`/mock`)
- `TrueSheet` - Component with mocked `present`, `dismiss`, `resize` methods
- `TrueSheetProvider` - Pass-through provider
- `useTrueSheet` - Hook returning mocked methods
### Navigation Module (`/navigation/mock`)
- `createTrueSheetNavigator` - Mocked navigator factory
- `TrueSheetActions` - Mocked action creators
- `useTrueSheetNavigation` - Hook returning mocked navigation object
### Reanimated Module (`/reanimated/mock`)
- `ReanimatedTrueSheet` - Component with mocked methods
- `ReanimatedTrueSheetProvider` - Pass-through provider
- `useReanimatedTrueSheet` - Hook returning mocked shared values
- `useReanimatedPositionChangeHandler` - Mocked handler hook
## Testing Static Methods
All static methods are mocked as Jest functions.
When using `jest`, simply mock the entire package.
```tsx
import { TrueSheet } from '@lodev09/react-native-true-sheet';
it('should present sheet', async () => {
await TrueSheet.present('my-sheet', 0);
expect(TrueSheet.present).toHaveBeenCalledWith('my-sheet', 0);
});
it('should dismiss sheet', async () => {
await TrueSheet.dismiss('my-sheet');
expect(TrueSheet.dismiss).toHaveBeenCalledWith('my-sheet');
});
jest.mock('@lodev09/react-native-true-sheet')
```
## Testing Component Rendering
The mock renders `TrueSheet` as a View with all props passed through.
```tsx
it('should render sheet content', () => {
const { getByText } = render(
<TrueSheet name="test" initialDetentIndex={0}>
<Text>Sheet Content</Text>
</TrueSheet>
);
expect(getByText('Sheet Content')).toBeDefined();
});
```
## Testing Reanimated Integration
```tsx
import {
useReanimatedTrueSheet,
ReanimatedTrueSheetProvider,
} from '@lodev09/react-native-true-sheet/reanimated';
it('should return mocked shared values', () => {
const { result } = renderHook(() => useReanimatedTrueSheet(), {
wrapper: ReanimatedTrueSheetProvider,
});
expect(result.current.animatedPosition.value).toBe(0);
expect(result.current.animatedIndex.value).toBe(-1);
});
```
## Best Practices
Clear mock calls between tests to avoid interference.
```tsx
describe('MyComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// Your tests...
});
```
:::tip
All methods return resolved Promises, so remember to `await` them in your tests.
:::

View File

@ -1,57 +0,0 @@
---
title: Keyboard Handling
description: How TrueSheet handles keyboard visibility.
keywords: [bottom sheet keyboard, bottom sheet input, bottom sheet text input]
---
import keyboard from './assets/keyboard.gif'
TrueSheet handles keyboard visibility natively on both iOS and Android. When a `TextInput` inside the sheet is focused, the sheet automatically adjusts to keep the input visible above the keyboard.
<img alt="keyboard" src={keyboard} width="300"/>
## How?
The keyboard handling is built into the native implementation:
- **iOS**: Uses `UISheetPresentationController`'s built-in keyboard avoidance
- **Android**: Tracks keyboard height via `WindowInsetsAnimationCompat` and reconfigures sheet detents in real-time
No additional configuration is required. Simply add your `TextInput` components inside the sheet:
```tsx
const App = () => {
return (
<TrueSheet ref={sheet} detents={['auto']}>
<View style={{ padding: 16 }}>
<TextInput
placeholder="Type something..."
style={{ borderWidth: 1, borderColor: '#ccc', padding: 12, borderRadius: 8 }}
/>
</View>
</TrueSheet>
)
}
```
## Footer Behavior
When using a [`footer`](/reference/configuration#footer) component, it automatically repositions above the keyboard, staying visible while the user types.
## Autofocus Limitation
Avoid using `autoFocus` on `TextInput` components inside the sheet. The keyboard may appear before the sheet has finished presenting, causing layout issues.
Instead, focus the input programmatically after the sheet has presented:
```tsx {4,7}
const inputRef = useRef<TextInput>(null)
const handlePresent = () => {
inputRef.current?.focus()
}
<TrueSheet onDidPresent={handlePresent}>
<TextInput ref={inputRef} placeholder="Type something..." />
</TrueSheet>
```

View File

@ -1,97 +0,0 @@
---
title: Liquid Glass
description: Configure iOS Liquid Glass visual effect for bottom sheets.
keywords: [bottom sheet liquid glass, ios liquid glass, sheet visual effect, ios 26]
---
import glass from '/img/preview-ios.gif'
Starting with iOS 26, Apple introduced the **Liquid Glass** visual effect, a new design element that creates a frosted glass appearance on sheets and modals.
TrueSheet **supports Liquid Glass by default** on iOS 26+, giving your sheets a modern, native look.
<img alt="liquid-glass" src={glass} width="300"/>
## What is Liquid Glass?
Liquid Glass is a visual effect introduced in iOS 26 that provides a translucent, frosted glass appearance with a subtle blur. It's part of Apple's latest design language and is automatically applied to native sheet presentations.
By default, TrueSheet enables Liquid Glass on iOS 26+ devices when no [`backgroundColor`](/reference/configuration#backgroundcolor) or [`backgroundBlur`](/reference/configuration#backgroundblur) is provided. The sheet will automatically display with the Liquid Glass effect.
## Disabling Liquid Glass
If you prefer the classic sheet appearance without Liquid Glass, there are two options:
### Using `backgroundColor` or `backgroundBlur`
Setting [`backgroundColor`](/reference/configuration#backgroundcolor) or [`backgroundBlur`](/reference/configuration#backgroundblur) (or both) on the sheet will disable the Liquid Glass effect for that specific sheet.
```tsx
<TrueSheet backgroundColor="#ffffff">
{/* Sheet content */}
</TrueSheet>
```
```tsx
<TrueSheet backgroundBlur="system-material">
{/* Sheet content */}
</TrueSheet>
```
This approach allows you to disable Liquid Glass on a per-sheet basis while keeping it enabled for other sheets in your app.
:::note
This only works on iOS 26.1 and above.
:::
### Using `UIDesignRequiresCompatibility`
Set `UIDesignRequiresCompatibility` to `true` in your `Info.plist` to disable Liquid Glass.
#### Using Info.plist
Add the following key to your `ios/YourApp/Info.plist`:
```xml title="ios/YourApp/Info.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Other keys... -->
<key>UIDesignRequiresCompatibility</key>
<true/>
</dict>
</plist>
```
#### Using Expo Config Plugin
If you're using Expo, you can configure this through your `app.json` or `app.config.js`:
```js title="app.config.js"
export default {
expo: {
ios: {
infoPlist: {
UIDesignRequiresCompatibility: true,
},
},
},
}
```
After making this change, rebuild your app:
```sh
npx expo prebuild --clean
npx expo run:ios
```
:::note
This setting disables the Liquid Glass UI across your entire app, not just for sheets. See [Apple's documentation](https://developer.apple.com/documentation/BundleResources/Information-Property-List/UIDesignRequiresCompatibility) for more details.
:::
## Learn More
- [How to create Apple Maps style liquid glass sheets in Expo](https://expo.dev/blog/how-to-create-apple-maps-style-liquid-glass-sheets) - Expo Blog
- [Apple's Liquid Glass Documentation](https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass)

View File

@ -1,256 +0,0 @@
---
title: React Navigation
description: Navigate to other screens from within the bottom sheet.
keywords: [bottom sheet navigation, react-navigation, navigating from sheet, sheet navigator]
---
import navigation from './assets/navigation.gif'
TrueSheet integrates with React Navigation out of the box. It just works!
<img alt="navigation" src={navigation} width="300"/>
## How?
You can use the [Sheet Navigator](#sheet-navigator) to present screens as sheets, or simply [navigate from within sheets](#navigating-from-sheets) using your existing navigation setup.
## 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.
```bash
npm install @react-navigation/native
```
### Basic Usage
```tsx
import { NavigationContainer } from '@react-navigation/native';
import {
createTrueSheetNavigator,
useTrueSheetNavigation,
} from '@lodev09/react-native-true-sheet/navigation';
const Sheet = createTrueSheetNavigator();
function App() {
return (
<NavigationContainer>
<Sheet.Navigator>
{/* Base screen (first screen is the default) */}
<Sheet.Screen name="Main" component={MainScreen} />
{/* Sheet screens */}
<Sheet.Screen
name="Details"
component={DetailsSheet}
options={{ detents: ['auto', 1], cornerRadius: 16 }}
/>
</Sheet.Navigator>
</NavigationContainer>
);
}
```
### Wrapping Existing Navigation
Wrap your root navigator to present sheets from anywhere:
```tsx
const Stack = createNativeStackNavigator();
const Sheet = createTrueSheetNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Navigator>
);
}
function App() {
return (
<NavigationContainer>
<Sheet.Navigator>
<Sheet.Screen name="Root" component={RootStack} />
<Sheet.Screen
name="Details"
component={DetailsSheet}
options={{ detents: ['auto', 1], cornerRadius: 16 }}
/>
</Sheet.Navigator>
</NavigationContainer>
);
}
```
### Navigation & Resizing
```tsx
function DetailsSheet() {
const navigation = useTrueSheetNavigation();
return (
<View>
<Button title="Expand" onPress={() => navigation.resize(1)} />
<Button title="Close" onPress={() => navigation.goBack()} />
</View>
);
}
```
### Screen Options
All [TrueSheet props](/reference/configuration) are available as screen options, plus the following navigation-specific options:
| Option | Type | Description |
|--------|------|-------------|
| `detentIndex` | `number` | The detent index to present at. Defaults to `0`. |
| `reanimated` | `boolean` | Enable worklet-based position events for this screen. |
| `positionChangeHandler` | `function` | A callback that receives position change events. When `reanimated` is enabled, this must be a worklet function. |
### Reanimated Integration
Enable worklet-based position events for smooth UI thread animations:
```tsx
// In your navigator
<Sheet.Screen
name="Details"
component={DetailsSheet}
options={{
reanimated: true,
positionChangeHandler: (payload) => {
'worklet';
// Access payload.position, payload.detentIndex, etc.
console.log(payload.position);
},
}}
/>
```
:::note
When `reanimated: true` is set, `react-native-reanimated` must be installed and `positionChangeHandler` must be a worklet function. The integration is lazy-loaded, so screens without `reanimated: true` don't require reanimated.
:::
### Screen Listeners
```tsx
<Sheet.Navigator
screenListeners={{
sheetDidPresent: (e) => console.log('Presented:', e.data.index),
sheetDidDismiss: () => console.log('Dismissed'),
}}
>
```
| Event | Description |
|-------|-------------|
| `sheetWillPresent` | Sheet is about to present |
| `sheetDidPresent` | Sheet finished presenting |
| `sheetWillDismiss` | Sheet is about to dismiss |
| `sheetDidDismiss` | Sheet finished dismissing |
| `sheetDetentChange` | Detent changed |
| `sheetDragBegin` | User started dragging |
| `sheetDragChange` | User is dragging |
| `sheetDragEnd` | User stopped dragging |
| `sheetPositionChange` | Position changed |
### Expo Router
```
app/
├── _layout.tsx # TrueSheet navigator
├── index.tsx # Base content
└── details.tsx # Sheet screen
```
```tsx
// app/_layout.tsx
import { withLayoutContext } from 'expo-router';
import {
createTrueSheetNavigator,
type TrueSheetNavigationEventMap,
type TrueSheetNavigationOptions,
type TrueSheetNavigationState,
} from '@lodev09/react-native-true-sheet/navigation';
import type { ParamListBase } from '@react-navigation/native';
const { Navigator } = createTrueSheetNavigator();
const Sheet = withLayoutContext<
TrueSheetNavigationOptions,
typeof Navigator,
TrueSheetNavigationState<ParamListBase>,
TrueSheetNavigationEventMap
>(Navigator);
export default function SheetLayout() {
return (
<Sheet>
<Sheet.Screen name="index" />
<Sheet.Screen
name="details"
options={{
detents: ['auto', 1],
cornerRadius: 16,
}}
/>
</Sheet>
);
}
```
## Navigating from Sheets
:::note
Requires a [patch to react-native-screens](https://github.com/lodev09/react-native-true-sheet/blob/main/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch). See [PR #3415](https://github.com/software-mansion/react-native-screens/pull/3415).
:::
Navigate directly from sheets - they remain visible when presenting modals on top.
```tsx
// Navigate directly - no need to dismiss first!
navigation.navigate('SomeScreen')
```
### Presenting on Screen Focus
When using `useFocusEffect`, delay presentation to avoid iOS issues:
```tsx
useFocusEffect(
useCallback(() => {
requestAnimationFrame(() => {
sheet.current?.present()
})
}, [])
)
```
### Present During Mount
When using `initialDetentIndex` with animation, the sheet may behave unexpectedly during screen transitions.
**Solution 1:** Disable animation
```tsx
<TrueSheet initialDetentIndex={0} initialDetentAnimated={false}>
```
**Solution 2:** Wait for transition
```tsx
const [ready, setReady] = useState(false)
useEffect(() => {
const unsubscribe = navigation.addListener("transitionEnd", ({ data }) => {
if (!data.closing) setReady(true)
})
return unsubscribe
}, [])
if (!ready) return null
return <TrueSheet initialDetentIndex={0}>{/* ... */}</TrueSheet>
```

View File

@ -1,37 +0,0 @@
---
title: Present On Mount
description: Present the bottom sheet on mount.
keywords: [bottom sheet on mount, bottom sheet initialIndex]
---
import onmount from './assets/onmount.gif'
Sometimes, you may want to present the sheet directly during mount. For example, you might want to present the sheet when a screen is opened through a deep link.
<img alt="onmount" src={onmount} width="300"/>
## How?
You can do this by setting [`initialDetentIndex`](/reference/configuration#initialdetentindex) prop. It accepts the [`detent`](/reference/types#sheetdetent) `index` that your sheet is configured with. See [detents](/reference/configuration#detents) prop for more information.
```tsx {5-6}
const App = () => {
return (
<TrueSheet
detents={['auto', 0.69, 1]}
initialDetentIndex={1}
initialDetentAnimated
>
<View />
</TrueSheet>
)
}
```
### Disabling Animation
You may want to disable the present animation. To do this, simply set [`initialDetentAnimated`](/reference/configuration#initialdetentanimated) to `false`.
### Using with React Navigation
Using this with [`react-navigation`](https://reactnavigation.org) can cause render issue. Check out the [reat-navigation guide](/guides/navigation#present-during-mount) for the fix.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -0,0 +1,37 @@
---
title: Presenting During Mount
description: Present the bottom sheet on mount.
keywords: [bottom sheet on mount, bottom sheet initialIndex]
---
import onmount from './onmount.gif'
Sometimes, you may want to present the sheet directly during mount. For example, you might want to present the sheet when a screen is opened through a deep link.
<img alt="onmount" src={onmount} width="300"/>
## How?
You can do this by setting [`initialIndex`](/reference/props#initialindex) prop. It accepts the [`size`](/reference/types#sheetsize) `index` that your sheet is configured with. See [sizes](/reference/props#sizes) prop for more information.
```tsx {5-6}
const App = () => {
return (
<TrueSheet
sizes={['auto', '69%', 'large']}
initialIndex={1}
initialindexanimated
>
<View />
</TrueSheet>
)
}
```
### Disabling Animation
You may want to disable the present animation. To do this, simply set [`initialIndexAnimated`](/reference/props#initialindexanimated) to `false`.
### Using with React Navigation
Using this with [`react-navigation`](https://reactnavigation.org) can cause render issue. Check out the [troubleshooting guide](/troubleshooting#present-during-mount) for the fix 😉.

View File

@ -1,102 +0,0 @@
---
title: Reanimated
description: Sync animations with your sheet using Reanimated.
keywords: [bottom sheet, react-native-reanimated, reanimated, animations]
---
import reanimated from './assets/reanimated.gif'
`TrueSheet` has first-class support for [react-native-reanimated v4](https://docs.swmansion.com/react-native-reanimated/).
<img alt="reanimated" src={reanimated} width="300"/>
:::info Requirements
- `react-native-reanimated`: ^4.0.0
- `react-native-worklets` (peer dependency of Reanimated v4)
:::
## How?
### 1. Add the Provider
Manages shared values for Reanimated integration.
```tsx
import { ReanimatedTrueSheetProvider } from '@lodev09/react-native-true-sheet/reanimated'
function App() {
return (
<ReanimatedTrueSheetProvider>
<YourApp />
</ReanimatedTrueSheetProvider>
)
}
```
### 2. Use ReanimatedTrueSheet
Animated sheet component that syncs position automatically with all props from [`TrueSheet`](/reference/configuration).
```tsx
import { type TrueSheet } from '@lodev09/react-native-true-sheet'
import { ReanimatedTrueSheet } from '@lodev09/react-native-true-sheet/reanimated'
function MyScreen() {
const sheetRef = useRef<TrueSheet>(null)
return (
<ReanimatedTrueSheet
ref={sheetRef}
detents={[0.3, 0.6, 1]}
initialDetentIndex={1}
>
<Text>Sheet Content</Text>
</ReanimatedTrueSheet>
)
}
```
:::info
Note that the `onPositionChange` prop event now runs on the UI thread (worklet). If you override this prop, make sure to add the `'worklet'` directive to your handler.
:::
### 3. Access Animated Values
Use the `useReanimatedTrueSheet` hook to access the sheet's animated values.
```tsx
import { useReanimatedTrueSheet } from '@lodev09/react-native-true-sheet/reanimated'
import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated'
function MyComponent() {
const { animatedPosition, animatedIndex, animatedDetent } = useReanimatedTrueSheet()
// Example: Move component based on sheet position
const positionStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -animatedPosition.value }]
}))
// Example: Fade in as sheet expands from index 0 to 1
const opacityStyle = useAnimatedStyle(() => ({
opacity: interpolate(animatedIndex.value, [0, 1], [0, 1], Extrapolation.CLAMP)
}))
return (
<Animated.View style={[positionStyle, opacityStyle]}>
<Text>This moves and fades with the sheet</Text>
</Animated.View>
)
}
```
#### Available Values
| Value | Type | Description |
| - | - | - |
| `animatedPosition` | `SharedValue<number>` | The current Y position of the sheet relative to the screen. |
| `animatedIndex` | `SharedValue<number>` | The current detent index as a continuous float. Interpolates smoothly between detent indices during drag (e.g., `0.5` when halfway between index 0 and 1). |
| `animatedDetent` | `SharedValue<number>` | The current detent value (0-1 fraction of screen height). Interpolates smoothly between detent values as the sheet is dragged. |
## Examples
See the [example app](https://github.com/lodev09/react-native-true-sheet/tree/main/example) for complete implementations.

View File

@ -1,98 +0,0 @@
---
title: Resizing Programmatically
description: Programmatically resize the bottom sheet and listen for detent changes.
keywords: [bottom sheet resizing, bottom sheet detents, bottom sheet auto resizing]
---
import resizing from './assets/resizing.gif'
`TrueSheet` has a main prop called [`detents`](/reference/configuration#detents) which allows you to define the detents that the sheet can support. This is an array of [`SheetDetent`](/reference/types#sheetdetent) that supports values like `"auto"` or fractional numbers (0-1).
In some cases, you may want to resize the sheet programmatically for a better experience.
<img alt="resizing" src={resizing} width="300"/>
## How?
### Resize Programmatically
Define the sheet and use the [`resize`](/reference/methods#resize) method.
```tsx {2-2,6-6,11-13}
const App = () => {
const sheet = useRef<TrueSheet>(null)
const resize = async () => {
// Resize to 69%
await sheet.current?.resize(1)
console.log('Yay, we are now at 69% 💦')
}
return (
<TrueSheet name="resizing-sheet" ref={sheet} detents={['auto', 0.69, 1]}>
<Button onPress={resize} title="Resize" />
</TrueSheet>
)
}
```
:::tip
You can also do it globally using the related [global method](/reference/methods#global-methods).
```tsx
TrueSheet.resize('resizing-sheet', 1)
```
:::
:::info
`detents` can only support up to 3 detents. **_collapsed_**, **_half-expanded_**, and **_expanded_**.
:::
:::info
Use [`insetAdjustment="never"`](/reference/configuration#insetadjustment) to disable automatic bottom inset adjustment on both platforms.
:::
:::tip
If you want to disable user dragging and only allow programmatic resizing, set [`draggable={false}`](/reference/configuration#draggable).
:::
### Listening to Detent Change
If you want to get the active detent information, you can listen to detent changes by providing the [`onDetentChange`](/reference/events#ondetentchange) event.
The event comes with the [`DetentInfoEventPayload`](/reference/types#detentinfoeventpayload) that provides the detent `index` and `position` (Y position on screen).
:::tip
Use the `index` to reference the detent from your `detents` array. For example, if `detents={['auto', 0.69, 1]}` and `index` is `1`, the active detent is `0.69`.
:::
```tsx {9-11,17-17}
const App = () => {
const sheet = useRef<TrueSheet>(null)
const resize = async () => {
// Resize to 69%
await sheet.current?.resize(1)
}
const handleDetentChange = (e: DetentChangeEvent) => {
const { index, position } = e.nativeEvent
console.log('Detent index:', index, 'position:', position) ✅
}
return (
<TrueSheet
ref={sheet}
detents={['auto', 0.69, 1]}
onDetentChange={handleDetentChange}
>
<Button onPress={resize} title="Resize" />
</TrueSheet>
)
}
```
:::info
The event will also trigger when the user drags the sheet into a detent.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Some files were not shown because too many files have changed in this diff Show More