Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c0ff31e6 | ||
|
|
7642126df6 | ||
|
|
924f6b22b8 |
@ -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
@ -0,0 +1,5 @@
|
||||
lib
|
||||
build
|
||||
src/__mocks__
|
||||
docs/build
|
||||
docs/.docusaurus
|
||||
89
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -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.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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.
|
||||
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -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)
|
||||
19
.github/actions/setup/action.yml
vendored
@ -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 }}
|
||||
|
||||
135
.github/workflows/build.yml
vendored
@ -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 }}"
|
||||
133
.github/workflows/check-repro.yml
vendored
@ -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']
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
41
.github/workflows/stale.yml
vendored
@ -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
@ -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
|
||||
|
||||
@ -1,4 +1 @@
|
||||
lib
|
||||
build
|
||||
docs/build
|
||||
docs/.docusaurus
|
||||
.eslintignore
|
||||
|
||||
@ -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
|
||||
942
.yarn/releases/yarn-4.11.0.cjs
vendored
@ -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"
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"file_scan_exclusions": [
|
||||
"**/.git",
|
||||
"**/node_modules",
|
||||
"**/.DS_Store"
|
||||
]
|
||||
}
|
||||
150
AGENTS.md
@ -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.
|
||||
@ -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
|
||||
|
||||
|
||||
2
LICENSE
@ -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
|
||||
|
||||
80
README.md
@ -1,59 +1,43 @@
|
||||
# React Native True Sheet
|
||||
|
||||
[](https://github.com/lodev09/react-native-true-sheet/actions/workflows/checks.yml)
|
||||
[](https://github.com/lodev09/react-native-true-sheet/actions/workflows/ci.yml)
|
||||
[](https://codeclimate.com/github/lodev09/react-native-true-sheet/maintainability)
|
||||
[](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.
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
330
android/src/main/java/com/lodev09/truesheet/TrueSheetDialog.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
63
android/src/main/java/com/lodev09/truesheet/core/Utils.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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 ¶ms);
|
||||
|
||||
} // namespace react
|
||||
} // namespace facebook
|
||||
@ -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'],
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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. 😎
|
||||
|
||||
|
||||
@ -7,5 +7,3 @@ tags: [bottom-sheet, true-sheet, native-sheet]
|
||||
---
|
||||
|
||||
## Hello World!
|
||||
|
||||
{/* truncate */}
|
||||
|
||||
@ -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)!
|
||||
|
Before Width: | Height: | Size: 5.9 MiB |
@ -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!
|
||||
@ -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!
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 5.1 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 5.7 MiB |
@ -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.
|
||||
BIN
docs/docs/guides/dimming/dimming.gif
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
51
docs/docs/guides/dimming/dimming.mdx
Normal 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`.
|
||||
:::
|
||||
@ -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.
|
||||
@ -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.
|
||||
:::
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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**.
|
||||
@ -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.
|
||||
:::
|
||||
|
||||
@ -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>
|
||||
```
|
||||
@ -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)
|
||||
@ -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>
|
||||
```
|
||||
@ -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.
|
||||
BIN
docs/docs/guides/onmount/onmount.gif
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
37
docs/docs/guides/onmount/onmount.mdx
Normal 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 😉.
|
||||
@ -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.
|
||||
@ -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.
|
||||
:::
|
||||
BIN
docs/docs/guides/resizing/resizing.gif
Normal file
|
After Width: | Height: | Size: 4.0 MiB |