From 6258f41540a9e5126c9d6af001915fe90afc9be2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 1 Oct 2026 16:05:42 +0800 Subject: [PATCH 1/6] feat: publish native overlay hosts as an independent package Migrate the verified Fabric Android, iOS, C++ and Web implementation from app-monorepo with a documented Nitro-template exception and standalone build, type, test and packaging checks. Preserve the native-sheet compatibility API. Align 42 release workspaces to 3.0.162-alpha.268 for branch CI publication on next. Extend dependency synchronization to kit/playground and the temporary overlay workspace reference; keep stable tags and PR merges unchanged. Validation: 39 overlay tests, 36 release-tool tests, typecheck, lint, test integrity, immutable install, JS/declaration/tarball build, full Android arm64-v8a example build, and CocoaPods install passed. iOS runtime remains unverified on this host. --- .github/workflows/native-tests.yml | 35 +- .github/workflows/package-publish.yml | 14 +- README.md | 2 +- example/react-native/ios/Podfile.lock | 210 +++--- example/react-native/package.json | 1 + native-modules/native-logger/package.json | 2 +- .../react-native-aes-crypto/package.json | 2 +- .../react-native-app-update/package.json | 2 +- .../react-native-async-storage/package.json | 2 +- .../package.json | 2 +- .../react-native-bundle-crypto/package.json | 2 +- .../react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- .../react-native-cloud-fs/package.json | 2 +- .../package.json | 2 +- .../react-native-device-utils/package.json | 2 +- .../react-native-dns-lookup/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../react-native-keychain-module/package.json | 2 +- .../react-native-lite-card/package.json | 2 +- .../react-native-network-info/package.json | 2 +- .../package.json | 2 +- .../react-native-pbkdf2/package.json | 2 +- .../react-native-perf-memory/package.json | 2 +- .../react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- .../package.json | 2 +- .../react-native-sni-connect/package.json | 2 +- .../react-native-splash-screen/package.json | 2 +- .../package.json | 2 +- .../react-native-tcp-socket/package.json | 2 +- .../react-native-zip-archive/package.json | 2 +- .../react-native-auto-size-input/package.json | 2 +- .../react-native-chart-webview/package.json | 2 +- native-views/react-native-image/package.json | 4 +- .../react-native-native-list/package.json | 6 +- .../react-native-native-overlay/.gitignore | 86 +++ .../react-native-native-overlay/LICENSE | 21 + .../NativeOverlay.podspec | 30 + .../react-native-native-overlay/README.md | 35 + .../android/build.gradle | 82 +++ .../android/src/main/AndroidManifest.xml | 2 + .../nativeoverlay/NativeOverlayAnimation.kt | 176 +++++ .../NativeOverlayEntryRootView.kt | 415 +++++++++++ .../nativeoverlay/NativeOverlayEvents.kt | 38 + .../onekey/nativeoverlay/NativeOverlayHost.kt | 174 +++++ .../nativeoverlay/NativeOverlayPackage.kt | 16 + .../NativeOverlayPageHostView.kt | 122 +++ .../NativeOverlayPageHostViewManager.kt | 40 + .../onekey/nativeoverlay/NativeOverlayView.kt | 465 ++++++++++++ .../nativeoverlay/NativeOverlayViewManager.kt | 156 ++++ .../android/src/main/jni/CMakeLists.txt | 44 ++ .../android/src/main/jni/RNCNativeOverlay.h | 19 + .../babel.config.js | 1 + .../NativeOverlayComponentDescriptor.h | 12 + .../NativeOverlayShadowNode.cpp | 12 + .../NativeOverlayShadowNode.h | 41 ++ .../RNCNativeOverlay/NativeOverlayState.h | 40 + .../react-native-native-overlay/docs/SPEC.md | 693 ++++++++++++++++++ .../eslint.config.mjs | 13 + .../ios/NativeOverlayAnimation.swift | 160 ++++ .../ios/NativeOverlayContainerView.swift | 688 +++++++++++++++++ .../ios/NativeOverlayKeyboard.swift | 88 +++ .../ios/NativeOverlayPageHostView.swift | 98 +++ .../ios/NativeOverlaySheetController.swift | 391 ++++++++++ .../ios/NativeOverlayWindowManager.swift | 194 +++++ .../ios/RCTNativeOverlayComponentView.h | 11 + .../ios/RCTNativeOverlayComponentView.mm | 193 +++++ .../RCTNativeOverlayPageHostComponentView.h | 11 + .../RCTNativeOverlayPageHostComponentView.mm | 64 ++ .../react-native-native-overlay/lefthook.yml | 14 + .../react-native-native-overlay/package.json | 188 +++++ .../react-native.config.js | 16 + .../src/NativeOverlayNativeComponent.ts | 61 ++ .../NativeOverlayPageHostNativeComponent.ts | 13 + .../src/OverlayErrors.ts | 3 + .../src/OverlayLevelContext.tsx | 32 + .../src/OverlayLevels.ts | 40 + .../src/OverlayPageHost.tsx | 21 + .../src/OverlayPageHost.web.tsx | 53 ++ .../src/OverlayPageScope.tsx | 42 ++ .../src/OverlayStore.ts | 401 ++++++++++ .../src/OverlayView.tsx | 165 +++++ .../src/OverlayView.web.tsx | 356 +++++++++ .../src/OverlayViewTypes.ts | 81 ++ .../src/__tests__/OverlayStore.test.ts | 298 ++++++++ .../src/__tests__/animation.test.ts | 121 +++ .../src/__tests__/keyboardShift.test.ts | 71 ++ .../src/animation/motionPresets.ts | 52 ++ .../src/animation/platformMotionPresets.ts | 1 + .../animation/platformMotionPresets.web.ts | 1 + .../src/animation/resolveAnimation.ts | 103 +++ .../src/animation/spring.ts | 80 ++ .../src/animation/types.ts | 71 ++ .../react-native-native-overlay/src/index.ts | 55 ++ .../react-native-native-overlay/src/types.ts | 79 ++ .../src/useOverlayController.ts | 199 +++++ .../src/useSuspendedPageOwners.ts | 26 + .../src/web/animateTransition.ts | 137 ++++ .../src/web/overlayLayers.ts | 182 +++++ .../src/web/useKeyboardShift.ts | 130 ++++ .../src/web/useSheetDrag.ts | 143 ++++ .../tsconfig.build.json | 6 + .../react-native-native-overlay/tsconfig.json | 41 ++ .../react-native-native-overlay/turbo.json | 23 + .../react-native-native-sheet/package.json | 2 +- .../react-native-pager-view/package.json | 4 +- .../react-native-perp-depth-bar/package.json | 2 +- .../react-native-scroll-guard/package.json | 2 +- .../react-native-segment-slider/package.json | 2 +- .../react-native-skeleton/package.json | 2 +- .../react-native-tab-view/package.json | 2 +- .../react-native-text-input/package.json | 2 +- native-views/react-native-text/package.json | 2 +- scripts/sync-app-monorepo.mjs | 47 +- scripts/sync-app-monorepo.test.mjs | 69 +- yarn.lock | 57 +- 118 files changed, 8291 insertions(+), 170 deletions(-) create mode 100644 native-views/react-native-native-overlay/.gitignore create mode 100644 native-views/react-native-native-overlay/LICENSE create mode 100644 native-views/react-native-native-overlay/NativeOverlay.podspec create mode 100644 native-views/react-native-native-overlay/README.md create mode 100644 native-views/react-native-native-overlay/android/build.gradle create mode 100644 native-views/react-native-native-overlay/android/src/main/AndroidManifest.xml create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAnimation.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEntryRootView.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEvents.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPackage.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostViewManager.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayViewManager.kt create mode 100644 native-views/react-native-native-overlay/android/src/main/jni/CMakeLists.txt create mode 100644 native-views/react-native-native-overlay/android/src/main/jni/RNCNativeOverlay.h create mode 100644 native-views/react-native-native-overlay/babel.config.js create mode 100644 native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayComponentDescriptor.h create mode 100644 native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.cpp create mode 100644 native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.h create mode 100644 native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayState.h create mode 100644 native-views/react-native-native-overlay/docs/SPEC.md create mode 100644 native-views/react-native-native-overlay/eslint.config.mjs create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlayAnimation.swift create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlayContainerView.swift create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlayKeyboard.swift create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlayPageHostView.swift create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlaySheetController.swift create mode 100644 native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift create mode 100644 native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.h create mode 100644 native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.mm create mode 100644 native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.h create mode 100644 native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.mm create mode 100644 native-views/react-native-native-overlay/lefthook.yml create mode 100644 native-views/react-native-native-overlay/package.json create mode 100644 native-views/react-native-native-overlay/react-native.config.js create mode 100644 native-views/react-native-native-overlay/src/NativeOverlayNativeComponent.ts create mode 100644 native-views/react-native-native-overlay/src/NativeOverlayPageHostNativeComponent.ts create mode 100644 native-views/react-native-native-overlay/src/OverlayErrors.ts create mode 100644 native-views/react-native-native-overlay/src/OverlayLevelContext.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayLevels.ts create mode 100644 native-views/react-native-native-overlay/src/OverlayPageHost.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayPageScope.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayStore.ts create mode 100644 native-views/react-native-native-overlay/src/OverlayView.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayView.web.tsx create mode 100644 native-views/react-native-native-overlay/src/OverlayViewTypes.ts create mode 100644 native-views/react-native-native-overlay/src/__tests__/OverlayStore.test.ts create mode 100644 native-views/react-native-native-overlay/src/__tests__/animation.test.ts create mode 100644 native-views/react-native-native-overlay/src/__tests__/keyboardShift.test.ts create mode 100644 native-views/react-native-native-overlay/src/animation/motionPresets.ts create mode 100644 native-views/react-native-native-overlay/src/animation/platformMotionPresets.ts create mode 100644 native-views/react-native-native-overlay/src/animation/platformMotionPresets.web.ts create mode 100644 native-views/react-native-native-overlay/src/animation/resolveAnimation.ts create mode 100644 native-views/react-native-native-overlay/src/animation/spring.ts create mode 100644 native-views/react-native-native-overlay/src/animation/types.ts create mode 100644 native-views/react-native-native-overlay/src/index.ts create mode 100644 native-views/react-native-native-overlay/src/types.ts create mode 100644 native-views/react-native-native-overlay/src/useOverlayController.ts create mode 100644 native-views/react-native-native-overlay/src/useSuspendedPageOwners.ts create mode 100644 native-views/react-native-native-overlay/src/web/animateTransition.ts create mode 100644 native-views/react-native-native-overlay/src/web/overlayLayers.ts create mode 100644 native-views/react-native-native-overlay/src/web/useKeyboardShift.ts create mode 100644 native-views/react-native-native-overlay/src/web/useSheetDrag.ts create mode 100644 native-views/react-native-native-overlay/tsconfig.build.json create mode 100644 native-views/react-native-native-overlay/tsconfig.json create mode 100644 native-views/react-native-native-overlay/turbo.json diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index ad76f77ce..24ce873d7 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -13,6 +13,31 @@ concurrency: cancel-in-progress: true jobs: + native-overlay-package: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24.x" + - name: Validate native overlay package + run: | + corepack enable + yarn install --immutable + yarn workspace @onekeyfe/react-native-native-overlay typecheck + yarn workspace @onekeyfe/react-native-native-overlay lint + yarn workspace @onekeyfe/react-native-native-overlay test --runInBand + yarn workspace @onekeyfe/react-native-native-overlay prepare + yarn workspace @onekeyfe/react-native-native-overlay pack --out "$RUNNER_TEMP/native-overlay.tgz" + mkdir "$RUNNER_TEMP/native-overlay" + tar -xzf "$RUNNER_TEMP/native-overlay.tgz" -C "$RUNNER_TEMP/native-overlay" + test -f "$RUNNER_TEMP/native-overlay/package/lib/module/index.js" + test -f "$RUNNER_TEMP/native-overlay/package/lib/typescript/src/index.d.ts" + test -f "$RUNNER_TEMP/native-overlay/package/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.cpp" + test -f "$RUNNER_TEMP/native-overlay/package/android/src/main/jni/CMakeLists.txt" + test -f "$RUNNER_TEMP/native-overlay/package/ios/RCTNativeOverlayComponentView.mm" + android-unit: runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -20,11 +45,11 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version: "24.x" - uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17' + java-version: "17" - uses: gradle/actions/setup-gradle@v4 - name: Install and generate native bindings run: | @@ -93,7 +118,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17' + java-version: "17" - name: Install verified Kotlin 2.3.21 compiler run: | curl -fsSLo kotlin-compiler.zip https://github.com/JetBrains/kotlin/releases/download/v2.3.21/kotlin-compiler-2.3.21.zip @@ -156,11 +181,11 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version: "24.x" - uses: ruby/setup-ruby@v1 with: working-directory: example/react-native - ruby-version: '3.4.4' + ruby-version: "3.4.4" bundler-cache: true - name: Select and verify Xcode and simulator run: | diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index e263ef7cb..2ddd06897 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -50,6 +50,7 @@ jobs: outputs: dist_tag: ${{ steps.channel.outputs.dist_tag }} release_version: ${{ steps.release.outputs.version }} + package_count: ${{ steps.release.outputs.package_count }} env: ONLY_WORKSPACE: ${{ inputs.only_workspace || '' }} steps: @@ -60,7 +61,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version: "24.x" registry-url: "https://registry.npmjs.org" - name: Select release channel id: channel @@ -97,6 +98,8 @@ jobs: run: | version=$(node -p "require('./native-views/react-native-native-list/package.json').version") echo "version=$version" >> "$GITHUB_OUTPUT" + count=$(node --input-type=module -e 'import { loadReleaseWorkspaces } from "./scripts/validate-npm-dist-tag.mjs"; console.log((await loadReleaseWorkspaces(process.cwd())).length)') + echo "package_count=$count" >> "$GITHUB_OUTPUT" echo "Published version: $version" >> "$GITHUB_STEP_SUMMARY" - name: Validate release version and dist-tag env: @@ -133,6 +136,7 @@ jobs: runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ needs.package-publish.outputs.release_version }} + RELEASE_PACKAGE_COUNT: ${{ needs.package-publish.outputs.package_count }} steps: - name: Create read-only app-monorepo token id: read-token @@ -158,12 +162,12 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "24.x" - - name: Update only app-modules mobile dependencies + - name: Update app-modules dependencies across consumers id: dependencies working-directory: app-monorepo run: | node ../app-modules/scripts/sync-app-monorepo.mjs . "$RELEASE_VERSION" - if git diff --quiet -- apps/mobile/package.json packages/components/package.json package.json; then + if git diff --quiet -- apps/mobile/package.json packages/components/package.json packages/kit/package.json apps/playground/package.json package.json; then echo 'changed=false' >> "$GITHUB_OUTPUT" else echo 'changed=true' >> "$GITHUB_OUTPUT" @@ -198,7 +202,7 @@ jobs: branch="codex/app-modules-v${version}" git config user.name 'onekey-release[bot]' git config user.email 'onekey-release[bot]@users.noreply.github.com' - git add -u apps/mobile/package.json packages/components/package.json package.json yarn.lock + git add -u apps/mobile/package.json packages/components/package.json packages/kit/package.json apps/playground/package.json package.json yarn.lock git diff --cached --check git commit -m "chore: update app-modules to ${version}" gh auth setup-git @@ -208,7 +212,7 @@ jobs: else git push origin "HEAD:refs/heads/${branch}" fi - body="Update app-modules mobile dependencies to ${version} after all 41 packages passed npm registry verification. Source: ${GITHUB_REPOSITORY}@${GITHUB_SHA}. Module-ID registry structure is checked; new native runtime modules require a fresh Union Build map before registry update." + body="Update app-modules mobile dependencies to ${version} after all ${RELEASE_PACKAGE_COUNT} packages passed npm registry verification. Source: ${GITHUB_REPOSITORY}@${GITHUB_SHA}. Module-ID registry structure is checked; new native runtime modules require a fresh Union Build map before registry update." pr_number=$(gh pr list --repo OneKeyHQ/app-monorepo --state open --base x --head "$branch" --json number,headRepositoryOwner --jq "[.[] | select(.headRepositoryOwner.login == \"$GITHUB_REPOSITORY_OWNER\")][0].number") if test -n "$pr_number"; then gh pr edit "$pr_number" --repo OneKeyHQ/app-monorepo --title "chore: update app-modules to ${version}" --body "$body" diff --git a/README.md b/README.md index 21947629f..b59a622f2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ yarn create:view ## Release automation -Every merged same-repository PR is classified by whether it changes a publishable workspace version. A change without version updates gets an ephemeral next-patch `-alpha.` version across all publishable packages and is published with the npm `next` tag; those preview versions are not committed back to `main`. A change that updates package versions publishes the exact synchronized stable version from the PR with the npm `latest` tag, without another automatic version or changelog change. After registry verification, both preview and latest releases open an app-monorepo PR against `x` for the mobile, components, root dependency pins, and lockfile updates. Source PR approval is therefore also release approval. +Every merged same-repository PR is classified by whether it changes a publishable workspace version. A change without version updates gets an ephemeral next-patch `-alpha.` version across all publishable packages and is published with the npm `next` tag; those preview versions are not committed back to `main`. A change that updates package versions publishes the exact synchronized stable version from the PR with the npm `latest` tag, without another automatic version or changelog change. After registry verification, both preview and latest releases open an app-monorepo PR against `x` for the mobile, components, kit, playground, root dependency pins, and lockfile updates. Source PR approval is therefore also release approval. Merged fork PRs are intentionally skipped with a workflow summary notice; run `package-publish` manually when ready. The manual action remains available for `next`, `latest`, and single-workspace recovery. diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 8867cbabe..8169c0552 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.156): + - AesCrypto (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -21,7 +21,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AsyncStorage (3.0.156): + - AsyncStorage (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -43,7 +43,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.156): + - AutoSizeInput (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -67,7 +67,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.156): + - BackgroundThread (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -90,7 +90,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ChartWebview (3.0.156): + - ChartWebview (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -115,7 +115,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.156): + - CloudFs (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -137,7 +137,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.156): + - CloudKitModule (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -165,7 +165,7 @@ PODS: - CocoaLumberjack/Core (3.9.1) - CocoaLumberjack/Swift (3.9.1): - CocoaLumberjack/Core - - DnsLookup (3.0.156): + - DnsLookup (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -194,7 +194,7 @@ PODS: - hermes-engine (250829098.0.16): - hermes-engine/Pre-built (= 250829098.0.16) - hermes-engine/Pre-built (250829098.0.16) - - KeychainModule (3.0.156): + - KeychainModule (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -234,7 +234,7 @@ PODS: - MMKV (2.4.0): - MMKVCore (~> 2.4.0) - MMKVCore (2.4.0) - - NativeSheet (3.0.156): + - NativeOverlay (3.0.162): - hermes-engine - RCTRequired - RCTTypeSafety @@ -256,7 +256,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NativeSheet/Tests (3.0.156): + - NativeSheet (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -278,7 +278,29 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NetworkInfo (3.0.156): + - NativeSheet/Tests (3.0.162-alpha.268): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - NetworkInfo (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -348,7 +370,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.156): + - OneKeyImage (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -374,9 +396,9 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.156) + - Skeleton (= 3.0.162-alpha.268) - Yoga - - OneKeyImage/Tests (3.0.156): + - OneKeyImage/Tests (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -402,13 +424,13 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.156) + - Skeleton (= 3.0.162-alpha.268) - Yoga - - OneKeyTextInput (3.0.156): + - OneKeyTextInput (3.0.162-alpha.268): - React-Core - - OneKeyTextInput/Tests (3.0.156): + - OneKeyTextInput/Tests (3.0.162-alpha.268): - React-Core - - Pbkdf2 (3.0.156): + - Pbkdf2 (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -430,7 +452,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.156): + - PerpDepthBar (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -454,7 +476,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.156): + - Ping (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1899,10 +1921,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list (3.0.156): + - react-native-native-list (3.0.162-alpha.268): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.156) + - OneKeyImage (= 3.0.162-alpha.268) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -1924,10 +1946,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list/Tests (3.0.156): + - react-native-native-list/Tests (3.0.162-alpha.268): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.156) + - OneKeyImage (= 3.0.162-alpha.268) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -1949,7 +1971,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.156): + - react-native-pager-view (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1971,7 +1993,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view/Tests (3.0.156): + - react-native-pager-view/Tests (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2062,7 +2084,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.156): + - react-native-tab-view (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2074,7 +2096,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.156) + - react-native-tab-view/common (= 3.0.162-alpha.268) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2085,7 +2107,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.156): + - react-native-tab-view/common (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2503,7 +2525,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.156): + - ReactNativeAppUpdate (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2528,7 +2550,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.156): + - ReactNativeBundleCrypto (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2553,7 +2575,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto/Tests (3.0.156): + - ReactNativeBundleCrypto/Tests (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2578,7 +2600,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.156): + - ReactNativeBundleUpdate (3.0.162-alpha.268): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2607,7 +2629,7 @@ PODS: - ReactNativeRangeDownloader - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.156): + - ReactNativeCheckBiometricAuthChanged (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2633,7 +2655,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.156): + - ReactNativeDeviceUtils (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2658,7 +2680,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeGetRandomValues (3.0.156): + - ReactNativeGetRandomValues (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2683,7 +2705,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeImageCropPicker (3.0.156): + - ReactNativeImageCropPicker (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2708,7 +2730,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.156): + - ReactNativeLiteCard (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2731,7 +2753,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.156): + - ReactNativeNativeLogger (3.0.162-alpha.268): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -2756,7 +2778,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativePerfMemory (3.0.156): + - ReactNativePerfMemory (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2781,7 +2803,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.156): + - ReactNativePerfStats (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2806,7 +2828,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.156): + - ReactNativeRangeDownloader (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2832,7 +2854,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.156): + - ReactNativeSplashScreen (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2857,7 +2879,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeZipArchive (3.0.156): + - ReactNativeZipArchive (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2928,7 +2950,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.156): + - ScrollGuard (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2960,7 +2982,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.156): + - SegmentSlider (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -2984,7 +3006,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Skeleton (3.0.156): + - Skeleton (3.0.162-alpha.268): - hermes-engine - NitroModules - RCTRequired @@ -3008,7 +3030,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.156): + - SniConnect (3.0.162-alpha.268): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -3032,7 +3054,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SplitBundleLoader (3.0.156): + - SplitBundleLoader (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3056,7 +3078,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.156): + - TcpSocket (3.0.162-alpha.268): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3093,6 +3115,7 @@ DEPENDENCIES: - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - "KeychainModule (from `../../../node_modules/@onekeyfe/react-native-keychain-module`)" + - "NativeOverlay (from `../../../node_modules/@onekeyfe/react-native-native-overlay`)" - "NativeSheet (from `../../../node_modules/@onekeyfe/react-native-native-sheet`)" - "NativeSheet/Tests (from `../../../node_modules/@onekeyfe/react-native-native-sheet`)" - "NetworkInfo (from `../../../node_modules/@onekeyfe/react-native-network-info`)" @@ -3248,6 +3271,8 @@ EXTERNAL SOURCES: :tag: hermes-v250829098.0.16 KeychainModule: :path: "../../../node_modules/@onekeyfe/react-native-keychain-module" + NativeOverlay: + :path: "../../../node_modules/@onekeyfe/react-native-native-overlay" NativeSheet: :path: "../../../node_modules/@onekeyfe/react-native-native-sheet" NetworkInfo: @@ -3464,31 +3489,32 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: b218a7ed52263e5eb3ac292938e1b78337a40213 - AsyncStorage: 15c9e8d2981048843744ed853dfed15c52599f5b - AutoSizeInput: 1acc4f267b9f7342443b55188a8c9a1c8ab33373 - BackgroundThread: 12296fefa3fbcd7bfd9e477340543c6ae7241fa3 - ChartWebview: d5fa9b4edf9c932995302bc923a79e3432c6f988 - CloudFs: 3098537768abe6f0cd37cec296147ae305dea1ce - CloudKitModule: 8d429f27eb714bdc40679c321d8d9c96c6ce7d8f + AesCrypto: 4f4586887134bc6c31bc9bd9a028053fa3fc88b9 + AsyncStorage: cd22db0ec9f5e5dfbdb1d3f9d40783a84c624719 + AutoSizeInput: 7fa6dd744560e8c3bb28c66f81b4c027d7d1c991 + BackgroundThread: 6315864406c78f89b9c31861624d52328a2a0911 + ChartWebview: 354c4a521104dbb790ee74e0d1fb6ca91bddf12e + CloudFs: 4d8fdefe1f62c82b00b89c1f696f5d5d734b2dc8 + CloudKitModule: c306c4d74171761f1f40fb0add47b29e19ad19df CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6 - DnsLookup: d2b99c2e1e62e445a6a985ee0a2952f8bd2c79ba + DnsLookup: e2ffe9f751521e6627a05d9ee54ad1c6f8c556f6 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: eb14761560c3097b15973a402c116da6187ab9af - KeychainModule: 63cfc0ebbe8fc30a2812f5b9bf2df644fa55e411 + hermes-engine: 9a9e5e2971450b2ff2a64057438c7f33c3c71d9f + KeychainModule: e0a925c20b5ad33f09faa58745687c29eca880f4 libwebp: af5937a13536ef73f3784d69cc342b98961acf33 MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa - NativeSheet: 80cf8a2a7a1cd5af66146246c6d675636c169f93 - NetworkInfo: 08833b9ee1ad3aad57f650bf2c03462260f15854 + NativeOverlay: 305b5b498d8126310829423de213c909e8e77051 + NativeSheet: 436bd0c5085ddb46d4375bdf0e213f57ccfd7cfe + NetworkInfo: 5b8949bc070d48b084537fc146e10b8d1f40722c NitroMmkv: 85a251e15c39d7c95fe58cf40786fa126b8d143c NitroModules: cda827b31eb05c1bfcc0e2bbd9b647a69c6c314f - OneKeyImage: ba73c4c40feb208a6354b7475fdc9f80f7c6391c - OneKeyTextInput: 2c9a210190cf78c409e06ec204ce7db02885d0f5 - Pbkdf2: 752f795b47ca96e89d263f05d3e0917393b4195d - PerpDepthBar: 15e24c04a0f0ff817855b6cccf5d2ffec5a38277 - Ping: bb21d3858f71884460a7716306888687ed5398df + OneKeyImage: 7f2dab3b6cf93a070d66593b07ad3496a404a5eb + OneKeyTextInput: f2d8b807789688f71cce622196635cb8e565f1f0 + Pbkdf2: dc477b4ad8ed12928f863714d0eefa6685f7d57b + PerpDepthBar: 4fedba5d815bb718d8dfe5d3529c3b0dd8f61a5d + Ping: d8358ecab86a108971523447ee94abb7f8d827d9 RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c @@ -3526,10 +3552,10 @@ SPEC CHECKSUMS: React-Mapbuffer: 44dd007f917e2396afa0e70bbd70273237d93fde React-microtasksnativemodule: f413ee411341fb3c34d20e85e9f6f524921fab15 React-mutationobservernativemodule: 58f6d2adf7d98e72b241608dfa2ddce414d2a4e7 - react-native-native-list: c586598e9a4902586b091b060d8b594013dc442a - react-native-pager-view: d0165b7fba601f2ce11f4798622122002562c82c + react-native-native-list: cd97dca2ee3128a89b8adbfc1c756a1453022b90 + react-native-pager-view: d8dfa199e49707927668c802025c28bc150cdd8c react-native-safe-area-context: 91a90d98c310adcc90a511e5aeb6046d7c19d885 - react-native-tab-view: b62691e2b0492c55dc436cf2f971c27f4a0165cc + react-native-tab-view: e20f34d20ccf39e109b9e6dff3a283dcf7532424 React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 React-networking: 89f6b69755a3176f30e56ba1ba8e214b1518ecfb React-oscompat: db6675ddaef3bddd1b41533627f3d26ec710c05f @@ -3564,34 +3590,34 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 01cbf7a08c30e8b71ad71489bb56b5bed195fa5e ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 - ReactNativeAppUpdate: 4880dff75e3bf33305882760cccaec0bccff2d21 - ReactNativeBundleCrypto: b156193d3236705100440499df94fbcdf1740e6e - ReactNativeBundleUpdate: f07c516f08f30dfecc7c36c63495ca984642442c - ReactNativeCheckBiometricAuthChanged: 6b9c67db94c2a3b9f8454710d7a1eaf4e3b3e2a0 + ReactNativeAppUpdate: 5cc672abd43959b95bbb57df21fe95ebdb7bc440 + ReactNativeBundleCrypto: 7fc3f568fcbaa1e78dc6cedc1b848c2e9dbabe6f + ReactNativeBundleUpdate: e77018a3b75cd2343502258d3ea7ebc8e38e5db1 + ReactNativeCheckBiometricAuthChanged: d403480d82350083bb2b99c1b0f932c0e3b0ebad ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 5b6d250ff5b8c8ab60412d65c9f264e23966a6be - ReactNativeGetRandomValues: a742b388d17ed96ddafbdaa7a373fc4fa546b1c9 - ReactNativeImageCropPicker: 7b5924895c249de7f4fcf5f2a1b9d20f8b2268b4 - ReactNativeLiteCard: b79da2fd6d8b9b4b937bbfff3639fb84527fa59c - ReactNativeNativeLogger: a65544512307eaa7d837231f1e8e5dd81f6f3924 - ReactNativePerfMemory: b524752f61b1ffd81f6205f608d82862619e257f - ReactNativePerfStats: f9c8c23d82c4ce4141647327d6e6e3c1b4e6a75e - ReactNativeRangeDownloader: 82f0be0eb546c9310fd34e7aabdd4baf0932f2f5 - ReactNativeSplashScreen: 5fe6902a2e6e6dc6d909ee12fcdb89a2311297d7 - ReactNativeZipArchive: 2f6962133b7662e7254c55bddd79eb96310ee574 + ReactNativeDeviceUtils: 9f7a8bc3d3137bece30f291ab97255f0b34fa5d7 + ReactNativeGetRandomValues: caec968297a7b6cf06c589dd3c4e55514b6ead3d + ReactNativeImageCropPicker: a112fdeae5f15583901e2299c7b69ee15719c3f8 + ReactNativeLiteCard: 59725bd7f02666dd2aed476b0e820ce2e3db7d7b + ReactNativeNativeLogger: 5231d4b8461297579f210df2981b67ae8c7aef0a + ReactNativePerfMemory: bc7f3ed64ca31ed77b919291e8ef97a8f9261ef8 + ReactNativePerfStats: 35fbabfc65e8f3bcc03f59f443c5d4db54aedc54 + ReactNativeRangeDownloader: 0539c3d3a52db8354d4a316fb8cfab94a317de6c + ReactNativeSplashScreen: b26ba6a5264471466d6f043a45e24cf4ca8e4d90 + ReactNativeZipArchive: f196744e993c80da0c44959c768615dce5fa7f2f RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41 - ScrollGuard: 1da2fe550eae86b3dc4393ca9836ff55793329d7 + ScrollGuard: 0bd6ead25884e76f24a500e7a328919a8363e4ca SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 993d0b85c00e9796e2ed2239fe9439817dcaa6cc - Skeleton: 176bd1b01d4b91bebca3d314d7eeb8f70311da69 - SniConnect: 35e5df5eceecd12ed462ef2421733ca27a6484ef - SplitBundleLoader: ba12571a4af23184777fe5a85983ff27e9c4c55c + SegmentSlider: 4f14afebf028e3994d100c5dc288f2529717938e + Skeleton: 32d4e2603c1675ebc4731fd3b31abc4dd6bb13fd + SniConnect: f52f780df1b5cdd502b93c4ed070e82c6bff73c4 + SplitBundleLoader: c177f56e9b48fa25deeb2e0b5bc569dc71deaa43 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: 2725be5301911fb4a3bcc4f85915dcacfd0ccfb1 + TcpSocket: c4f9950854242c86c1af69c32ca906e13d900d95 Yoga: 9891cfb1c680f64de9c41b09e7453dea33454d2b PODFILE CHECKSUM: d163efd24ff4c7dc7da7c6c0f1023634b164196e -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/example/react-native/package.json b/example/react-native/package.json index 08f48e27f..ef0c09037 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -33,6 +33,7 @@ "@onekeyfe/react-native-lite-card": "workspace:*", "@onekeyfe/react-native-native-list": "workspace:*", "@onekeyfe/react-native-native-logger": "workspace:*", + "@onekeyfe/react-native-native-overlay": "workspace:*", "@onekeyfe/react-native-native-sheet": "workspace:*", "@onekeyfe/react-native-network-info": "workspace:*", "@onekeyfe/react-native-pager-view": "workspace:*", diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 4168d7800..f2ab71286 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 52415450a..81cf14530 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 316945459..d570c9697 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 3cfb82a17..e87ec7131 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 9f7d1d227..ae328d987 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 7c125b425..0f5df76bf 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 08a662bff..ee1bc9eec 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index ec36f8017..62090783d 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 75e1463bb..4a21ac6ae 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 64d52fc05..6337c0f80 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index e066e6af8..4823fc86c 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 2a0483573..e1212d3c4 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index a65f5f4c7..aa32f5b56 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json index e7f895962..cb14c3a42 100644 --- a/native-modules/react-native-image-crop-picker/package.json +++ b/native-modules/react-native-image-crop-picker/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image-crop-picker", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 66e71d0ac..cefcce472 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index e1e6c00a5..0aba6fee4 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 3ce1dc99d..e2fcd2cd1 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index fc3bfcd2d..263587eb3 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index cf381e4f5..be2a89b5f 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 048694a67..6b9e556df 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 61d0e8452..f3c27ead6 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index dcdc95b3e..7ea1240db 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index c053cb320..45477f290 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 062d09fa0..60bbf9cb6 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index eb01860fe..68735951f 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index be68f1c92..edb6f2fa1 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index b6d59f79c..59aafa32a 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index c10a61a5d..322c7f405 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 7d56ec6b1..e8a8c372c 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 596b6de24..257272fbf 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-image/package.json b/native-views/react-native-image/package.json index f14ed5645..51f6dba1b 100644 --- a/native-views/react-native-image/package.json +++ b/native-views/react-native-image/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "High-performance native image view for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -81,7 +81,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-skeleton": "3.0.156", + "@onekeyfe/react-native-skeleton": "3.0.162-alpha.268", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index a0af1b0db..29c5d0630 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-list", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Template-driven native RecyclerView and UICollectionView for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", @@ -84,8 +84,8 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-image": "3.0.156", - "@onekeyfe/react-native-native-logger": "3.0.156", + "@onekeyfe/react-native-image": "3.0.162-alpha.268", + "@onekeyfe/react-native-native-logger": "3.0.162-alpha.268", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-overlay/.gitignore b/native-views/react-native-native-overlay/.gitignore new file mode 100644 index 000000000..67f32126d --- /dev/null +++ b/native-views/react-native-native-overlay/.gitignore @@ -0,0 +1,86 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +**/.xcode.env.local + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ + +# React Native Codegen +ios/generated +android/generated + +# React Native Nitro Modules +nitrogen/ diff --git a/native-views/react-native-native-overlay/LICENSE b/native-views/react-native-native-overlay/LICENSE new file mode 100644 index 000000000..24ee41144 --- /dev/null +++ b/native-views/react-native-native-overlay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 OneKey + +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 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native-views/react-native-native-overlay/NativeOverlay.podspec b/native-views/react-native-native-overlay/NativeOverlay.podspec new file mode 100644 index 000000000..912d7421d --- /dev/null +++ b/native-views/react-native-native-overlay/NativeOverlay.podspec @@ -0,0 +1,30 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "NativeOverlay" + s.version = package["version"].split("-").first + s.summary = package["description"] + s.homepage = "https://github.com/OneKeyHQ/app-modules" + s.license = package["license"] + s.authors = "@onekeyfe" + + s.platforms = { :ios => '16.0' } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" } + + s.source_files = [ + "ios/**/*.h", + "ios/**/*.{swift}", + "ios/**/*.{m,mm}", + "common/cpp/**/*.{cpp,h}", + ] + # C++ headers stay private so the Swift module does not import them. + s.project_header_files = "common/cpp/**/*.h" + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/common/cpp\"" + } + s.exclude_files = "ios/tests/**/*" + s.swift_version = '5.0' + install_modules_dependencies(s) +end diff --git a/native-views/react-native-native-overlay/README.md b/native-views/react-native-native-overlay/README.md new file mode 100644 index 000000000..5c63ff677 --- /dev/null +++ b/native-views/react-native-native-overlay/README.md @@ -0,0 +1,35 @@ +# @onekeyfe/react-native-native-overlay + +Native and Web overlay hosts with ordered levels, page scope, lifecycle events, +keyboard avoidance and Fabric measurement. See [SPEC](docs/SPEC.md) for the +behavioral contract, platform differences and outstanding runtime acceptance. + +```tsx +import { OverlayView } from '@onekeyfe/react-native-native-overlay'; + + + {content} +; +``` + +Global levels are ordered modal, hardware, secure, toast, lock, then debug. +React context stays attached to the original subtree while the native host +reparents its contents. Page overlays use `OverlayPageHost` and +`OverlayPageHostScope` / `OverlayPageOwnerScope`. Android and iOS require the React Native new +architecture; Web additionally requires `react-dom`. + +## Development + +Run `yarn typecheck`, `yarn lint`, `yarn test --runInBand`, and `yarn prepare`. +This package was initialized from the repository view template and retains +Fabric rather than Nitro under the explicit exception recorded in the SPEC. + +## Release + +Use the repository `package-publish` CI workflow. This branch's synchronized +42-package batch is `3.0.162-alpha.268`, published under `next`; no PR merge or +stable-tag change is required. Consumers must pin the exact verified version. + +## License + +MIT diff --git a/native-views/react-native-native-overlay/android/build.gradle b/native-views/react-native-native-overlay/android/build.gradle new file mode 100644 index 000000000..50042ca24 --- /dev/null +++ b/native-views/react-native-native-overlay/android/build.gradle @@ -0,0 +1,82 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['NativeOverlay_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["NativeOverlay_" + name]).toInteger() +} + +android { + namespace "com.onekey.nativeoverlay" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.google.android.material:material:1.14.0-alpha05" +} + +react { + jsRootDir = file("../src/") + libraryName = "RNCNativeOverlay" + codegenJavaPackageName = "com.onekey.nativeoverlay" +} diff --git a/native-views/react-native-native-overlay/android/src/main/AndroidManifest.xml b/native-views/react-native-native-overlay/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a2f47b605 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAnimation.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAnimation.kt new file mode 100644 index 000000000..e3e3b3317 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAnimation.kt @@ -0,0 +1,176 @@ +package com.onekey.nativeoverlay + +import android.animation.TimeInterpolator +import android.view.animation.PathInterpolator +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.sin +import kotlin.math.sqrt + +/** Mirrors `IOverlayMotion` from `src/animation/types.ts`. */ +internal sealed class OverlayMotion { + data class Spring(val mass: Double, val stiffness: Double, val damping: Double) : OverlayMotion() + + data class Timing( + val durationMs: Long, + val x1: Float, + val y1: Float, + val x2: Float, + val y2: Float, + ) : OverlayMotion() + + fun interpolator(): TimeInterpolator = when (this) { + is Spring -> SpringInterpolator(this) + is Timing -> PathInterpolator(x1, y1, x2, y2) + } + + fun durationMs(): Long = when (this) { + is Spring -> SpringInterpolator.settleTimeMs(this) + is Timing -> durationMs + } + + companion object { + /** Tamagui `quick` on native. */ + val QUICK = Spring(0.1, 100.0, 20.0) + + fun parse(json: JSONObject?): OverlayMotion { + json ?: return QUICK + return when (json.optString("type")) { + "timing" -> { + val timing = json.optJSONObject("timing") ?: return QUICK + val easing = timing.optJSONArray("easing") + val points = if (easing != null && easing.length() == 4) { + FloatArray(4) { easing.optDouble(it).toFloat() } + } else { + floatArrayOf(0.25f, 0.1f, 0.25f, 1f) + } + Timing( + timing.optDouble("durationMs", 150.0).toLong(), + points[0], + points[1], + points[2], + points[3], + ) + } + "spring" -> { + val spring = json.optJSONObject("spring") ?: return QUICK + Spring( + spring.optDouble("mass", 1.0), + spring.optDouble("stiffness", 100.0), + spring.optDouble("damping", 20.0), + ) + } + else -> QUICK + } + } + } +} + +/** + * Analytic step response of `m·x'' + c·x' + k·x = 0`, identical to + * Reanimated and `springProgressAt` in JS. Driving one ValueAnimator with it + * keeps alpha, translation, scale and backdrop on the same clock; androidx + * SpringAnimation would need one animation per property. + */ +internal class SpringInterpolator(private val spring: OverlayMotion.Spring) : TimeInterpolator { + private val durationMs = settleTimeMs(spring) + + override fun getInterpolation(input: Float): Float { + if (input >= 1f) return 1f + return progressAt(spring, input * durationMs / 1000.0).toFloat() + } + + companion object { + private const val EPSILON = 0.001 + private const val MAX_SETTLE_MS = 10_000L + private val settleCache = HashMap() + + fun progressAt(spring: OverlayMotion.Spring, t: Double): Double { + val w0 = sqrt(spring.stiffness / spring.mass) + val zeta = spring.damping / (2 * sqrt(spring.stiffness * spring.mass)) + return when { + zeta < 1 -> { + val wd = w0 * sqrt(1 - zeta * zeta) + 1 - exp(-zeta * w0 * t) * (cos(wd * t) + (zeta * w0 / wd) * sin(wd * t)) + } + zeta == 1.0 -> 1 - exp(-w0 * t) * (1 + w0 * t) + else -> { + val root = sqrt(zeta * zeta - 1) + val r1 = -w0 * (zeta - root) + val r2 = -w0 * (zeta + root) + 1 - (r2 * exp(r1 * t) - r1 * exp(r2 * t)) / (r2 - r1) + } + } + } + + fun settleTimeMs(spring: OverlayMotion.Spring): Long = synchronized(settleCache) { + settleCache.getOrPut(spring) { + var lastOutside = 0L + for (t in 0..MAX_SETTLE_MS) { + if (abs(1 - progressAt(spring, t / 1000.0)) >= EPSILON) lastOutside = t + } + lastOutside + 1 + } + } + } +} + +/** Mirrors `IResolvedOverlayTransition`. */ +internal data class OverlayTransition( + val kind: String = "fade", + val edge: String = "bottom", + val distanceDp: Double? = null, + val scale: Float = 0.95f, + val offsetYDp: Double = 0.0, + val fade: Boolean = true, + val motion: OverlayMotion = OverlayMotion.QUICK, +) { + val hidesOpacity: Boolean get() = kind == "fade" || fade + + fun reduceMotionAdjusted(reduceMotion: Boolean): OverlayTransition = + if (reduceMotion && kind != "none") copy(kind = "fade", fade = true) else this + + companion object { + fun parse(json: JSONObject?): OverlayTransition { + json ?: return OverlayTransition() + val kind = json.optString("type", "fade") + return OverlayTransition( + kind = kind, + edge = json.optString("edge", "bottom"), + distanceDp = if (json.has("distance")) json.optDouble("distance") else null, + scale = json.optDouble("scale", 0.95).toFloat(), + offsetYDp = json.optDouble("offsetY", 0.0), + fade = if (json.has("fade")) json.optBoolean("fade") else kind == "fade" || kind == "scale", + motion = OverlayMotion.parse(json.optJSONObject("motion")), + ) + } + } +} + +/** Mirrors `IResolvedOverlayAnimation`. */ +internal data class OverlayAnimationConfig( + val enter: OverlayTransition = OverlayTransition(), + val exit: OverlayTransition = OverlayTransition(), + val backdropMotion: OverlayMotion = OverlayMotion.QUICK, +) { + companion object { + fun parse(source: String?): OverlayAnimationConfig { + if (source.isNullOrEmpty()) return OverlayAnimationConfig() + return try { + val json = JSONObject(source) + val enter = OverlayTransition.parse(json.optJSONObject("enter")) + OverlayAnimationConfig( + enter = enter, + exit = json.optJSONObject("exit")?.let(OverlayTransition::parse) ?: enter, + backdropMotion = OverlayMotion.parse( + json.optJSONObject("backdrop")?.optJSONObject("motion"), + ), + ) + } catch (_: Exception) { + OverlayAnimationConfig() + } + } + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEntryRootView.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEntryRootView.kt new file mode 100644 index 000000000..a412f04e6 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEntryRootView.kt @@ -0,0 +1,415 @@ +package com.onekey.nativeoverlay + +import android.annotation.SuppressLint +import android.graphics.Rect +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSPointerDispatcher +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView +import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.TouchTargetHelper +import com.facebook.react.uimanager.events.EventDispatcher + +/** + * Keeps Fabric-assigned frames: the React root inside is laid out by Yoga + * (window size from JS), so this container must not re-layout it. + */ +internal class NativeOverlayContentView(context: ThemedReactContext) : ViewGroup(context) { + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) = Unit +} + +/** + * Carries the keyboard avoidance lift (translationY), so it composes with the + * enter / exit animation on the content or sheet inside it. + */ +internal class NativeOverlayKeyboardLayer(context: ThemedReactContext) : FrameLayout(context) { + init { + clipChildren = false + } +} + +/** + * One overlay entry. React children mounted here are outside their + * ReactRootView, so this root mirrors RN Modal's touch dispatch boundary + * (JSTouchDispatcher / JSPointerDispatcher) to keep Pressability and native + * gesture cancellation working after reparenting. + */ +@SuppressLint("ViewConstructor") +internal class NativeOverlayEntryRootView( + private val reactContext: ThemedReactContext, +) : FrameLayout(reactContext), RootView, ReactPointerEventsView { + var eventDispatcher: EventDispatcher? = null + + /** + * Page entries are inside the ReactRootView, whose TouchTargetHelper reads + * this: blocking entries stop the search (touches never reach the page + * below), non-blocking ones let it continue past the empty area. + */ + override val pointerEvents: PointerEvents + get() = if (blocking && isShownForInput) PointerEvents.AUTO else PointerEvents.BOX_NONE + var stackOrder = 0 + var levelOrder = 0 + /** Page scope: the owning page, used by the page host to hide the entry. */ + var ownerKey = "" + /** + * False for page scope: the entry is inside the ReactRootView, which already + * dispatches its touches; a second dispatcher would double every event. + */ + var dispatchesJsTouches = true + var blocking = true + var dismissOnBackPress = true + var dismissOnBackdropPress = false + var isShownForInput = false + var onRequestDismiss: ((String) -> Unit)? = null + + val backdropView = View(reactContext).apply { alpha = 0f } + val keyboardLayer = NativeOverlayKeyboardLayer(reactContext) + val contentView = NativeOverlayContentView(reactContext) + + /** The lift finished changing; the owner re-reports where its content is. */ + var onKeyboardShiftSettled: (() -> Unit)? = null + private var imeBottom = 0 + private var safeTop = 0 + private var sheetBottomPadding = 0 + private var keyboardAnimating = false + + /** `sheet` presentation: the draggable surface; otherwise null. */ + var sheetView: FrameLayout? = null + private set + private var sheetBehavior: BottomSheetBehavior? = null + private var handleView: View? = null + var onSheetHiddenByPan: (() -> Unit)? = null + + /** The view that enter / exit animations move. */ + val animatedView: View get() = sheetView ?: contentView + + private val touchDispatcher = JSTouchDispatcher(this) + private val pointerDispatcher = if (ReactFeatureFlags.dispatchPointerEvents) { + JSPointerDispatcher(this) + } else { + null + } + private var passingThrough = false + private val hitRect = Rect() + private val targetViewTag = IntArray(1) + + init { + clipChildren = false + addView(backdropView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + addView(keyboardLayer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + keyboardLayer.addView(contentView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + installKeyboardTracking() + backdropView.setOnClickListener { + if (dismissOnBackdropPress) onRequestDismiss?.invoke("backdrop") + } + } + + fun updateBlocking(value: Boolean) { + blocking = value + // A clickable backdrop consumes touches that miss the content. + backdropView.isClickable = value + backdropView.importantForAccessibility = + if (value) IMPORTANT_FOR_ACCESSIBILITY_NO else IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + + /** + * Hosts the content in a bottom sheet driven by BottomSheetBehavior, the + * same drag / nested-scroll mechanics as the previous BottomSheetDialog, + * but inside this layer instead of a separate window. + */ + fun configureSheet( + heightPx: Int, + cornerRadiusPx: Float, + backgroundColor: Int, + showHandle: Boolean, + draggable: Boolean, + ) { + val sheet = sheetView ?: createSheet() + (sheet.background as? GradientDrawable)?.apply { + setColor(backgroundColor) + cornerRadii = floatArrayOf( + cornerRadiusPx, cornerRadiusPx, cornerRadiusPx, cornerRadiusPx, 0f, 0f, 0f, 0f, + ) + } + handleView?.visibility = if (showHandle) VISIBLE else GONE + sheetBehavior?.apply { + isDraggable = draggable + isHideable = draggable + } + // The content sits above the navigation bar, as UIKit sheets keep it + // above the home indicator; the surface still reaches the screen edge. + sheetBottomPadding = bottomSystemInset() + val targetHeight = heightPx + sheetBottomPadding + val params = sheet.layoutParams + if (params.height != targetHeight) { + params.height = targetHeight + sheet.layoutParams = params + } + } + + /** The sheet's full height, navigation bar padding included. */ + fun sheetHeightPx(): Int = sheetView?.layoutParams?.height ?: 0 + + // The entry may not be attached yet when the sheet is configured; the + // activity's decor view always carries the window insets. + private fun bottomSystemInset(): Int { + val root = reactContext.currentActivity?.window?.decorView ?: this + return ViewCompat.getRootWindowInsets(root) + ?.getInsets(WindowInsetsCompat.Type.navigationBars()) + ?.bottom ?: 0 + } + + private fun createSheet(): FrameLayout { + keyboardLayer.removeView(contentView) + val coordinator = CoordinatorLayout(reactContext) + keyboardLayer.addView(coordinator, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + val sheet = FrameLayout(reactContext).apply { + background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE } + clipToOutline = true + isClickable = true + } + sheet.addView(contentView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + val density = resources.displayMetrics.density + val handle = View(reactContext).apply { + background = GradientDrawable().apply { + cornerRadius = 2.5f * density + setColor(0x33000000) + } + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + } + sheet.addView( + handle, + LayoutParams((36 * density).toInt(), (5 * density).toInt()).apply { + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + topMargin = (8 * density).toInt() + }, + ) + val behavior = BottomSheetBehavior().apply { + skipCollapsed = true + isFitToContents = true + state = BottomSheetBehavior.STATE_EXPANDED + addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) onSheetHiddenByPan?.invoke() + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + // slideOffset runs from 0 (expanded) to -1 (hidden) while dragging down. + if (slideOffset <= 0f) backdropView.alpha = (1f + slideOffset).coerceIn(0f, 1f) + } + }) + } + coordinator.addView( + sheet, + CoordinatorLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + this.behavior = behavior + }, + ) + sheetView = sheet + sheetBehavior = behavior + handleView = handle + return sheet + } + + /** Union of the rendered React children; the React root fills the window. */ + fun contentExtent(): Rect { + sheetView?.let { sheet -> + val top = height - sheet.layoutParams.height + return Rect(0, top, width, height) + } + val union = Rect() + for (i in 0 until contentView.childCount) { + val root = contentView.getChildAt(i) as? ViewGroup ?: continue + for (j in 0 until root.childCount) { + val child = root.getChildAt(j) + if (child.visibility != View.VISIBLE) continue + child.getHitRect(hitRect) + hitRect.offset(root.left, root.top) + union.union(hitRect) + } + } + if (union.isEmpty) union.set(0, 0, width, height) + return union + } + + // region Keyboard + + /** + * IME insets arrive through this entry: applied insets for the resting + * state, the insets animation for the frames in between. Nothing is + * consumed, so the React content still sees them. + */ + private fun installKeyboardTracking() { + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + readInsets(insets) + if (!keyboardAnimating) { + updateKeyboardShift() + onKeyboardShiftSettled?.invoke() + } + insets + } + ViewCompat.setWindowInsetsAnimationCallback( + this, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) keyboardAnimating = true + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList, + ): WindowInsetsCompat { + if (keyboardAnimating) { + readInsets(insets) + updateKeyboardShift() + } + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + if (animation.typeMask and WindowInsetsCompat.Type.ime() == 0) return + keyboardAnimating = false + ViewCompat.getRootWindowInsets(this@NativeOverlayEntryRootView)?.let(::readInsets) + updateKeyboardShift() + onKeyboardShiftSettled?.invoke() + } + }, + ) + } + + private fun readInsets(insets: WindowInsetsCompat) { + imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + safeTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + if (changed) updateKeyboardShift() + } + + /** + * Lifts the content above the IME, the same rule as iOS: a sheet until its + * content bottom clears the keyboard, other content until its bottom is + * 16dp above it, never above the status bar; full-window content stays. + */ + fun updateKeyboardShift() { + val shift = if (imeBottom <= 0 || !isAttachedToWindow || height == 0) { + 0f + } else { + val location = IntArray(2) + getLocationInWindow(location) + val keyboardTop = (rootView.height - imeBottom - location[1]).toFloat() + val sheet = sheetView + val needed: Float + val room: Float + if (sheet != null) { + val sheetTop = (height - sheet.layoutParams.height).toFloat() + needed = (height - sheetBottomPadding) - keyboardTop + room = sheetTop - safeTop + } else { + val extent = contentExtent() + needed = extent.bottom + KEYBOARD_MARGIN_DP * resources.displayMetrics.density - keyboardTop + room = (extent.top - safeTop).toFloat() + } + maxOf(0f, minOf(needed, room)) + } + if (keyboardLayer.translationY != -shift) keyboardLayer.translationY = -shift + } + + // endregion + + /** + * Hit-tests the React content the way the JS touch dispatcher would, so + * `box-none` wrappers (a full-window toaster, say) let touches through. + */ + private fun hitsContent(event: MotionEvent): Boolean { + val x = event.x - keyboardLayer.translationX - contentView.left - contentView.translationX + val y = event.y - keyboardLayer.translationY - contentView.top - contentView.translationY + for (i in contentView.childCount - 1 downTo 0) { + val root = contentView.getChildAt(i) as? ViewGroup ?: continue + if (root.visibility != View.VISIBLE) continue + targetViewTag[0] = View.NO_ID + TouchTargetHelper.findTargetTagForTouch(x - root.left, y - root.top, root, targetViewTag) + if (targetViewTag[0] != View.NO_ID) return true + } + return false + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (!isShownForInput) return false + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + // Non-blocking overlays (toasts) only take touches that land on content. + passingThrough = !blocking && !hitsContent(event) + } + if (passingThrough) return false + return super.dispatchTouchEvent(event) + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (!dispatchesJsTouches) return super.onInterceptTouchEvent(event) + eventDispatcher?.let { dispatcher -> + touchDispatcher.handleTouchEvent(event, dispatcher, reactContext) + pointerDispatcher?.handleMotionEvent(event, dispatcher, true) + } + return super.onInterceptTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!dispatchesJsTouches) { + super.onTouchEvent(event) + return blocking + } + eventDispatcher?.let { dispatcher -> + touchDispatcher.handleTouchEvent(event, dispatcher, reactContext) + pointerDispatcher?.handleMotionEvent(event, dispatcher, false) + } + super.onTouchEvent(event) + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, true) } + return super.onInterceptHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, false) } + return super.onHoverEvent(event) + } + + @OptIn(UnstableReactNativeAPI::class) + override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) { + eventDispatcher?.let { dispatcher -> + touchDispatcher.onChildStartedNativeGesture(ev, dispatcher, reactContext) + pointerDispatcher?.onChildStartedNativeGesture(childView, ev, dispatcher) + } + } + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) { + eventDispatcher?.let { touchDispatcher.onChildEndedNativeGesture(ev, it) } + pointerDispatcher?.onChildEndedNativeGesture() + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit +} + +private const val KEYBOARD_MARGIN_DP = 16f diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEvents.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEvents.kt new file mode 100644 index 000000000..5cf7ac920 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEvents.kt @@ -0,0 +1,38 @@ +package com.onekey.nativeoverlay + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +internal class NativeOverlayPresentedEvent( + surfaceId: Int, + viewTag: Int, + private val stackOrder: Int, +) : Event(surfaceId, viewTag) { + override fun getEventName(): String = "topPresented" + + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putInt("stackOrder", stackOrder) + } +} + +internal class NativeOverlayReasonEvent( + surfaceId: Int, + viewTag: Int, + private val name: String, + private val reason: String, +) : Event(surfaceId, viewTag) { + override fun getEventName(): String = name + + // Every request / dismissal must reach JS; never coalesce them. + override fun canCoalesce(): Boolean = false + + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putString("reason", reason) + } + + companion object { + const val DISMISSED = "topDismissed" + const val REQUEST_DISMISS = "topRequestDismiss" + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt new file mode 100644 index 000000000..e9f4eb53f --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt @@ -0,0 +1,174 @@ +package com.onekey.nativeoverlay + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import java.util.WeakHashMap + +/** Mirrors `OVERLAY_LEVEL_ORDER`. */ +internal enum class NativeOverlayLevel(val order: Int) { + MODAL(100), + HARDWARE(200), + SECURE(300), + TOAST(400), + LOCK(500), + DEBUG(900); + + companion object { + fun from(value: String?): NativeOverlayLevel = + entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: MODAL + } +} + +private class BackInterceptingCallback( + private val delegate: Window.Callback, + private val host: NativeOverlayHost, +) : Window.Callback by delegate { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && host.handleBackKey(event)) return true + return delegate.dispatchKeyEvent(event) + } +} + +/** + * One per activity: a FrameLayout added after the ReactRootView inside + * `android.R.id.content`, with one child container per level. Overlays are + * views, not windows, so order between levels is plain child order and + * touches that nothing consumes fall through to the React root below. + */ +internal class NativeOverlayHost private constructor( + private val activity: Activity, +) : FrameLayout(activity) { + private val levelContainers = sortedMapOf( + compareBy { it.order }, + ) + private val backCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + topBlockingEntry()?.let { entry -> + if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back") + } + } + } + private val hiddenForAccessibility = mutableMapOf() + + init { + clipChildren = false + } + + /** + * Called for KEYCODE_BACK before the activity sees it. ReactActivity's + * `onBackPressed` forwards to JS BackHandler (react-navigation pops the + * screen) before the OnBackPressedDispatcher, and the app opts out of + * predictive back, so the dispatcher alone never runs first. + */ + fun handleBackKey(event: KeyEvent): Boolean { + val entry = topBlockingEntry() ?: return false + if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) { + entry.onRequestDismiss?.invoke("back") + } + return true + } + + internal fun installBackInterceptor() { + val window = activity.window ?: return + val current = window.callback ?: return + if (current !is BackInterceptingCallback) { + window.callback = BackInterceptingCallback(current, this) + } + } + + fun attach(entry: NativeOverlayEntryRootView, level: NativeOverlayLevel) { + installBackInterceptor() + val content = activity.findViewById(android.R.id.content) ?: return + if (parent !== content) { + (parent as? ViewGroup)?.removeView(this) + content.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } else if (content.getChildAt(content.childCount - 1) !== this) { + // Something (e.g. a dev overlay) was added later; stay on top. + bringToFront() + } + val container = levelContainer(level) + if (entry.parent !== container) { + (entry.parent as? ViewGroup)?.removeView(entry) + val index = (0 until container.childCount).firstOrNull { i -> + (container.getChildAt(i) as? NativeOverlayEntryRootView)?.let { it.stackOrder > entry.stackOrder } == true + } ?: container.childCount + container.addView(entry, index, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + onEntriesChanged() + } + + fun detach(entry: NativeOverlayEntryRootView) { + (entry.parent as? ViewGroup)?.removeView(entry) + onEntriesChanged() + } + + fun onEntriesChanged() { + val hasBlocking = topBlockingEntry() != null + if (hasBlocking && !backCallback.isEnabled) { + // The most recently added callback wins; re-add so ours runs before + // ReactActivity's and react-native-screens' callbacks. + backCallback.remove() + (activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback) + } + backCallback.isEnabled = hasBlocking + updateAccessibility() + } + + private fun levelContainer(level: NativeOverlayLevel): FrameLayout = + levelContainers.getOrPut(level) { + val container = FrameLayout(activity).apply { clipChildren = false } + val index = levelContainers.keys.count { it.order < level.order } + addView(container, index, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + container + } + + private fun orderedEntries(): List = + levelContainers.values.flatMap { container -> + (0 until container.childCount).mapNotNull { container.getChildAt(it) as? NativeOverlayEntryRootView } + } + + /** Global overlays win; otherwise the topmost visible page overlay. */ + private fun topBlockingEntry(): NativeOverlayEntryRootView? = + orderedEntries().lastOrNull { it.blocking && it.isShownForInput } + ?: NativeOverlayPageHostView.allHosts() + .filter { it.isAttachedToWindow && it.isShown } + .flatMap { it.shownEntries() } + .lastOrNull { it.blocking } + + /** TalkBack only reads the topmost blocking overlay and what is above it. */ + private fun updateAccessibility() { + val entries = orderedEntries() + val topIndex = entries.indexOfLast { it.blocking && it.isShownForInput } + val content = parent as? ViewGroup + val below = mutableListOf() + if (topIndex >= 0) { + content?.let { group -> + (0 until group.childCount).map(group::getChildAt).filter { it !== this }.forEach(below::add) + } + below.addAll(entries.subList(0, topIndex)) + } + val restore = hiddenForAccessibility.keys.filter { it !in below } + restore.forEach { view -> + view.importantForAccessibility = hiddenForAccessibility.remove(view) ?: IMPORTANT_FOR_ACCESSIBILITY_AUTO + } + below.forEach { view -> + if (view !in hiddenForAccessibility) { + hiddenForAccessibility[view] = view.importantForAccessibility + view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + } + } + + companion object { + private val hosts = WeakHashMap() + + fun of(activity: Activity): NativeOverlayHost = + hosts.getOrPut(activity) { NativeOverlayHost(activity) } + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPackage.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPackage.kt new file mode 100644 index 000000000..0b0436dd9 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPackage.kt @@ -0,0 +1,16 @@ +package com.onekey.nativeoverlay + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class NativeOverlayPackage : ReactPackage { + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = listOf(NativeOverlayViewManager(), NativeOverlayPageHostViewManager()) + + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): List = emptyList() +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt new file mode 100644 index 000000000..123c8cb88 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt @@ -0,0 +1,122 @@ +package com.onekey.nativeoverlay + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactPointerEventsView +import java.lang.ref.WeakReference + +/** + * Page-scope overlays of one root route render inside this view. It sits in + * the React tree (last child of the root-route screen), so the ReactRootView + * already dispatches touches to the moved content. + */ +@SuppressLint("ViewConstructor") +class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPointerEventsView { + // RN's TouchTargetHelper walks the native tree; without box-none this + // full-screen host would become the JS target of every touch in the route. + override val pointerEvents: PointerEvents = PointerEvents.BOX_NONE + + var hostKey: String = "" + set(value) { + if (field == value) return + if (field.isNotEmpty() && registry[field]?.get() === this) registry.remove(field) + field = value + if (value.isNotEmpty()) registry[value] = WeakReference(this) + } + + private var suspendedOwners: Set = emptySet() + + fun setSuspendedOwners(joined: String?) { + val owners = joined.orEmpty().split('\n').filter { it.isNotEmpty() }.toSet() + if (owners == suspendedOwners) return + suspendedOwners = owners + entries().forEach(::applySuspension) + } + + internal fun attach(entry: NativeOverlayEntryRootView) { + if (entry.parent !== this) { + (entry.parent as? ViewGroup)?.removeView(entry) + val index = entries().indexOfFirst { sortKey(it) > sortKey(entry) } + .let { if (it < 0) childCount else indexOfChild(entries()[it]) } + addView(entry, index, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + layoutEntries() + applySuspension(entry) + } + + internal fun detach(entry: NativeOverlayEntryRootView) { + removeView(entry) + } + + /** Visible, input-ready entries in bottom-to-top order. */ + internal fun shownEntries(): List = + entries().filter { it.visibility == VISIBLE && it.isShownForInput } + + fun invalidateHost() { + if (hostKey.isNotEmpty() && registry[hostKey]?.get() === this) registry.remove(hostKey) + } + + private var layoutScheduled = false + private val layoutRunnable = Runnable { + layoutScheduled = false + layoutEntries() + } + + /** + * This view sits inside Fabric's tree, where parents ignore requestLayout + * and only Fabric calls layout(). Entries are native-only children, so lay + * them out here whenever they (or their content) ask for it. + */ + override fun requestLayout() { + super.requestLayout() + if (!layoutScheduled) { + layoutScheduled = true + post(layoutRunnable) + } + } + + private fun layoutEntries() { + onLayout(false, left, top, right, bottom) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val width = r - l + val height = b - t + if (width <= 0 || height <= 0) return + for (i in 0 until childCount) { + val child = getChildAt(i) + child.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + child.layout(0, 0, width, height) + } + } + + private fun applySuspension(entry: NativeOverlayEntryRootView) { + val hidden = entry.ownerKey in suspendedOwners + val target = if (hidden) INVISIBLE else VISIBLE + if (entry.visibility == target) return + entry.visibility = target + if (!hidden) { + entry.alpha = 0f + entry.animate().alpha(1f).setDuration(150).start() + } + } + + private fun entries(): List = + (0 until childCount).mapNotNull { getChildAt(it) as? NativeOverlayEntryRootView } + + private fun sortKey(entry: NativeOverlayEntryRootView): Long = + entry.levelOrder.toLong() * 1_000_000_000L + entry.stackOrder + + companion object { + private val registry = HashMap>() + + internal fun host(key: String): NativeOverlayPageHostView? = registry[key]?.get() + + internal fun allHosts(): List = registry.values.mapNotNull { it.get() } + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostViewManager.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostViewManager.kt new file mode 100644 index 000000000..c80f684b6 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostViewManager.kt @@ -0,0 +1,40 @@ +package com.onekey.nativeoverlay + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RNCNativeOverlayPageHostManagerDelegate +import com.facebook.react.viewmanagers.RNCNativeOverlayPageHostManagerInterface + +/** No React children: entries are attached natively by `NativeOverlayView`. */ +@ReactModule(name = NativeOverlayPageHostViewManager.NAME) +class NativeOverlayPageHostViewManager : + SimpleViewManager(), + RNCNativeOverlayPageHostManagerInterface { + private val delegate = + RNCNativeOverlayPageHostManagerDelegate(this) + + override fun getName(): String = NAME + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun createViewInstance(context: ThemedReactContext) = NativeOverlayPageHostView(context) + + override fun onDropViewInstance(view: NativeOverlayPageHostView) { + view.invalidateHost() + super.onDropViewInstance(view) + } + + override fun setHostKey(view: NativeOverlayPageHostView, value: String?) { + view.hostKey = value.orEmpty() + } + + override fun setSuspendedOwners(view: NativeOverlayPageHostView, value: String?) { + view.setSuspendedOwners(value) + } + + companion object { + const val NAME = "RNCNativeOverlayPageHost" + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt new file mode 100644 index 000000000..326219300 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt @@ -0,0 +1,465 @@ +package com.onekey.nativeoverlay + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.res.Configuration +import android.graphics.Color +import android.provider.Settings +import android.util.TypedValue +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent +import com.facebook.react.bridge.Arguments +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.events.EventDispatcher +import kotlin.math.abs + +/** Visual state of the content between "hidden" (off stage) and identity. */ +private data class OverlayVisualState( + val alpha: Float = 1f, + val translationX: Float = 0f, + val translationY: Float = 0f, + val scale: Float = 1f, +) { + fun lerp(to: OverlayVisualState, t: Float) = OverlayVisualState( + alpha + (to.alpha - alpha) * t, + translationX + (to.translationX - translationX) * t, + translationY + (to.translationY - translationY) * t, + scale + (to.scale - scale) * t, + ) + + fun applyTo(view: View) { + view.alpha = alpha.coerceIn(0f, 1f) + view.translationX = translationX + view.translationY = translationY + view.scaleX = scale + view.scaleY = scale + } + + companion object { + val IDENTITY = OverlayVisualState() + + fun of(view: View) = OverlayVisualState(view.alpha, view.translationX, view.translationY, view.scaleX) + } +} + +/** + * The React-facing view. It never draws: its single React child is moved + * into a [NativeOverlayEntryRootView] inside the activity's + * [NativeOverlayHost] while visible. + */ +class NativeOverlayView( + private val reactContext: ThemedReactContext, +) : ViewGroup(reactContext) { + var visible = false + var level: String = "modal" + var scope: String = "global" + var hostKey: String = "" + var ownerKey: String = "" + var presentation: String = "center" + var stackOrder = 0 + var blocking = true + var dismissOnBackPress = true + var dismissOnBackdropPress = false + var backdropColor: Int? = null + var sheetHeight = 0.0 + var sheetCornerRadius = 24.0 + var showHandle = false + var sheetBackgroundColor: Int? = null + var dismissOnPanDown = true + var animationConfig: String? = null + var onPresented: ((Int) -> Unit)? = null + var onDismissed: ((String) -> Unit)? = null + var onRequestDismiss: ((String) -> Unit)? = null + + /** + * Fabric state of the hand-written shadow node: the window origin (dp) of + * wherever the content is drawn, so `measure` reports on-screen frames. + */ + var stateWrapper: StateWrapper? = null + set(value) { + field = value + pushContentOffset() + } + private var contentOffsetX = 0.0 + private var contentOffsetY = 0.0 + private val contentLayoutListener = + OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> reportContentOffset() } + var eventDispatcher: EventDispatcher? = null + set(value) { + field = value + entry?.eventDispatcher = value + } + + private enum class Phase { HIDDEN, ENTERING, SHOWN, EXITING } + + private var phase = Phase.HIDDEN + private var contentChild: View? = null + private var entry: NativeOverlayEntryRootView? = null + private var host: NativeOverlayHost? = null + private var pageHost: NativeOverlayPageHostView? = null + private var contentAnimator: ValueAnimator? = null + private var backdropAnimator: ValueAnimator? = null + private var parsedConfigSource: String? = null + private var config = OverlayAnimationConfig() + private var cycleOpen = false + + init { + visibility = INVISIBLE + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) = Unit + + fun addReactChild(child: View) { + contentChild?.takeIf { it !== child }?.let(::removeReactChild) + contentChild = child + entry?.let { attachContent(it) } ?: stageContent() + } + + fun getReactChildCount(): Int = if (contentChild == null) 0 else 1 + + fun getReactChildAt(index: Int): View? = if (index == 0) contentChild else null + + fun removeReactChild(child: View) { + if (contentChild !== child) return + (child.parent as? ViewGroup)?.removeView(child) + contentChild = null + } + + fun commitConfiguration() { + if (parsedConfigSource != animationConfig) { + parsedConfigSource = animationConfig + config = OverlayAnimationConfig.parse(animationConfig) + } + entry?.let(::applyEntryConfiguration) + if (visible) { + cycleOpen = true + if (phase == Phase.HIDDEN || phase == Phase.EXITING) present() + } else if (cycleOpen) { + when (phase) { + Phase.ENTERING, Phase.SHOWN -> dismiss() + Phase.HIDDEN -> { + cycleOpen = false + onDismissed?.invoke("programmatic") + } + Phase.EXITING -> Unit + } + } + } + + fun onDropViewInstance() { + if (phase != Phase.HIDDEN) finishDismiss(null) + onPresented = null + onDismissed = null + onRequestDismiss = null + eventDispatcher = null + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + commitConfiguration() + } + + // Detach from window (screen removed, activity destroyed) ends the overlay. + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // A page overlay's owner screen detaches when another screen covers it; + // the page host hides the entry, it is not closed. + if (phase != Phase.HIDDEN && scope != "page") finishDismiss("system") + } + + private fun present() { + val activity = reactContext.currentActivity ?: return + if (!isAttachedToWindow && scope != "page") return + val overlayHost = NativeOverlayHost.of(activity) + val target = entry ?: makeEntry() + applyEntryConfiguration(target) + if (scope == "page") { + val page = NativeOverlayPageHostView.host(hostKey) ?: return + target.dispatchesJsTouches = false + target.levelOrder = NativeOverlayLevel.from(level).order + target.ownerKey = ownerKey + pageHost = page + page.attach(target) + // Back for page entries is resolved through the activity interceptor. + overlayHost.installBackInterceptor() + } else { + host = overlayHost + overlayHost.attach(target, NativeOverlayLevel.from(level)) + } + attachContent(target) + + val transition = config.enter.reduceMotionAdjusted(isReduceMotionEnabled()) + if (phase == Phase.HIDDEN) { + hiddenState(target, transition).applyTo(target.animatedView) + target.backdropView.alpha = 0f + } + cancelAnimators() + phase = Phase.ENTERING + target.isShownForInput = true + host?.onEntriesChanged() + if (blocking) { + target.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) + } + if (transition.kind == "none") { + OverlayVisualState.IDENTITY.applyTo(target.animatedView) + target.backdropView.alpha = 1f + phase = Phase.SHOWN + reportContentOffset() + onPresented?.invoke(stackOrder) + return + } + animate(target, OverlayVisualState.IDENTITY, 1f, transition.motion) { + if (phase == Phase.ENTERING) { + phase = Phase.SHOWN + reportContentOffset() + onPresented?.invoke(stackOrder) + } + } + } + + private fun dismiss() { + val target = entry ?: return finishDismiss("programmatic") + cancelAnimators() + phase = Phase.EXITING + target.isShownForInput = false + host?.onEntriesChanged() + val transition = config.exit.reduceMotionAdjusted(isReduceMotionEnabled()) + if (transition.kind == "none") return finishDismiss("programmatic") + animate(target, hiddenState(target, transition), 0f, transition.motion) { + if (phase == Phase.EXITING) finishDismiss("programmatic") + } + } + + /** `reason == null` tears down without notifying JS (view dropped). */ + private fun finishDismiss(reason: String?) { + cancelAnimators() + phase = Phase.HIDDEN + entry?.let { target -> + target.isShownForInput = false + stageContent() + host?.detach(target) + pageHost?.detach(target) + } + entry = null + host = null + pageHost = null + if (reason == null || !cycleOpen) return + cycleOpen = visible + onDismissed?.invoke(reason) + } + + private fun animate( + target: NativeOverlayEntryRootView, + to: OverlayVisualState, + backdropTo: Float, + motion: OverlayMotion, + onEnd: () -> Unit, + ) { + // Start from the current values so an interrupted run retargets smoothly. + val animated = target.animatedView + val from = OverlayVisualState.of(animated) + contentAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = motion.durationMs() + interpolator = motion.interpolator() + addUpdateListener { from.lerp(to, it.animatedValue as Float).applyTo(animated) } + addListener(object : AnimatorListenerAdapter() { + private var cancelled = false + + override fun onAnimationCancel(animation: Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (!cancelled) onEnd() + } + }) + start() + } + val backdropFrom = target.backdropView.alpha + val backdropMotion = config.backdropMotion + backdropAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = backdropMotion.durationMs() + interpolator = backdropMotion.interpolator() + addUpdateListener { + val t = it.animatedValue as Float + target.backdropView.alpha = (backdropFrom + (backdropTo - backdropFrom) * t).coerceIn(0f, 1f) + } + start() + } + } + + private fun cancelAnimators() { + contentAnimator?.cancel() + backdropAnimator?.cancel() + contentAnimator = null + backdropAnimator = null + } + + /** The off-stage state: where enter starts and exit ends. */ + private fun hiddenState(target: NativeOverlayEntryRootView, transition: OverlayTransition): OverlayVisualState { + val alpha = if (transition.hidesOpacity) 0f else 1f + val extent = target.contentExtent() + val density = resources.displayMetrics.density + val isSheet = target.sheetView != null + return when (transition.kind) { + "slide" -> { + val travel = transition.distanceDp?.let { (it * density).toFloat() } ?: when { + // The sheet may not be laid out yet; its own height is the travel. + isSheet && transition.edge == "bottom" -> + maxOf(target.sheetHeightPx(), (sheetHeight * density).toInt()).toFloat() + else -> when (transition.edge) { + "top" -> extent.bottom.toFloat() + "left" -> extent.right.toFloat() + "right" -> (target.width - extent.left).toFloat() + else -> (target.height - extent.top).toFloat() + } + } + when (transition.edge) { + "top" -> OverlayVisualState(alpha, 0f, -travel, 1f) + "left" -> OverlayVisualState(alpha, -travel, 0f, 1f) + "right" -> OverlayVisualState(alpha, travel, 0f, 1f) + else -> OverlayVisualState(alpha, 0f, travel, 1f) + } + } + "scale" -> { + // Scale around the content's center, not the full-window root's. + target.animatedView.pivotX = extent.exactCenterX() - target.animatedView.left + target.animatedView.pivotY = extent.exactCenterY() - target.animatedView.top + OverlayVisualState(alpha, 0f, (transition.offsetYDp * density).toFloat(), transition.scale) + } + else -> OverlayVisualState(alpha = alpha) + } + } + + private fun makeEntry(): NativeOverlayEntryRootView { + val target = NativeOverlayEntryRootView(reactContext) + target.eventDispatcher = eventDispatcher + target.onRequestDismiss = { reason -> onRequestDismiss?.invoke(reason) } + target.onKeyboardShiftSettled = { reportContentOffset() } + target.onSheetHiddenByPan = { + if (phase == Phase.SHOWN || phase == Phase.ENTERING) { + // Already off screen: tear down, then let JS close the entry; the + // next visible=false commit reports onDismissed. + finishDismiss(null) + onRequestDismiss?.invoke("pan") + } + } + entry = target + return target + } + + /** + * Without an explicit color the sheet follows the theme (iOS uses + * `systemBackground`); a fixed white showed as a strip under dark content + * in the navigation bar inset. + */ + private fun defaultSheetBackgroundColor(): Int { + val theme = (context as? ThemedReactContext)?.currentActivity?.theme ?: context.theme + val value = TypedValue() + if ( + theme.resolveAttribute(android.R.attr.colorBackground, value, true) && + value.type >= TypedValue.TYPE_FIRST_COLOR_INT && + value.type <= TypedValue.TYPE_LAST_COLOR_INT + ) { + return value.data + } + val nightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return if (nightMode == Configuration.UI_MODE_NIGHT_YES) Color.BLACK else Color.WHITE + } + + private fun applyEntryConfiguration(target: NativeOverlayEntryRootView) { + target.stackOrder = stackOrder + target.updateBlocking(blocking) + target.dismissOnBackPress = dismissOnBackPress + target.dismissOnBackdropPress = dismissOnBackdropPress + target.backdropView.setBackgroundColor(backdropColor ?: Color.TRANSPARENT) + if (presentation == "sheet") { + val density = resources.displayMetrics.density + target.configureSheet( + heightPx = (sheetHeight * density).toInt().coerceAtLeast(1), + cornerRadiusPx = (sheetCornerRadius * density).toFloat(), + backgroundColor = sheetBackgroundColor ?: defaultSheetBackgroundColor(), + showHandle = showHandle, + draggable = dismissOnPanDown, + ) + } + } + + /** + * While hidden, keep the React child inside this invisible view. Fabric only + * reports onLayout for attached views, and fitted sheets size themselves + * from that measurement before presenting. + */ + private fun stageContent() { + val child = contentChild ?: return + if (child.parent === this) return + moveContent(child, this) + } + + private fun attachContent(target: NativeOverlayEntryRootView) { + val child = contentChild ?: return + if (child.parent === target.contentView) return + moveContent(child, target.contentView) + } + + private fun moveContent(child: View, container: ViewGroup) { + (child.parent as? ViewGroup)?.let { previous -> + previous.removeOnLayoutChangeListener(contentLayoutListener) + previous.removeView(child) + } + container.addView(child) + container.addOnLayoutChangeListener(contentLayoutListener) + reportContentOffset() + } + + /** Window origin of the content's container, ignoring running animations. */ + private fun reportContentOffset() { + val container = contentChild?.parent as? View ?: return + var x = 0f + var y = 0f + var current: View? = container + while (current != null) { + x += current.left + y += current.top + // The keyboard lift is a resting position, unlike animation transforms. + if (current is NativeOverlayKeyboardLayer) { + x += current.translationX + y += current.translationY + } + val parent = current.parent as? View + if (parent != null) { + x -= parent.scrollX + y -= parent.scrollY + } + current = parent + } + val density = resources.displayMetrics.density + contentOffsetX = (x / density).toDouble() + contentOffsetY = (y / density).toDouble() + pushContentOffset() + } + + private fun pushContentOffset() { + val wrapper = stateWrapper ?: return + val data = wrapper.stateData + if (data != null && data.hasKey("x") && data.hasKey("y") && + abs(data.getDouble("x") - contentOffsetX) < 0.5 && + abs(data.getDouble("y") - contentOffsetY) < 0.5 + ) { + return + } + wrapper.updateState( + Arguments.createMap().apply { + putDouble("x", contentOffsetX) + putDouble("y", contentOffsetY) + }, + ) + } + + private fun isReduceMotionEnabled(): Boolean = + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayViewManager.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayViewManager.kt new file mode 100644 index 000000000..c4cdc92db --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayViewManager.kt @@ -0,0 +1,156 @@ +package com.onekey.nativeoverlay + +import android.view.View +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper +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.viewmanagers.RNCNativeOverlayManagerDelegate +import com.facebook.react.viewmanagers.RNCNativeOverlayManagerInterface + +@ReactModule(name = NativeOverlayViewManager.NAME) +class NativeOverlayViewManager : + ViewGroupManager(), + RNCNativeOverlayManagerInterface { + private val delegate = RNCNativeOverlayManagerDelegate(this) + + override fun getName(): String = NAME + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun createViewInstance(context: ThemedReactContext): NativeOverlayView { + val view = NativeOverlayView(context) + fun dispatch(event: (surfaceId: Int) -> com.facebook.react.uimanager.events.Event<*>) { + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent(event(UIManagerHelper.getSurfaceId(context))) + } + view.onPresented = { stackOrder -> + dispatch { NativeOverlayPresentedEvent(it, view.id, stackOrder) } + } + view.onDismissed = { reason -> + dispatch { NativeOverlayReasonEvent(it, view.id, NativeOverlayReasonEvent.DISMISSED, reason) } + } + view.onRequestDismiss = { reason -> + dispatch { NativeOverlayReasonEvent(it, view.id, NativeOverlayReasonEvent.REQUEST_DISMISS, reason) } + } + return view + } + + override fun addEventEmitters(context: ThemedReactContext, view: NativeOverlayView) { + view.eventDispatcher = UIManagerHelper.getEventDispatcher(context) + } + + override fun updateState( + view: NativeOverlayView, + props: ReactStylesDiffMap, + stateWrapper: StateWrapper, + ): Any? { + view.stateWrapper = stateWrapper + return null + } + + override fun onAfterUpdateTransaction(view: NativeOverlayView) { + super.onAfterUpdateTransaction(view) + view.commitConfiguration() + } + + override fun onDropViewInstance(view: NativeOverlayView) { + view.onDropViewInstance() + super.onDropViewInstance(view) + } + + override fun addView(parent: NativeOverlayView, child: View, index: Int) { + parent.addReactChild(child) + } + + override fun getChildCount(parent: NativeOverlayView): Int = parent.getReactChildCount() + + override fun getChildAt(parent: NativeOverlayView, index: Int): View? = parent.getReactChildAt(index) + + override fun removeView(parent: NativeOverlayView, view: View) { + parent.removeReactChild(view) + } + + override fun removeAllViews(parent: NativeOverlayView) { + parent.getReactChildAt(0)?.let(parent::removeReactChild) + } + + override fun removeViewAt(parent: NativeOverlayView, index: Int) { + parent.getReactChildAt(index)?.let(parent::removeReactChild) + } + + override fun setVisible(view: NativeOverlayView, value: Boolean) { + view.visible = value + } + + override fun setLevel(view: NativeOverlayView, value: String?) { + view.level = value ?: "modal" + } + + override fun setScope(view: NativeOverlayView, value: String?) { + view.scope = value ?: "global" + } + + override fun setHostKey(view: NativeOverlayView, value: String?) { + view.hostKey = value.orEmpty() + } + + override fun setOwnerKey(view: NativeOverlayView, value: String?) { + view.ownerKey = value.orEmpty() + } + + override fun setPresentation(view: NativeOverlayView, value: String?) { + view.presentation = value ?: "center" + } + + override fun setStackOrder(view: NativeOverlayView, value: Int) { + view.stackOrder = value + } + + override fun setBlocking(view: NativeOverlayView, value: Boolean) { + view.blocking = value + } + + override fun setDismissOnBackPress(view: NativeOverlayView, value: Boolean) { + view.dismissOnBackPress = value + } + + override fun setDismissOnBackdropPress(view: NativeOverlayView, value: Boolean) { + view.dismissOnBackdropPress = value + } + + override fun setBackdropColor(view: NativeOverlayView, value: Int?) { + view.backdropColor = value + } + + override fun setSheetHeight(view: NativeOverlayView, value: Double) { + view.sheetHeight = value + } + + override fun setSheetCornerRadius(view: NativeOverlayView, value: Double) { + view.sheetCornerRadius = value + } + + override fun setShowHandle(view: NativeOverlayView, value: Boolean) { + view.showHandle = value + } + + override fun setSheetBackgroundColor(view: NativeOverlayView, value: Int?) { + view.sheetBackgroundColor = value + } + + override fun setDismissOnPanDown(view: NativeOverlayView, value: Boolean) { + view.dismissOnPanDown = value + } + + override fun setAnimationConfig(view: NativeOverlayView, value: String?) { + view.animationConfig = value + } + + companion object { + const val NAME = "RNCNativeOverlay" + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/jni/CMakeLists.txt b/native-views/react-native-native-overlay/android/src/main/jni/CMakeLists.txt new file mode 100644 index 000000000..00119a092 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/jni/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE ON) + +# Replaces the codegen library so the hand-written overlay shadow node +# (common/cpp) is compiled and registered. Mirrors react-native-screens. +set(LIB_LITERAL RNCNativeOverlay) +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_COMMON_COMPONENTS_DIR ${LIB_COMMON_DIR}/react/renderer/components/${LIB_LITERAL}) +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 ${LIB_COMMON_COMPONENTS_DIR}/*.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} + OBJECT + ${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} + fbjni + jsi + reactnative +) + +target_compile_reactnative_options(${LIB_TARGET_NAME} PRIVATE) diff --git a/native-views/react-native-native-overlay/android/src/main/jni/RNCNativeOverlay.h b/native-views/react-native-native-overlay/android/src/main/jni/RNCNativeOverlay.h new file mode 100644 index 000000000..c07f89f89 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/jni/RNCNativeOverlay.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +// Shadows the codegen header of the same name so autolinking, which +// includes it before ComponentDescriptors.h, sees the hand-written +// RNCNativeOverlayComponentDescriptor. +#include + +namespace facebook::react { + +JSI_EXPORT +std::shared_ptr RNCNativeOverlay_ModuleProvider( + const std::string &moduleName, + const JavaTurboModule::InitParams ¶ms); + +} // namespace facebook::react diff --git a/native-views/react-native-native-overlay/babel.config.js b/native-views/react-native-native-overlay/babel.config.js new file mode 100644 index 000000000..c7008d526 --- /dev/null +++ b/native-views/react-native-native-overlay/babel.config.js @@ -0,0 +1 @@ +module.exports = { presets: ['module:@react-native/babel-preset'] }; diff --git a/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayComponentDescriptor.h b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayComponentDescriptor.h new file mode 100644 index 000000000..6ac897862 --- /dev/null +++ b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayComponentDescriptor.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include "NativeOverlayShadowNode.h" + +namespace facebook::react { + +using RNCNativeOverlayComponentDescriptor = + ConcreteComponentDescriptor; + +} // namespace facebook::react diff --git a/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.cpp b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.cpp new file mode 100644 index 000000000..951e70229 --- /dev/null +++ b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.cpp @@ -0,0 +1,12 @@ +#include "NativeOverlayShadowNode.h" + +namespace facebook::react { + +extern const char RNCNativeOverlayComponentName[] = "RNCNativeOverlay"; + +Point RNCNativeOverlayShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + return getStateData().contentOffset; +} + +} // namespace facebook::react diff --git a/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.h b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.h new file mode 100644 index 000000000..9d1bea941 --- /dev/null +++ b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayShadowNode.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +#include "NativeOverlayState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNCNativeOverlayComponentName[]; + +class JSI_EXPORT RNCNativeOverlayShadowNode final + : public ConcreteViewShadowNode< + RNCNativeOverlayComponentName, + RNCNativeOverlayProps, + RNCNativeOverlayEventEmitter, + NativeOverlayState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + /* + * Like : the content is drawn outside the host's tree position, so + * measurements stop at this node (a root) and the host is never culled. + */ + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::RootNodeKind); + traits.set(ShadowNodeTraits::Trait::Unstable_uncullableView); + return traits; + } + + /* + * Measurements start at this node's origin, which native reports as the + * window origin of wherever it presented the content. + */ + Point getContentOriginOffset(bool includeTransform) const override; +}; + +} // namespace facebook::react diff --git a/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayState.h b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayState.h new file mode 100644 index 000000000..27aaea14a --- /dev/null +++ b/native-views/react-native-native-overlay/common/cpp/react/renderer/components/RNCNativeOverlay/NativeOverlayState.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#ifdef RN_SERIALIZABLE_STATE +#include +#endif + +namespace facebook::react { + +/* + * Native moves an overlay's content out of the host view into its level + * window, sheet, or page host. `contentOffset` is the window origin of the + * view the content now lives in (0,0 for a full-window level; the sheet top + * for a sheet), so `measure` / `measureInWindow` report on-screen frames. + */ +class NativeOverlayState final { + public: + NativeOverlayState() = default; + explicit NativeOverlayState(Point contentOffset) + : contentOffset(contentOffset) {} + + Point contentOffset{}; + +#ifdef RN_SERIALIZABLE_STATE + NativeOverlayState( + const NativeOverlayState & /*previousState*/, + folly::dynamic data) + : contentOffset( + {static_cast(data["x"].getDouble()), + static_cast(data["y"].getDouble())}) {} + + folly::dynamic getDynamic() const { + return folly::dynamic::object("x", contentOffset.x)("y", contentOffset.y); + } +#endif +}; + +} // namespace facebook::react diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md new file mode 100644 index 000000000..fca20825a --- /dev/null +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -0,0 +1,693 @@ +# react-native-native-overlay SPEC + +Status: Implemented through the P5 caller migration in app-monorepo. Android +and Web runtime checks passed there; iOS runtime, physical-device and iPad +acceptance remain unverified. Packaging in app-modules is validated separately. + +The implementation is migrated from app-monorepo commit +`376633bd9f1c410dbccb01fb6a0492be9a5c3f38`, package +`packages/react-native-native-overlay`. This independent package owns the native +and Web overlay hosts. Caller migrations remain in app-monorepo. + +### Template exception and migration boundary + +The package was initialized with `yarn create:view native-overlay` on +2026-10-01, from app-modules main `5b4574bf0d2a5fdd394b7bd81dd1c8c0d15d101f`. +The checked-in template is Nitro; the verified implementation uses Fabric +codegen components and custom shadow nodes to preserve React subtrees, native +hit testing and `measureInWindow` across overlay windows. Rewriting it to Nitro +would change those boundaries and requires separate design/runtime validation. +This SPEC records the explicit template exception allowed by +`docs/NATIVE_MODULE_DEVELOPMENT.md` section 3. + +Keep the generated package/configuration structure, but replace the template's +Nitro sample view, nitrogen config/bindings and CMake adapter with the existing +Fabric Android, iOS and custom C++ implementation. The package has no Nitro +peer dependency or nitrogen build step. Builder Bob emits JavaScript and +TypeScript declarations; React Native generates the Fabric bindings at app +build time. Publish `common/cpp`, `src`, `lib`, Android/iOS sources, podspec and +autolinking configuration. Web consumers additionally provide `react-dom`. + +`react-native-native-sheet` remains separately published for existing consumers; +its compatibility shim/deprecation is a subsequent migration step, not a +silent API change in this release. Sheet behavior derived from that package +retains its original implementation attribution in source comments. + +### Release boundary + +This branch aligns all 42 publishable workspaces to `3.0.162-alpha.268`. +Publish with the existing `package-publish` workflow dispatch, `npm_dist_tag=next` +and an empty `only_workspace`, which publishes and registry-verifies all 42. +Do not merge either repository PR or change the `latest` tag. The app-monorepo +workspace copy is removed only after this release succeeds and every target +version and tarball is available. Versions already published on npm are immutable. + +## 5.1 Purpose, scope, and non-goals + +The package owns **where an overlay is drawn, which overlay is on top, and how +it enters and exits** on iOS, Android, and Web (web, desktop, extension). + +Callers are `@onekeyhq/components` (Dialog, Toast, Popover, ActionList, Select, +DatePicker) and `@onekeyhq/kit` global containers: hardware stage and dialogs, +password verify, DialogLoading, app lock, Spotlight, in-app notification. + +Non-goals: + +- Business UI. Titles, buttons, forms, toast cards, the hardware stage morph and + the lock screen UI stay React content owned by components and kit. +- Navigation. Screens and navigation modals stay in react-navigation and + react-native-screens. +- Third-party windows such as RN `Modal`, WalletConnect, and system dialogs. + They are only handled defensively by the `lock` level (§5.7). + +## 5.2 Definitions and ownership boundaries + +| Term | Meaning | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **scope** | `global`: attached to the app window. `page`: owned by one screen, rendered in that screen's root-route host. | +| **root-route host** | A single `PageOverlayHost` rendered as the last child of each root-stack route (Main tabs, Modal, Onboarding, FullScreenPush, WebView). Because it sits there, page overlays cover native headers and the tab bar. | +| **owner page** | The nearest screen that requested a page overlay. The overlay is visible only while its owner is on top inside its root route. | +| **level** | Fixed layer. `modal` 100 < `hardware` 200 < `secure` 300 < `toast` 400 < `lock` 500 < `debug` 900. | +| **lane** | The entries sharing (scope, host, level). Strategies apply per lane. | +| **strategy** | What a new entry does to its lane. `stack` (default) appears on top. `queue` waits until the lane is empty and is ordered by `priority`. `replace` closes the top entry, or the entries sharing `replaceKey`. | +| **presentation** | Frame and default motion: `sheet`, `center`, `fullscreen`, `toast`, `anchored`. | + +Ordering rule: + +- Every page entry renders below every global entry. +- Inside a scope, entries are ordered by level, then by request order. +- Nothing else affects order: not mount order, portal order, or window add + order. + +Ownership: + +- **JS store** (`OverlayStore`, one per JS runtime, `main` only) owns ordering, + queues, page visibility, and back/Escape resolution. +- **Native hosts** own drawing, hit testing, animation, keyboard, and back-key + capture, and report lifecycle back to the store. +- **Content** is the caller's React subtree. The overlay view stays where the + caller rendered it. Native code reparents its content into the host, so React + context is preserved; there is no root-siblings portal. + +The package MUST NOT infer anything from product data. Levels and strategies +come only from props. + +## 5.3 Public API and defaults + +### Store (implemented) + +- `overlayStore.request(request, { onRemoved })` returns an `IOverlayEntry`. + - Defaults: `scope: 'global'`, `level: 'modal'`, `strategy: 'stack'`, + `priority: 0`. + - `blocking` defaults to false for `toast` and `debug`, and true for every + other level. + - `dismissible` defaults to the value of `blocking`. + - `page` scope requires `hostKey` and `ownerKey`. Missing either throws + `OverlayRequestError`. + - Re-requesting an id that already exists is a no-op. +- `dismiss(id, reason)` moves an entry to `closing`. `finalize(id)` removes it + after the native exit animation. A closing entry that is never finalized is + removed after 5 s. +- `dismissAll({ scope, belowLevel, reason })`, `setPageVisible(ownerKey, +visible)`, `removePage(ownerKey)`. +- `resolveBack(focusedHostKey?)` returns `dismiss(id)`, `block`, or `pass`. +- `getBlockingTop()` returns the entry that makes everything below it inert. +- `subscribe` and `getSnapshot` are compatible with `useSyncExternalStore`. + +### Component (P1) + +```tsx + +``` + +- `usePageOverlayHost()` resolves `hostKey` and `ownerKey` from navigation + context. +- `useInPageDialog`, `useInModalDialog`, and `useInTabDialog` become aliases of + page scope. Tab scope is removed. + +### Animation (implemented model) + +- `animation.enter` and `animation.exit` are each + `{ type: none|fade|slide|scale, edge, distance, scale, offsetY, fade, origin: center|anchor, motion }`. +- `exit` defaults to `enter`. +- `motion` is a preset name or a concrete `{ type: 'spring', spring: { mass, stiffness, damping, overshootClamping } }` + or `{ type: 'timing', timing: { durationMs, easing: cubicBezier } }`. +- Presets are resolved per platform: + - Native mirrors the Reanimated driver: `quick` is a spring with m 0.1, + k 100, c 20 (damping ratio ≈ 3.16). + - Web mirrors the CSS driver: `quick` is 150 ms `cubic-bezier(.25,.1,.25,1)`. + - Converting native springs for web is opt-in (`springToCssLinear`), because + it would slow web overlays down 4–9×. +- Presentation defaults (`DEFAULT_OVERLAY_ANIMATIONS`): + +| Presentation | Enter / exit | Backdrop | +| ------------ | -------------------------------------------------------------------------------- | ------------- | +| sheet | slide from bottom, `quick` | fade, `quick` | +| center | scale 0.85 + fade, `quick` | fade, `quick` | +| toast | slide from top + fade, `toastSlide` (native 300 ms quad in-out, web 400 ms ease) | none | +| fullscreen | fade, `quick` | none | +| anchored | scale 0.95 + fade from the resolved anchor placement, `popoverQuick` | fade | + +- Special cases the callers set explicitly: + - App lock: `enter: none`, `exit: fade lockFade`. The web tamper check reloads + the page if the lock fades in from opacity 0. + - Hardware stage (MorphOverlay), TradingView fullscreen, ScreenshotBranding, + DevOverlay: `presentation: fullscreen`, `enter`/`exit: none`, transparent, + touches pass through where content is `box-none`. MorphOverlay keeps its + Reanimated springs. The container must not animate alpha, transform, or + frame while its content is animating. + - Custom toast (`Toast.show`): `scale 0.8 + offsetY -20`, `quick`, with an + opt-in blocking backdrop. + +## 5.4 Lifecycle and concurrency + +Lifecycle: + +``` +request ─► queued ─► active ─► (enter anim) presented + │ + dismiss / replace / page-removed / back / backdrop / pan + â–¼ + closing ─► (exit anim) onDismissed ─► finalize ─► onRemoved +``` + +- Content stays mounted until `finalize`. This replaces today's hardcoded + timers: the Dialog 300 ms portal destroy, the ColorPicker 300 ms, the + ActionList and Select 150 ms waits, and the DialogLoading 50 ms delays. +- A queued entry is promoted only after the previous entry in its lane is + finalized, so exit and enter never overlap. `replace` is the exception: the + new entry enters while the replaced one exits. +- Reopening an entry that is still closing retargets the running animation from + its current value. It does not restart. +- `onRemoved` fires exactly once per entry, even when a finalize and the + timeout race. +- Page entries: + - They are suspended (hidden but kept) while the owner page is covered or + detached. + - Queued page entries are not promoted into a hidden page. + - `removePage` closes them with reason `page-removed`. +- Hide signals must come from native, because tab stacks use `freezeOnBlur` + and frozen React trees receive no updates: + - iOS: the owner `RNSScreen`'s `viewWillDisappear` / `viewWillAppear`. + - Android: the owner `ScreenFragment`'s view detach / attach. + - Web: navigation focus. +- Threads: store mutations happen on the JS thread. Native animation and layout + run on the UI thread. Events are delivered as direct events. + +## 5.5 Data, cache, and identity + +- Identity is the entry `id`: caller-provided, otherwise `overlay-`. +- Nothing is persisted. The store is per runtime and empty on reload. +- `replaceKey` is the only identity shared across entries. + +## 5.6 Platform contract + +| Concern | iOS | Android | Web | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Global level host | One passthrough `UIWindow` per active level per `UIWindowScene`, ordered by `windowLevel`. `lock` sits above RN Alert (2001). | One `OverlayHost` FrameLayout in `android.R.id.content` above the ReactRootView, with one child container per level. | Sibling `div[data-onekey-layer]` roots under `body`, one z-index band per level (`OVERLAY_WEB_Z_INDEX_BASE`). | +| Order within a level | The window's presentation chain (sheet) and subview order (other presentations). | Child order. | DOM order. | +| Global sheet | `UISheetPresentationController`, presented from the level window's root VC (reuses native-sheet). | View sheet: CoordinatorLayout + BottomSheetBehavior + dimming view (native-sheet reworked off `BottomSheetDialog`). | Custom sheet: pointer drag, snap by velocity, `visualViewport` keyboard. | +| Page host | Fabric `PageOverlayHost` as the last child of the root-route screen. Custom-drawn sheet, because UIKit sheets cannot be page-scoped. | The same host inside the root-route screen. The ReactRootView already dispatches touches, so no nested RootView. | `position: absolute` host inside the root-route card. | +| Header / tab bar coverage (page) | Covered: the host is above the tab controller and navigation bars. | Covered. | Covered. | +| Touch passthrough (toast, debug, box-none) | `hitTest` returns nil on the window or root view. | A non-blocking entry hit-tests its React content with `TouchTargetHelper` (honoring `box-none`) and falls through to the FrameLayout sibling on a miss. | `pointer-events: none` root, `auto` per entry. | +| Back / Escape | Escape via `accessibilityPerformEscape`. The interactive pop gesture is disabled while a blocking page overlay is shown. | A `Window.Callback` wrapper consumes `KEYCODE_BACK` while a blocking overlay is shown. `ReactActivity.onBackPressed` hands back to JS BackHandler (react-navigation) before the `OnBackPressedDispatcher`, and the app opts out of predictive back, so a dispatcher callback alone never runs first. The dispatcher callback is kept for the predictive-back path. The IME still gets back first to close the keyboard. | Capture-phase `keydown` that ignores IME composition. | +| Modality / accessibility | `accessibilityViewIsModal` on the blocking level window; `.screenChanged` posted on present. | `IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS` on the content below. | `inert` on the app root and lower layers, `role=dialog`, `aria-modal`, focus trap and restore. | +| Keyboard | System sheet avoidance; `keyboardLayoutGuide` for custom presentations. The main window regains key status on dismiss. | `WindowInsetsAnimationCompat` per sheet (the app uses `adjustPan`). | `visualViewport`. | +| Animation engine | `UIViewPropertyAnimator` + `UISpringTimingParameters(mass:stiffness:damping:)`, which maps 1:1 and supports damping ratio > 1. | `SpringAnimation` with `stiffness = k/m` and `dampingRatio`; `PathInterpolator` for timing curves. | WAAPI `element.animate`. Timing presets use `cubic-bezier`; custom springs use `linear()`. | +| Content hosting | `RCTSurfaceTouchHandler` on the host root. A state-driven shadow node sizes Yoga, which replaces native-sheet's forced `frame`. A nested `SafeAreaProvider`. | A `RootView` + `JSTouchDispatcher` container for global levels. A state-driven shadow node, like RN Modal. | `createPortal`. The Tamagui theme class is set on the layer root, and React event bubbling stops at the entry root. | + +Known divergences: + +| Divergence | Status | +| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| iOS global sheet (system physics) vs. iOS page sheet (custom physics) | Accepted (decision A). Revisit after on-device comparison. | +| RN `Modal` and third-party dialogs on Android are windows above every layer | Accepted, mitigated by the lock policy (§5.7). | +| Web anchored origin follows the resolved placement after flip; today's code uses the requested placement | Intentional fix. | + +## 5.7 Failure, fallback, and safety + +- A page request without host or owner keys throws `OverlayRequestError`. This + is a programming error; there is no silent global fallback, unlike today's + `Portal.Render` retry. +- A closing entry is removed after 5 s even if native never reports the end of + its exit animation. +- Lock level: + - iOS: the lock window sits above everything this app creates. + - Android: when lock shows, the app's foreign windows are found with + `WindowInspector.getGlobalWindowViews()` (API 29+) and hidden or + dismissed. An alternative is to make lock a focus-owning `ComponentDialog` + that re-raises itself. + - Web: layers below lock get `inert`. Third-party roots are left alone. +- System view controllers (document picker, PHPicker, share sheet, + ASWebAuthenticationSession anchor) must be presented through + `OverlayPresenterResolver` on iOS, so they appear above the overlay windows. + `lock` is never lowered. +- No secrets or content are logged. Logs carry only entry id, level, scope and + reason. + +## 5.8 Performance and resource budget + +- iOS level windows are created lazily per active level and hidden when empty. + There are at most 6 per scene. +- Android layers are views, not windows: no extra surfaces or WindowManager IPC. +- Store operations are O(n) in the number of live entries. Snapshots are + rebuilt once per mutation. +- Animations run entirely on the UI thread. JS receives only lifecycle events: + no per-frame bridge traffic. +- Content height changes animate only between settled measurements (the + two-frame stable pattern from Popover). Continuous per-frame height changes, + such as the DatePicker month-row interpolation, are clipped inside the + content. + +## 5.9 Conformance and acceptance + +Automated (P0): + +- `src/__tests__/OverlayStore.test.ts` covers: + - level ordering regardless of request order; + - page below global; + - `stack`, `queue` (priority, lane isolation, promotion after finalize) and + `replace` (top entry and `replaceKey`); + - the closing timeout and single `onRemoved`; + - page suspend, restore and removal; + - back resolution (global first, `block`, `pass`, focused host) and the + blocking top. +- `src/__tests__/animation.test.ts` covers: + - the spring response in all damping regimes, overshoot clamping and settle + time; + - Android `SpringForce` conversion and CSS `linear()` sampling; + - presentation defaults and per-platform preset resolution. + +Runtime matrix, required per platform before migrating callers (P1–P3): + +1. Show sheet, toast, hardware dialog, password and lock in all 5! orders. The + final z-order is always level order. +2. A queue of 3 hardware dialogs with a priority insert. +3. `replace` DialogLoading text updates without flicker. +4. Page overlay: + - push another screen: the overlay hides; + - pop: it restores; + - switch tab and come back: it restores; + - remove the page: it closes with `page-removed`; + - the native header and tab bar are covered. +5. Back/Escape reaches the topmost blocking overlay; the lock swallows it. +6. Keyboard: an input in a global sheet, a page sheet and a secure dialog. +7. iOS: a system picker opened from an overlay appears above it. +8. Split view: page overlays stay in their own pane. +9. Reduce motion degrades to fade. + +P3 page scope notes: + +- kit wraps every root route component (`withOverlayPageHost`) with + `OverlayPageHostScope` and renders `OverlayPageHost` after the navigator, + keyed by the root route key. components `Page` wraps its content in + `PageOverlayOwner`, keyed by the screen's route key. +- Visibility: `PageOverlayOwner` listens to the screen's `focus` / `blur` + events (they fire even while `freezeOnBlur` freezes the React tree) and + reports `setPageVisible(owner, activeWithinHost)`. "Active within host" + checks every navigator up to, but not including, the root stack, so a root + modal over Main does not hide Main's page overlays; a push or tab switch + does. Unmounting the page calls `removePage`. +- The owner may be frozen, so the host applies suspension: `OverlayPageHost` + subscribes to the store and hides entries of suspended owners natively + (`suspendedOwners`) or in the DOM (`visibility: hidden` + `inert`). Page + entries keep `visible=true` while suspended; they are not dismissed. +- Page entries live inside the React surface: iOS does not attach the + overlay's own `RCTSurfaceTouchHandler`, Android disables the entry's + `JSTouchDispatcher` (the ReactRootView already dispatches), otherwise every + touch would be delivered twice. +- iOS page sheets are drawn by the entry (bottom-pinned content view, pan to + close) because UIKit sheets always cover the window. Android and Web reuse + their global sheet implementation inside the page host. +- Android back also resolves page entries: global overlays first, then the + topmost visible page overlay. +- A page request without a resolvable host or owner falls back to global + scope. + +P2 sheet notes: + +- iOS presents the sheet (`.pageSheet` + one custom detent, ported from + native-sheet) from the top of its level window's presentation chain. + Non-sheet entries are subviews of the same window, so within a level, + sheets and center / toast entries stack by request order. Dismissing a + lower sheet makes UIKit dismiss the sheets presented above it; those report + `onDismissed('system')` and the store closes them. +- Android hosts the sheet in a CoordinatorLayout + BottomSheetBehavior inside + the level container (no BottomSheetDialog window). Enter / exit use the + overlay motion; dragging fades the backdrop; a drag to hidden reports + `pan`. +- Web renders a bottom surface with pointer drag (40% distance or 0.5 px/ms + flick dismisses, never from scrolled content). +- A fitted sheet measures its content with `onLayout` before presenting. + Hidden content stays mounted inside the zero-width host (clipped on iOS, + INVISIBLE on Android), and the host height stays auto: a fixed 0 height + makes Yoga measure the content at most 0 tall. +- `keepContentMounted` renders content while closed so callers can measure it + before opening (Popover waits for header / scroll measurements). +- `pan` dismissals cannot be vetoed; `onRequestDismiss('pan')` is informative. + +P4a toast notes: + +- Message toasts (`Toast.success` / `message` / `error` …) keep their + libraries (sonner on web, `@backpackapp-io/react-native-toast` on native) + and render inside one persistent, non-blocking `toast`-level fullscreen + entry (kit `ToastOverlayContainer`). The iOS raise tokens are gone. +- Custom toasts (`Toast.show` / `ShowCustom`) are their own `toast` entries: + transparent blocking backdrop (tap closes when `dismissOnOverlayPress`), + `scale 0.8 + offsetY -20 + fade` on `quick`, PanResponder swipe up to + close unless `disableSwipeGesture` (the Tamagui toast ignored the flag). `Toast.show` unmounts its portal on the overlay's `onClose`, after + the exit animation, instead of a 300 ms timer. + +P4b dialog loading notes: + +- Dialog takes an internal `overlayLevel` prop. With it, `DialogFrame` + renders through `OverlayDialogPresentation` on every platform: a sheet + (24 pt corners, no handle) on narrow windows, a centered 400 pt card + otherwise, the theme `$bgBackdrop`, Escape never closes on web, Android + back closes unless `disableSystemClose`. +- The kit DialogLoading container uses `overlayLevel="modal"`: request order + puts it above earlier dialogs, and hardware / password prompts stay above + it. Its 50 ms delays and the iOS remount key are gone; text updates in + place. +- Android sheets pad their content by the navigation-bar inset, matching the + UIKit sheet's home-indicator inset. + +P1 implementation map: + +| Contract | iOS | Android | Web | +| ---------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | +| Level host | `NativeOverlayWindowManager.swift` | `NativeOverlayHost.kt` | `web/overlayLayers.ts` | +| Entry, hit testing, backdrop | `NativeOverlayEntryView` in `NativeOverlayContainerView.swift` | `NativeOverlayEntryRootView.kt` | `WebOverlayEntry` in `OverlayView.web.tsx` | +| Page host | `NativeOverlayPageHostView.swift` | `NativeOverlayPageHostView.kt` | `OverlayPageHost.web.tsx` | +| Sheet | `NativeOverlaySheetController.swift` | `NativeOverlayEntryRootView.configureSheet` | `OverlayView.web.tsx` + `web/useSheetDrag.ts` | +| Enter / exit animation | `NativeOverlayAnimation.swift` (`UIViewPropertyAnimator`) | `NativeOverlayAnimation.kt` (analytic spring interpolator) | `web/animateTransition.ts` (WAAPI) | +| Store bridge | `useOverlayController.ts` | same | same | + +P4c dialog notes: + +- Every Dialog renders through `OverlayDialogPresentation` (sheet on narrow + windows, centered card otherwise); the Tamagui Sheet / Dialog and the + NativeSheetPresentation branches of `DialogFrame` are gone. Removed + `nativeSheet`, `sheetOverlayProps` and JS portal/layering options throw at + the component and imperative API entry points, including object spreads. +- `floatingPanelProps` style the centered card (width, maxWidth, maxHeight, + bg, overflow, radius…); `zIndex`, `sheetOverlayProps`, `sheetProps` + (except `disableDrag`), `modal` and `forceMount` no longer apply. +- Level: `overlayLevel` prop, else from the portal container (`lock` for the + lock screen's container, `secure` for the password prompt's, otherwise + `modal`). `isOverTopAllViews` is redundant: every level is above app + content. +- `useInPageDialog` / `useInTabDialog` pass the page's host and owner keys: + in-page dialogs are page overlays (hidden while the page is covered). +- `Dialog.show` unmounts its portal when the exit animation ends + (`onExited`), with a 1.5 s fallback, instead of a 300 ms timer. +- Web: a blocking page overlay inerts the siblings on the path from its page + host to the app root, not the app root that contains it. +- Native sheets pad the home indicator / navigation bar and lift above the + keyboard natively; the dialog adds no keyboard padding on native (the + bounded login layout still caps its scroll view by the keyboard height). + +P4d hardware / password notes: + +- The hardware stage (MorphOverlay / DeviceStage) renders its portal inside + one persistent, non-blocking `hardware`-level fullscreen entry (kit + `HardwareStageOverlayContainer`); the stage's own wall blocks the app + while it is up, and taps pass through once it is gone. The iOS raise + tokens and the Android split-view offset are gone. +- Hardware dialogs (device confirm, errors, BLE permission, third-party + install / permission) use `overlayLevel: 'hardware'`: above every modal + dialog, ordered by request with the stage. +- Password setup / verify prompts use `overlayLevel: 'secure'`, above the + stage and every dialog; their web z-index and portal workarounds are gone. + +P4e lock screen notes: + +- kit `AppStateContainer` (one file for every platform) hosts the lock + screen and its dialog portal in a persistent blocking `lock`-level + fullscreen entry with no overlay animation (the lock screen keeps its own + AnimatePresence fade). Everything below it stays open and is covered. +- `setSecurityBlocked`, the `security` dismiss reason and + `snapshot.securityBlockedBelow` are removed. +- Dialogs opened into the lock screen's container (forgot passcode, export + logs) take the `lock` level from the container, so they render above the + lock screen; on web in the same `document.body` child the lock screen keeps + interactive (OK-62416). +- Android back is swallowed on the lock screen (blocking, not dismissible); + it no longer reaches navigation behind the lock. +- The web lock tamper check and the lock screen's body-level inert pass are + unchanged; the lock layer root is a `document.body` child. + +P5a native popover notes: + +- Every native Popover / ActionList / Select sheet is a native overlay sheet + (`NativeSheetPresentation`); the Tamagui Popover sheet (`TMPopover.Adapt`), + its external backdrop, z-index and back handler are gone, and + the removed `nativeSheet` option throws. iPad / wide windows keep the 400–480 pt centered + frame inside the sheet. `usingSheet={false}` still renders no panel on + native (web / desktop floating panels). +- The native popover renders inline in the caller's tree (its contexts + apply) instead of through the full-window portal. Every popover remains + mounted until the exit callback (1 s fallback); content then unmounts + unless `keepChildrenMounted`. Reopening cancels pending teardown. +- `closePopover()` waits for exit completion; repeated requests share the + same completion promise, and completion or unmount clears its timer. +- Sheet options are package-owned. Unsupported legacy `sheetProps` keys + and floating-panel `zIndex` / `portalProps` throw instead of being ignored. + +P5b web popover notes: + +- Web Popover / ActionList / Select render in `OverlayView` at the `modal` + level; the Tamagui Popover and `PopoverContent` are gone. + - Below `md` with `usingSheet` (the default): an overlay sheet with a + header and a dismissing backdrop. + - Otherwise: `presentation="anchored"`, non-blocking and without a backdrop. + Floating UI (`@floating-ui/dom`) places the panel: offset, flip, shift, + and a max height from the available space. `autoUpdate` follows scroll + and resize. +- The enter animation scales an ancestor of the panel, so the panel measures + smaller while it runs. The panel is positioned again on `onPresented`. +- Anchored panels close on an outside press. Presses on the trigger, on the + panel, or inside an overlay ranked above it do not close it, so a nested + Select or a Dialog opened from the panel keeps it open. They also close on + Escape, unless `hoverable`. +- `hoverable` opens and closes on mouse enter / leave of the trigger and + panel. The default close delay is 100 ms, so the pointer can cross the gap. +- `keepChildrenMounted` maps to `keepContentMounted`. On web a closed entry + stays in its layer, `visibility: hidden` and `inert`. +- `OverlayStore.resolveBack` also resolves non-blocking entries that are + dismissible, so Escape reaches an anchored panel. + +P5c tooltip and nested-level notes: + +- `OverlayView` provides its level to its content. + - `useEnclosingOverlayLevel()` reads that level. + - `useNestedOverlayLevel(minimum = 'modal')` returns the higher of it and + `minimum`. + - Popover (web anchored and sheet), the native popover sheet + (`NativeSheetPresentation`) and Tooltip open at that level. A popover in a + password prompt or on the lock screen then renders above it, not beneath. +- Web Tooltip renders in `OverlayView`: anchored, non-blocking, no backdrop. + Floating UI places it with offset 6, flip and shift. The Tamagui tooltip is + gone; the trigger is still a Tamagui `View`, so `triggerAsChild` works. + - Hover and keyboard focus (`:focus-visible`) open it; leaving or blurring + the trigger closes it. + - A tap toggles it on touch. + - A mouse/pen press closes it until the pointer leaves (unchanged). + - Escape and an outside press close it. + - Non-`hovering` tooltips let the pointer pass through. `hovering` ones + stay open while the pointer is over the content. + - A controlled `open` wins; requests go to `onOpenChange`. + - Native Tooltip still renders only the trigger. +- iOS: an overlay whose host view lives inside a level window resolves that + window back to the app window. Before this, closing a sheet opened from a + `secure` overlay recursed forever in `preferredStatusBarStyle` + (`_appearingOrAppearedChildModalViewController` stack overflow). The level + root controller also never forwards to itself. + +P5d Spotlight notes: + +- Spotlight renders in a blocking `OverlayView`: `presentation="fullscreen"` + (fade), a 0.3 black backdrop, and the nested level. Back and Escape are + swallowed, and the backdrop does not dismiss it; only "Done" ends the tour. +- It renders inline, so the highlighted copy of the trigger keeps its + contexts. The deferred-trigger plumbing and `SPOTLIGHT_OVERLAY_PORTAL` + (container and enum member) are gone. +- The Windows / Linux desktop -30 px title-bar offset is dropped: the overlay + layer covers the whole window, so `measureInWindow` coordinates line up. + Not yet checked on a Windows / Linux desktop build. + +P5e full-window portal notes: + +- `FULL_WINDOW_OVERLAY_PORTAL` and `TOASTER_OVERLAY_PORTAL` are only mount + roots for the imperative APIs (`Dialog.show`, `Toast.show`, + `ActionList.show`, and other `Portal.Render` callers). What mounts there + renders its own `OverlayView`. `Portal.Body` into either one throws. +- These now render their `OverlayView`-backed dialogs and toasts inline + instead of through `Portal.Body`: LocalDbDowngrade, the hardware and + third-party hardware UI state containers, and ColorPicker's mobile dialog. +- The Perp OrderBook hover summary is a non-blocking, backdrop-less, + unanimated `fullscreen` overlay at `modal`. +- The Market inline action bar (long-press on a watchlist row) is a + `fullscreen` overlay with a transparent, dismissing backdrop. Back now + dismisses it as well. +- `DialogFrame` defaults its level to the nested level, so a Dialog declared + inside another overlay opens above it. `Dialog.show` keeps the level of its + portal container. + +P5f full-window container notes: + +- `OverlayContainer` (react-native-screens `FullWindowOverlay` on iOS) and + `useOverlayZIndex` are removed. `Dialog.show`'s `isOverTopAllViews` is + removed: a dialog's level decides its stacking, and on iOS the flag mounted + the dialog twice. `FullWindowOverlayContainer` now only mounts + self-hosting overlays and the imperative mount roots. +- The TradingView native fullscreen chart keeps its global host, because the + chart owner's page freezes while fullscreen. The host presents each layer + in a non-blocking `fullscreen` overlay at `modal`, without animation, and + back is not dismissed. Insets come from the app's safe-area context. +- ScreenshotBranding is a non-interactive, unanimated overlay at `toast`. +- The dev overlay window and the TradingView debug panel are non-interactive + hosts at `debug`. +- The Market desktop fullscreen chart stays in place (a remount would reload + TradingView), fixed at `OVERLAY_WEB_Z_INDEX_BASE.modal - 1`. +- iOS: a touched level window becomes key, and React Native reads its window + dimensions from the key window. After a rotation while one was key (the + fullscreen chart), JS kept the landscape size, so the page stayed in its + wide layout and the native header stayed hidden. A level window now + re-reports the dimensions when it resigns key or resizes while key, by + re-assigning the app window's frame (React Native observes it). + +Measurement: + +- `RNCNativeOverlay` has a hand-written shadow node (`common/cpp`, codegen + `interfaceOnly`), like ``: it is a `RootNodeKind` (measurements stop + at it) and `Unstable_uncullableView`. Its state holds the window origin of + the view the content currently lives in (level window, sheet, page host, + or the host itself while staged), returned from + `getContentOriginOffset`, so Fabric `measure` / `measureInWindow` report + on-screen frames. +- iOS reports the origin after presenting, on entry / sheet layout, and after + restaging; Android on the same events plus container layout changes. Both + read model geometry, so a running enter / exit transform is ignored. +- Web measures the DOM and needs nothing extra. +- Android builds the codegen library from `android/src/main/jni/CMakeLists.txt` + (as react-native-screens does); `jni/RNCNativeOverlay.h` shadows the + generated header so autolinking sees the custom descriptor. + +Keyboard avoidance: + +- One rule on every platform (`computeKeyboardShift` on web, + `NativeOverlayKeyboardShift` on iOS, `updateKeyboardShift` on Android): a + sheet rises until its content bottom clears the keyboard; other content + rises until its bottom is 16 pt above it; nothing rises above the top safe + area, so full-window content (toaster host, lock screen) never moves. +- The lift lives on a keyboard layer between the entry and its animated + content, so it composes with enter / exit transforms, and `measure` + includes it. +- iOS: a shared tracker follows `keyboardWillChangeFrame` from the first + overlay host on, animated with the keyboard's own curve; UIKit sheets keep + their native avoidance. +- Android: each entry reads IME insets (applied insets at rest, the insets + animation per frame, nothing consumed); the app runs edge-to-edge through + react-native-keyboard-controller, so the window never pans for overlays. +- Web: `visualViewport` resize / scroll on mobile browsers. +- Center dialogs put the card itself at the overlay root (auto margins), so + the native content extent is the card, not a full-window wrapper. + +Status bar: + +- `statusBarStyle` (`light` | `dark`) on an overlay; the topmost shown, + unsuspended entry that sets one owns the status bar + (`findStatusBarOwner`, render order: level, then request order). +- The owner mounts a React Native ``, the same stack the pages + use: the app runs without view-controller-based status bar appearance on + iOS, and Android sets the window appearance. Web has no status bar. +- iOS asks the root view controller of level windows above the status bar + (`lock`, `debug`) anyway; without view-controller-based appearance it + answers with the app-wide style, so the StatusBar stack still decides. + +Migration rules (decided 2026-09-28): + +- No transition period. Every overlay (Dialog, Sheet, Toast, DialogLoading, + hardware stage and dialogs, password prompts, lock screen, Popover, + ActionList, Select, Spotlight) moves onto this package, and the JS + overlays (Tamagui Dialog / Sheet / Popover portals, `Portal.Container` + overlay hosts, `useOverlayZIndex`) are removed. No lint rule: each old + entry point that has to stay exported throws at runtime once its last + caller migrates, so a leftover or new use fails at once (2026-09-29). +- The global Portal exists only for the JS overlays; it is removed once + its last caller migrates. +- The app lock is unified with the rest: the lock screen renders at the + `lock` level and covers everything below it. Overlays are no longer + closed on lock (done in P4e). +- Every presentation avoids the keyboard (center, sheet, page sheet) on + iOS, Android, and web. +- Fabric `measure` / `measureInWindow` inside an overlay must report the + on-screen frame. +- The status bar follows the topmost full-screen overlay (`statusBarStyle`). +- Custom toasts honor `disableSwipeGesture`. + +Accepted limitations: + +- The iOS page sheet is drawn by the entry (UIKit sheets always cover the + window): it has no grabber and does not coordinate its pan with inner + scroll views. Page sheets should keep scrollable content short or pass + `dismissOnPanDown={false}`. +- Custom toasts stack by request order and overlap at the top, as before; + message toasts stack inside their libraries. + +Future work (better to have): + +- VoiceOver / TalkBack cross-window modality, verified on device. +- Screen-reader toasts: the native toast library renders nothing under a + screen reader today; the toast host should announce content and extend + its timer instead. + +Known gaps: + +- `statusBarStyle` follows React Native's StatusBar stack, so a page that + mounts a new `` while an overlay owns the style, or the + imperative theme calls (`StatusBar.setBarStyle`), take over until the owner + changes; rare while a full-screen overlay covers the page. +- The podspec normalizes an npm prerelease version to its stable numeric core. + npm remains pinned to the exact prerelease; local CocoaPods never uses a + prerelease version string in its path-based dependency resolver. + + +## Package migration conformance audit (2026-10-01) + +- Template deviations: Fabric codegen/custom shadow nodes replace Nitro sample + code and bindings as documented above. The template's ESLint configuration + uses an unregistered Prettier plugin; use RN's exported ESLint 9 flat config + and a separate Prettier check. Import CodegenTypes from RN's public namespace + so the package typechecks under the template's strict API condition. +- Behavioral contract: no new overlay behavior in this migration. Formatting, + public codegen type imports and build metadata preserve the implementation + from the recorded app-monorepo commit. Native-sheet API remains intact. +- Passed: standalone typecheck; three Jest suites / 39 tests; lint (zero errors, + 11 advisory style warnings); Builder Bob JS/declaration build; npm tarball + contents including custom C++, Web JS, declarations and autolinking files; + 36 release-tool tests including all four overlay consumer migrations; + repository test-integrity check; immutable Yarn installation. +- Passed: full Android example `:app:assembleDebug` for arm64-v8a, including + the overlay Fabric schema, Kotlin and custom C++ autolinking. +- Passed: example CocoaPods install with NativeOverlay and the synchronized + prerelease packages. This is dependency/codegen evidence, not iOS execution. +- Previous app-monorepo Android/Web interaction evidence remains recorded in + the handoff. iOS runtime, physical devices, iPad, screen-reader cross-window + modality and real password-lock flows remain outstanding; CI adds packaging + checks and builds this pod through the example application. diff --git a/native-views/react-native-native-overlay/eslint.config.mjs b/native-views/react-native-native-overlay/eslint.config.mjs new file mode 100644 index 000000000..414af732a --- /dev/null +++ b/native-views/react-native-native-overlay/eslint.config.mjs @@ -0,0 +1,13 @@ +import reactNativeConfig from '@react-native/eslint-config/flat'; + +export default [ + { + ignores: [ + '**/node_modules/**', + '**/lib/**', + '**/*.config.js', + '**/*.config.mjs', + ], + }, + ...reactNativeConfig, +]; diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayAnimation.swift b/native-views/react-native-native-overlay/ios/NativeOverlayAnimation.swift new file mode 100644 index 000000000..f02d93a6b --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlayAnimation.swift @@ -0,0 +1,160 @@ +import UIKit + +/// Mirrors `IOverlayMotion` from `src/animation/types.ts`. +enum NativeOverlayMotion { + case spring(mass: CGFloat, stiffness: CGFloat, damping: CGFloat) + case timing(duration: TimeInterval, controlPoint1: CGPoint, controlPoint2: CGPoint) + + /// Tamagui `quick` on native. + static let quick = NativeOverlayMotion.spring(mass: 0.1, stiffness: 100, damping: 20) + + init(json: Any?) { + guard let dict = json as? [String: Any], let type = dict["type"] as? String else { + self = .quick + return + } + if type == "timing", + let timing = dict["timing"] as? [String: Any], + let durationMs = (timing["durationMs"] as? NSNumber)?.doubleValue { + let easing = (timing["easing"] as? [NSNumber])?.map { CGFloat($0.doubleValue) } ?? [] + let points = easing.count == 4 ? easing : [0.25, 0.1, 0.25, 1] + self = .timing( + duration: durationMs / 1000, + controlPoint1: CGPoint(x: points[0], y: points[1]), + controlPoint2: CGPoint(x: points[2], y: points[3]) + ) + return + } + if type == "spring", let spring = dict["spring"] as? [String: Any] { + self = .spring( + mass: CGFloat((spring["mass"] as? NSNumber)?.doubleValue ?? 1), + stiffness: CGFloat((spring["stiffness"] as? NSNumber)?.doubleValue ?? 100), + damping: CGFloat((spring["damping"] as? NSNumber)?.doubleValue ?? 20) + ) + return + } + self = .quick + } + + func makeAnimator() -> UIViewPropertyAnimator { + switch self { + case let .spring(mass, stiffness, damping): + // (mass:stiffness:damping:) maps 1:1 to Reanimated and, unlike the + // dampingRatio initializer, accepts ratios above 1 (`quick` is ~3.16). + let parameters = UISpringTimingParameters( + mass: mass, + stiffness: stiffness, + damping: damping, + initialVelocity: .zero + ) + return UIViewPropertyAnimator(duration: 0, timingParameters: parameters) + case let .timing(duration, controlPoint1, controlPoint2): + let parameters = UICubicTimingParameters( + controlPoint1: controlPoint1, + controlPoint2: controlPoint2 + ) + return UIViewPropertyAnimator(duration: duration, timingParameters: parameters) + } + } +} + +/// Mirrors `IResolvedOverlayTransition`. +struct NativeOverlayTransition { + enum Kind: String { + case none + case fade + case slide + case scale + } + + enum Edge: String { + case top + case bottom + case left + case right + } + + var kind: Kind = .fade + var edge: Edge = .bottom + var distance: CGFloat? + var scale: CGFloat = 0.95 + var offsetY: CGFloat = 0 + var fade = true + var motion: NativeOverlayMotion = .quick + + init() {} + + init(json: Any?) { + guard let dict = json as? [String: Any] else { return } + kind = Kind(rawValue: dict["type"] as? String ?? "") ?? .fade + edge = Edge(rawValue: dict["edge"] as? String ?? "") ?? .bottom + distance = (dict["distance"] as? NSNumber).map { CGFloat($0.doubleValue) } + scale = CGFloat((dict["scale"] as? NSNumber)?.doubleValue ?? 0.95) + offsetY = CGFloat((dict["offsetY"] as? NSNumber)?.doubleValue ?? 0) + fade = (dict["fade"] as? NSNumber)?.boolValue ?? (kind == .fade || kind == .scale) + motion = NativeOverlayMotion(json: dict["motion"]) + } + + /// Reduce Motion keeps the timing but drops movement. + var accessibilityAdjusted: NativeOverlayTransition { + guard UIAccessibility.isReduceMotionEnabled, kind != .none else { return self } + var copy = self + copy.kind = .fade + copy.fade = true + return copy + } + + var hidesOpacity: Bool { kind == .fade || fade } + + /// The off-stage transform: where enter starts and exit ends. + func hiddenTransform(contentExtent: CGRect, bounds: CGRect) -> CGAffineTransform { + switch kind { + case .none, .fade: + return .identity + case .slide: + let travel: CGFloat + if let distance { + travel = distance + } else { + switch edge { + case .top: travel = contentExtent.maxY + case .bottom: travel = bounds.height - contentExtent.minY + case .left: travel = contentExtent.maxX + case .right: travel = bounds.width - contentExtent.minX + } + } + switch edge { + case .top: return CGAffineTransform(translationX: 0, y: -travel) + case .bottom: return CGAffineTransform(translationX: 0, y: travel) + case .left: return CGAffineTransform(translationX: -travel, y: 0) + case .right: return CGAffineTransform(translationX: travel, y: 0) + } + case .scale: + // Scale around the content's center, not the full-window wrapper's. + let dx = contentExtent.midX - bounds.midX + let dy = contentExtent.midY - bounds.midY + return CGAffineTransform(translationX: dx, y: dy + offsetY) + .scaledBy(x: scale, y: scale) + .translatedBy(x: -dx, y: -dy) + } + } +} + +/// Mirrors `IResolvedOverlayAnimation`. +struct NativeOverlayAnimationConfig { + var enter = NativeOverlayTransition() + var exit = NativeOverlayTransition() + var backdropMotion: NativeOverlayMotion = .quick + + init() {} + + init(jsonString: String) { + guard !jsonString.isEmpty, + let data = jsonString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dict = object as? [String: Any] else { return } + enter = NativeOverlayTransition(json: dict["enter"]) + exit = dict["exit"] == nil ? enter : NativeOverlayTransition(json: dict["exit"]) + backdropMotion = NativeOverlayMotion(json: (dict["backdrop"] as? [String: Any])?["motion"]) + } +} diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayContainerView.swift b/native-views/react-native-native-overlay/ios/NativeOverlayContainerView.swift new file mode 100644 index 000000000..263bb9ff9 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlayContainerView.swift @@ -0,0 +1,688 @@ +import React +import UIKit + +/// One overlay inside a level window: backdrop plus the reparented content. +final class NativeOverlayEntryView: UIView { + let backdropView = UIView() + /// Carries the keyboard avoidance lift, so it composes with the enter / + /// exit transforms animated on `contentView`. + let keyboardLayer = UIView() + let contentView = UIView() + var stackOrder = 0 + var levelOrder = 0 + /// Page scope: the owning page, used by the page host to hide the entry. + var ownerKey = "" + var blocking = true + var dismissOnBackPress = true + var onEscape: (() -> Void)? + var onBackdropTap: (() -> Void)? + /// Page sheet: the drag closed it past the threshold. + var onPanDismiss: (() -> Void)? + + /// Page scope cannot use UIKit sheets (they always cover the window), so a + /// page sheet is this entry's content view pinned to the bottom. + private(set) var pageSheetHeight: CGFloat? + private var pageSheetPan: UIPanGestureRecognizer? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + backdropView.frame = bounds + backdropView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + backdropView.alpha = 0 + keyboardLayer.frame = bounds + keyboardLayer.autoresizingMask = [.flexibleWidth, .flexibleHeight] + keyboardLayer.backgroundColor = .clear + contentView.frame = bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentView.backgroundColor = .clear + addSubview(backdropView) + addSubview(keyboardLayer) + keyboardLayer.addSubview(contentView) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackdropTap)) + tap.cancelsTouchesInView = false + backdropView.addGestureRecognizer(tap) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardDidChange), + name: NativeOverlayKeyboard.didChange, + object: nil + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Touches that miss the React content either hit the backdrop (blocking) + /// or fall through to the windows below (toast, debug). + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hit = super.hitTest(point, with: event) else { return nil } + // The React root is `pointerEvents="box-none"`, so Fabric returns nil for + // it and the plain content view reports itself when nothing was hit. + if hit === self || hit === keyboardLayer || hit === contentView || hit === backdropView { + return blocking ? backdropView : nil + } + return hit + } + + override func accessibilityPerformEscape() -> Bool { + guard blocking else { return false } + if dismissOnBackPress { + onEscape?() + } + return true + } + + @objc private func handleBackdropTap() { + onBackdropTap?() + } + + func configurePageSheet( + height: CGFloat, + backgroundColor: UIColor, + cornerRadius: CGFloat, + draggable: Bool + ) { + pageSheetHeight = height + contentView.backgroundColor = backgroundColor + contentView.layer.cornerRadius = cornerRadius + contentView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + contentView.layer.cornerCurve = .continuous + contentView.clipsToBounds = true + contentView.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + layoutPageSheet() + if draggable, pageSheetPan == nil { + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePageSheetPan(_:))) + contentView.addGestureRecognizer(pan) + pageSheetPan = pan + } + pageSheetPan?.isEnabled = draggable + } + + /// Layout changed (rotation, page sheet height); the owner re-reports + /// where its content is. + var onLayout: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + layoutPageSheet() + updateKeyboardShift(animated: false) + onLayout?() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateKeyboardShift(animated: false) + } + + // MARK: - Keyboard + + private var keyboardShift: CGFloat = 0 + + @objc private func keyboardDidChange() { + updateKeyboardShift(animated: true) + } + + /// Lifts the content above the software keyboard (see + /// `NativeOverlayKeyboardShift`). A full-window content never moves. + func updateKeyboardShift(animated: Bool) { + let keyboard = NativeOverlayKeyboard.shared + let isSheet = pageSheetHeight != nil + // The resting frame, independent of the running enter / exit transform. + let contentFrame: CGRect + if isSheet { + let height = min(pageSheetHeight ?? 0, bounds.height) + contentFrame = CGRect(x: 0, y: bounds.height - height, width: bounds.width, height: height) + } else { + contentFrame = contentExtent() + } + let shift = NativeOverlayKeyboardShift.compute( + keyboardTop: window == nil ? nil : keyboard.top(in: self), + contentFrame: contentFrame, + contentBottom: contentFrame.maxY, + isSheet: isSheet, + safeTop: safeAreaInsets.top + ) + guard abs(shift - keyboardShift) >= 0.5 else { return } + keyboardShift = shift + let apply = { self.keyboardLayer.transform = CGAffineTransform(translationX: 0, y: -shift) } + guard animated else { + apply() + onLayout?() + return + } + let animator = UIViewPropertyAnimator(duration: keyboard.duration, curve: keyboard.curve, animations: apply) + animator.addCompletion { [weak self] _ in self?.onLayout?() } + animator.startAnimation() + } + + private func layoutPageSheet() { + guard let height = pageSheetHeight else { return } + let sheetHeight = min(height, bounds.height) + // Keep the transform while animating; only the resting frame changes. + let transform = contentView.transform + contentView.transform = .identity + contentView.frame = CGRect(x: 0, y: bounds.height - sheetHeight, width: bounds.width, height: sheetHeight) + contentView.transform = transform + } + + @objc private func handlePageSheetPan(_ pan: UIPanGestureRecognizer) { + let travel = max(pan.translation(in: self).y, 0) + let height = max(contentView.bounds.height, 1) + switch pan.state { + case .changed: + contentView.transform = CGAffineTransform(translationX: 0, y: travel) + backdropView.alpha = 1 - min(travel / height, 1) + case .ended, .cancelled: + let velocity = pan.velocity(in: self).y + if pan.state == .ended, travel > height * 0.4 || velocity > 1000 { + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { + self.contentView.transform = CGAffineTransform(translationX: 0, y: height) + self.backdropView.alpha = 0 + } completion: { _ in + self.onPanDismiss?() + } + } else { + UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0) { + self.contentView.transform = .identity + self.backdropView.alpha = 1 + } + } + default: + break + } + } + + /// Union of the rendered React children; the root React view fills the window. + func contentExtent() -> CGRect { + let roots = contentView.subviews + let rects = roots.flatMap { root in + root.subviews.map { root.convert($0.frame, to: contentView) } + } + guard var union = rects.first else { return bounds } + rects.dropFirst().forEach { union = union.union($0) } + return union + } +} + +@objc public final class NativeOverlayContainerView: UIView { + @objc public var visible = false + @objc public var level = "modal" + @objc public var scope = "global" + @objc public var hostKey = "" + @objc public var ownerKey = "" + @objc public var presentation = "center" + @objc public var stackOrder = 0 + @objc public var blocking = true + @objc public var dismissOnBackPress = true + @objc public var dismissOnBackdropPress = false + @objc public var backdropColor: UIColor? + @objc public var sheetHeight: CGFloat = 0 + @objc public var sheetCornerRadius: CGFloat = 24 + @objc public var showHandle = false + @objc public var sheetBackgroundColor: UIColor? + @objc public var dismissOnPanDown = true + @objc public var animationConfig = "" + @objc public var touchHandler: UIGestureRecognizer? + @objc public var onPresented: RCTDirectEventBlock? + @objc public var onDismissed: RCTDirectEventBlock? + @objc public var onRequestDismiss: RCTDirectEventBlock? + /// Window origin (points) of the view the content lives in; the shadow + /// node offsets `measure` / `measureInWindow` by it. + @objc public var onContentOffsetChanged: ((CGPoint) -> Void)? + private var reportedContentOffset: CGPoint? + + private enum Phase { + case hidden + case entering + case shown + case exiting + } + + private var phase: Phase = .hidden + private weak var contentChild: UIView? + private var entryView: NativeOverlayEntryView? + private var sheetController: NativeOverlaySheetController? + private weak var hostWindow: UIWindow? + private var isSheet: Bool { presentation == "sheet" } + private var isPageScope: Bool { scope == "page" } + /// UIKit sheets only for global scope; page sheets are drawn by the entry. + private var usesSystemSheet: Bool { isSheet && !isPageScope } + private var animator: UIViewPropertyAnimator? + private var backdropAnimator: UIViewPropertyAnimator? + private var parsedConfigSource: String? + private var config = NativeOverlayAnimationConfig() + /// True between a visible=true commit and the matching onDismissed. + private var cycleOpen = false + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + commitConfiguration() + } else if phase != .hidden && !isPageScope { + // A page overlay's owner screen leaves the window when another screen + // is pushed over it; the page host hides the entry, it is not closed. + finishDismiss(reason: "system") + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + // The staged child lives in this zero-size view while hidden. + clipsToBounds = true + // Start tracking the keyboard with the first overlay host (the toaster + // host mounts at launch), so later entries know a keyboard already up. + _ = NativeOverlayKeyboard.shared + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc public func insertChild(_ child: UIView, atIndex index: Int) { + contentChild = child + child.removeFromSuperview() + if let sheetController, sheetController.isViewLoaded { + sheetController.view.addSubview(child) + } else if let entryView { + entryView.contentView.addSubview(child) + } else { + stageContent() + } + reportContentOffset() + } + + /// Keeps the React child attached (inside this clipped, zero-size view) + /// while hidden, so Fabric keeps reporting its layout; fitted sheets size + /// themselves from that measurement before presenting. + private func stageContent() { + guard let child = contentChild, child.superview !== self else { return } + child.removeFromSuperview() + addSubview(child) + } + + @objc public func removeChild(_ child: UIView) { + guard contentChild === child else { return } + child.removeFromSuperview() + contentChild = nil + } + + @objc public func commitConfiguration() { + if parsedConfigSource != animationConfig { + parsedConfigSource = animationConfig + config = NativeOverlayAnimationConfig(jsonString: animationConfig) + } + if let entryView { + applyEntryConfiguration(entryView) + } + if let sheetController, phase == .entering || phase == .shown { + sheetController.update(style: sheetStyle(), animated: phase == .shown) + } + if visible { + cycleOpen = true + // A sheet that is still animating out is re-presented after it finishes. + if phase == .hidden || (phase == .exiting && !usesSystemSheet) { + present() + } + } else if cycleOpen { + if phase == .entering || phase == .shown { + dismiss() + } else if phase == .hidden { + cycleOpen = false + onDismissed?(["reason": "programmatic"]) + } + } + } + + @objc public func invalidate() { + if phase != .hidden { + finishDismiss(reason: nil) + } + onPresented = nil + onDismissed = nil + onRequestDismiss = nil + } + + // MARK: - Presentation + + private func present() { + guard let overlayLevel = NativeOverlayLevel(rawValue: level) else { return } + let entry: NativeOverlayEntryView + if isPageScope { + guard let pageHost = NativeOverlayPageHostView.host(for: hostKey) else { return } + entry = entryView ?? makeEntryView(in: pageHost) + entry.levelOrder = overlayLevel.order + entry.ownerKey = ownerKey + applyEntryConfiguration(entry) + pageHost.insert(entry) + } else { + guard let appWindow = window, + let host = NativeOverlayWindowManager.shared.window(for: overlayLevel, appWindow: appWindow) + else { return } + hostWindow = host + if isSheet { + presentSheet(in: host) + return + } + entry = entryView ?? makeEntryView(in: host) + applyEntryConfiguration(entry) + insert(entry, into: host) + } + if let child = contentChild, child.superview !== entry.contentView { + entry.contentView.addSubview(child) + } + entry.layoutIfNeeded() + + let transition = config.enter.accessibilityAdjusted + if phase == .hidden { + entry.contentView.alpha = transition.hidesOpacity ? 0 : 1 + entry.contentView.transform = transition.hiddenTransform( + contentExtent: entry.contentExtent(), + bounds: entry.bounds + ) + entry.backdropView.alpha = 0 + } + stopAnimators() + phase = .entering + if blocking { + UIAccessibility.post(notification: .screenChanged, argument: entry) + } + + if transition.kind == .none { + entry.contentView.alpha = 1 + entry.contentView.transform = .identity + entry.backdropView.alpha = 1 + phase = .shown + didPresent() + return + } + let contentAnimator = transition.motion.makeAnimator() + contentAnimator.addAnimations { + entry.contentView.alpha = 1 + entry.contentView.transform = .identity + } + contentAnimator.addCompletion { [weak self] position in + guard let self, position == .end, self.phase == .entering else { return } + self.phase = .shown + self.didPresent() + } + let backdrop = config.backdropMotion.makeAnimator() + backdrop.addAnimations { entry.backdropView.alpha = 1 } + animator = contentAnimator + backdropAnimator = backdrop + contentAnimator.startAnimation() + backdrop.startAnimation() + } + + private func dismiss() { + if usesSystemSheet || sheetController != nil { + dismissSheet() + return + } + guard let entry = entryView else { + finishDismiss(reason: "programmatic") + return + } + stopAnimators() + phase = .exiting + let transition = config.exit.accessibilityAdjusted + guard transition.kind != .none else { + finishDismiss(reason: "programmatic") + return + } + let hiddenTransform = transition.hiddenTransform( + contentExtent: entry.contentExtent(), + bounds: entry.bounds + ) + let contentAnimator = transition.motion.makeAnimator() + contentAnimator.addAnimations { + if transition.hidesOpacity { + entry.contentView.alpha = 0 + } + entry.contentView.transform = hiddenTransform + } + contentAnimator.addCompletion { [weak self] position in + guard let self, position == .end, self.phase == .exiting else { return } + self.finishDismiss(reason: "programmatic") + } + let backdrop = config.backdropMotion.makeAnimator() + backdrop.addAnimations { entry.backdropView.alpha = 0 } + animator = contentAnimator + backdropAnimator = backdrop + contentAnimator.startAnimation() + backdrop.startAnimation() + } + + /// `reason == nil` tears down without notifying JS (view invalidated). + private func finishDismiss(reason: String?) { + stopAnimators() + phase = .hidden + teardownSheet() + if let entry = entryView { + if let touchHandler, touchHandler.view === entry { + touchHandler.perform(NSSelectorFromString("detachFromView:"), with: entry) + } + stageContent() + entry.removeFromSuperview() + } + entryView = nil + stageContent() + reportContentOffset() + NativeOverlayWindowManager.shared.entryRemoved(from: hostWindow) + guard let reason, cycleOpen else { return } + cycleOpen = visible + onDismissed?(["reason": reason]) + // Reopened while the exit animation ran; UIKit-initiated dismissals + // ("system") are left for JS to settle instead. + if reason == "programmatic", visible, window != nil { + present() + } + } + + private func didPresent() { + reportContentOffset() + onPresented?(["stackOrder": stackOrder]) + } + + /// Reports the window origin of the content's current superview (the + /// level window, sheet, page host, or this host while staged). Only at + /// rest: the enter / exit transforms would skew it, while UIKit's own + /// sheet container transform must be included. + func reportContentOffset() { + guard phase == .shown || phase == .hidden, + let container = contentChild?.superview, + let containerWindow = container.window + else { return } + var offset = container.convert(CGPoint.zero, to: nil) + // Overlay level windows and the app window normally share the screen origin. + if let referenceWindow = window, referenceWindow !== containerWindow { + offset.x += containerWindow.frame.origin.x - referenceWindow.frame.origin.x + offset.y += containerWindow.frame.origin.y - referenceWindow.frame.origin.y + } + if let reported = reportedContentOffset, + abs(reported.x - offset.x) < 0.5, abs(reported.y - offset.y) < 0.5 { + return + } + reportedContentOffset = offset + onContentOffsetChanged?(offset) + } + + private func stopAnimators() { + // Freeze at the current presentation values so the next run retargets + // from where the previous one left off. + [animator, backdropAnimator].forEach { running in + guard let running, running.state == .active else { return } + running.stopAnimation(false) + running.finishAnimation(at: .current) + } + animator = nil + backdropAnimator = nil + } + + // MARK: - Sheet + + private func sheetStyle() -> NativeOverlaySheetStyle { + NativeOverlaySheetStyle( + height: max(sheetHeight, 1), + backgroundColor: sheetBackgroundColor ?? .systemBackground, + cornerRadius: max(sheetCornerRadius, 0), + showHandle: showHandle, + backdropColor: backdropColor, + dismissOnBackdropPress: dismissOnBackdropPress, + enterMotion: config.enter.motion, + exitMotion: config.exit.motion + ) + } + + private func presentSheet(in host: UIWindow) { + guard sheetController == nil, + let presenter = NativeOverlayWindowManager.shared.presenter(in: host) else { return } + // UIKit refuses to present while another transition runs in this window. + if presenter.isBeingPresented || presenter.transitionCoordinator != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self, self.visible, self.phase == .hidden else { return } + self.present() + } + return + } + let controller = NativeOverlaySheetController(host: self, style: sheetStyle()) + sheetController = controller + phase = .entering + presenter.present(controller, animated: true) { [weak self, weak controller] in + guard let self, let controller, self.sheetController === controller else { return } + if self.phase == .entering { + self.phase = .shown + self.didPresent() + } + if !self.visible { + self.dismissSheet() + } + } + } + + private func dismissSheet() { + guard let controller = sheetController else { + finishDismiss(reason: "programmatic") + return + } + // Closing mid-presentation is handled by the present completion. + guard !controller.isBeingPresented, phase != .exiting else { return } + phase = .exiting + controller.dismissRequestedByHost = true + controller.dismiss(animated: true) { [weak self, weak controller] in + guard let self, self.sheetController === controller else { return } + self.finishDismiss(reason: "programmatic") + } + } + + private func teardownSheet() { + guard let controller = sheetController else { return } + if let touchHandler, controller.isViewLoaded, touchHandler.view === controller.view { + touchHandler.perform(NSSelectorFromString("detachFromView:"), with: controller.view) + } + stageContent() + if controller.presentingViewController != nil, !controller.isBeingDismissed { + controller.dismissRequestedByHost = true + controller.dismiss(animated: false) + } + sheetController = nil + } + + // MARK: - Entry view + + private func makeEntryView(in host: UIView) -> NativeOverlayEntryView { + let entry = NativeOverlayEntryView(frame: host.bounds) + entry.autoresizingMask = [.flexibleWidth, .flexibleHeight] + entry.onEscape = { [weak self] in + self?.onRequestDismiss?(["reason": "back"]) + } + entry.onBackdropTap = { [weak self] in + guard let self, self.dismissOnBackdropPress, self.phase != .exiting else { return } + self.onRequestDismiss?(["reason": "backdrop"]) + } + entry.onLayout = { [weak self] in + self?.reportContentOffset() + } + entry.onPanDismiss = { [weak self] in + guard let self, self.phase == .shown || self.phase == .entering else { return } + self.finishDismiss(reason: nil) + self.onRequestDismiss?(["reason": "pan"]) + } + // Page hosts live inside the React surface, whose touch handler already + // reaches the moved content; a second handler would double-dispatch. + if !isPageScope, let touchHandler, touchHandler.view == nil { + touchHandler.perform(NSSelectorFromString("attachToView:"), with: entry) + } + entryView = entry + return entry + } + + private func applyEntryConfiguration(_ entry: NativeOverlayEntryView) { + entry.stackOrder = stackOrder + entry.blocking = blocking + entry.dismissOnBackPress = dismissOnBackPress + entry.accessibilityViewIsModal = blocking + entry.backdropView.backgroundColor = backdropColor ?? .clear + if isPageScope && isSheet { + entry.configurePageSheet( + height: max(sheetHeight, 1), + backgroundColor: sheetBackgroundColor ?? .systemBackground, + cornerRadius: max(sheetCornerRadius, 0), + draggable: dismissOnPanDown + ) + } + } + + /// Keeps entries of one level ordered by `stackOrder`. + private func insert(_ entry: NativeOverlayEntryView, into host: UIView) { + if entry.superview === host { return } + entry.frame = host.bounds + let above = host.subviews + .compactMap { $0 as? NativeOverlayEntryView } + .first { $0.stackOrder > entry.stackOrder } + if let above { + host.insertSubview(entry, belowSubview: above) + } else { + host.addSubview(entry) + } + } +} + +extension NativeOverlayContainerView: NativeOverlaySheetHost { + func sheetDidLayout(_ controller: NativeOverlaySheetController) { + guard sheetController === controller else { return } + reportContentOffset() + } + + func sheetDidLoad(_ controller: NativeOverlaySheetController) { + if let touchHandler, touchHandler.view == nil { + touchHandler.perform(NSSelectorFromString("attachToView:"), with: controller.view) + } + if let child = contentChild { + child.removeFromSuperview() + controller.view.addSubview(child) + } + } + + func sheetDidDismissInteractively(_ controller: NativeOverlaySheetController) { + guard sheetController === controller else { return } + // Already off screen: tear down, then let JS close the entry; the next + // visible=false commit reports onDismissed. + finishDismiss(reason: nil) + onRequestDismiss?(["reason": "pan"]) + } + + func sheetDidDisappearUnexpectedly(_ controller: NativeOverlaySheetController) { + guard sheetController === controller else { return } + finishDismiss(reason: "system") + } + + func sheetRequestedDismiss(_ controller: NativeOverlaySheetController, reason: String) { + guard sheetController === controller, phase != .exiting else { return } + if reason == "back", !dismissOnBackPress { return } + onRequestDismiss?(["reason": reason]) + } +} diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayKeyboard.swift b/native-views/react-native-native-overlay/ios/NativeOverlayKeyboard.swift new file mode 100644 index 000000000..a32caf53d --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlayKeyboard.swift @@ -0,0 +1,88 @@ +import UIKit + +/// Tracks the software keyboard frame for every overlay entry, so an entry +/// presented while the keyboard is already up avoids it too. +final class NativeOverlayKeyboard { + static let shared = NativeOverlayKeyboard() + + static let didChange = Notification.Name("NativeOverlayKeyboardDidChange") + + /// Screen coordinates; `.zero` while hidden. + private(set) var frame: CGRect = .zero + private(set) var duration: TimeInterval = 0.25 + private(set) var curve: UIView.AnimationCurve = .easeInOut + + private init() { + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(keyboardWillChange(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardWillChange(_ notification: Notification) { + readAnimation(notification) + let end = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero + frame = end + NotificationCenter.default.post(name: Self.didChange, object: self) + } + + @objc private func keyboardWillHide(_ notification: Notification) { + readAnimation(notification) + frame = .zero + NotificationCenter.default.post(name: Self.didChange, object: self) + } + + private func readAnimation(_ notification: Notification) { + let info = notification.userInfo + duration = info?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + if let raw = info?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int, + let value = UIView.AnimationCurve(rawValue: raw) { + curve = value + } + } + + /// Keyboard top in `view`'s coordinates, or nil when it does not cover it. + func top(in view: UIView) -> CGFloat? { + guard frame.height > 0, let window = view.window else { return nil } + let inWindow = window.convert(frame, from: window.screen.coordinateSpace) + let local = view.convert(inWindow, from: window) + guard local.minY < view.bounds.maxY else { return nil } + return local.minY + } +} + +enum NativeOverlayKeyboardShift { + /// Gap between centered content and the keyboard. + static let margin: CGFloat = 16 + + /** + How far to lift an entry's content (points, >= 0). + + - Sheets rise until their content bottom clears the keyboard. + - Other content rises until its bottom is `margin` above the keyboard. + - Nothing rises above the top safe area; full-window content never moves. + */ + static func compute( + keyboardTop: CGFloat?, + contentFrame: CGRect, + contentBottom: CGFloat, + isSheet: Bool, + safeTop: CGFloat + ) -> CGFloat { + guard let keyboardTop else { return 0 } + let needed = isSheet + ? contentBottom - keyboardTop + : contentFrame.maxY + margin - keyboardTop + let room = contentFrame.minY - safeTop + return max(0, min(needed, room)) + } +} diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayPageHostView.swift b/native-views/react-native-native-overlay/ios/NativeOverlayPageHostView.swift new file mode 100644 index 000000000..8654edee2 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlayPageHostView.swift @@ -0,0 +1,98 @@ +import UIKit + +/// Page-scope overlays of one root route render inside this view. It sits +/// in the React tree (last child of the root-route screen), so the surface's +/// own touch handler already dispatches touches to the moved content. +@objc public final class NativeOverlayPageHostView: UIView { + private struct WeakHost { + weak var value: NativeOverlayPageHostView? + } + + private static var registry: [String: WeakHost] = [:] + + static func host(for key: String) -> NativeOverlayPageHostView? { + registry[key]?.value + } + + @objc public var hostKey = "" { + didSet { + guard hostKey != oldValue else { return } + if !oldValue.isEmpty, Self.registry[oldValue]?.value === self { + Self.registry[oldValue] = nil + } + if !hostKey.isEmpty { + Self.registry[hostKey] = WeakHost(value: self) + } + } + } + + private var suspendedOwners: Set = [] + + @objc public func setSuspendedOwners(_ joined: String) { + let owners = Set(joined.split(separator: "\n").map(String.init)) + guard owners != suspendedOwners else { return } + suspendedOwners = owners + entries().forEach(applySuspension) + } + + override public init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc public func invalidate() { + if !hostKey.isEmpty, Self.registry[hostKey]?.value === self { + Self.registry[hostKey] = nil + } + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit === self ? nil : hit + } + + override public func layoutSubviews() { + super.layoutSubviews() + entries().forEach { $0.frame = bounds } + } + + /// Orders page entries by level, then by request order. + func insert(_ entry: NativeOverlayEntryView) { + if entry.superview !== self { + entry.removeFromSuperview() + entry.frame = bounds + let above = entries().first { Self.sortKey($0) > Self.sortKey(entry) } + if let above { + insertSubview(entry, belowSubview: above) + } else { + addSubview(entry) + } + } + applySuspension(entry) + } + + private func applySuspension(_ entry: NativeOverlayEntryView) { + let hidden = suspendedOwners.contains(entry.ownerKey) + guard entry.isHidden != hidden else { return } + if hidden { + entry.isHidden = true + } else { + entry.alpha = 0 + entry.isHidden = false + UIView.animate(withDuration: 0.15) { entry.alpha = 1 } + } + } + + private func entries() -> [NativeOverlayEntryView] { + subviews.compactMap { $0 as? NativeOverlayEntryView } + } + + private static func sortKey(_ entry: NativeOverlayEntryView) -> (Int, Int) { + (entry.levelOrder, entry.stackOrder) + } +} diff --git a/native-views/react-native-native-overlay/ios/NativeOverlaySheetController.swift b/native-views/react-native-native-overlay/ios/NativeOverlaySheetController.swift new file mode 100644 index 000000000..76c0d89f1 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlaySheetController.swift @@ -0,0 +1,391 @@ +import UIKit + +/// Callbacks from a presented sheet back to its `NativeOverlayContainerView`. +protocol NativeOverlaySheetHost: AnyObject { + var dismissOnPanDown: Bool { get } + func sheetDidLoad(_ controller: NativeOverlaySheetController) + /// The sheet's frame settled (presentation, detent or keyboard change). + func sheetDidLayout(_ controller: NativeOverlaySheetController) + func sheetDidDismissInteractively(_ controller: NativeOverlaySheetController) + /// UIKit dismissed the sheet without being asked, e.g. because a sheet + /// below it in the same level window was dismissed. + func sheetDidDisappearUnexpectedly(_ controller: NativeOverlaySheetController) + func sheetRequestedDismiss(_ controller: NativeOverlaySheetController, reason: String) +} + +struct NativeOverlaySheetStyle { + var height: CGFloat + var backgroundColor: UIColor + var cornerRadius: CGFloat + var showHandle: Bool + var backdropColor: UIColor? + var dismissOnBackdropPress: Bool + var enterMotion: NativeOverlayMotion + var exitMotion: NativeOverlayMotion +} + +/// Slides the sheet with the overlay's own motion instead of UIKit's curve +/// and fades the custom dimming view on the same clock. +private final class NativeOverlaySheetTransitionAnimator: NSObject, + UIViewControllerAnimatedTransitioning { + private let presenting: Bool + private let motion: NativeOverlayMotion + private var animator: UIViewPropertyAnimator? + + init(presenting: Bool, motion: NativeOverlayMotion) { + self.presenting = presenting + self.motion = motion + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + motion.makeAnimator().duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + interruptibleAnimator(using: transitionContext).startAnimation() + } + + func interruptibleAnimator( + using transitionContext: UIViewControllerContextTransitioning + ) -> UIViewImplicitlyAnimating { + if let animator { + return animator + } + let viewKey: UITransitionContextViewKey = presenting ? .to : .from + let controllerKey: UITransitionContextViewControllerKey = presenting ? .to : .from + guard let sheetView = transitionContext.view(forKey: viewKey), + let sheetController = transitionContext.viewController(forKey: controllerKey) + as? NativeOverlaySheetController else { + transitionContext.completeTransition(false) + return motion.makeAnimator() + } + let container = transitionContext.containerView + let finalFrame = presenting ? transitionContext.finalFrame(for: sheetController) : sheetView.frame + let travel = max(container.bounds.maxY - finalFrame.minY, sheetView.bounds.height) + let offscreen = CGAffineTransform(translationX: 0, y: travel) + let reduceMotion = UIAccessibility.isReduceMotionEnabled + + if presenting { + sheetView.frame = finalFrame + if reduceMotion { + sheetView.alpha = 0 + } else { + sheetView.transform = offscreen + } + sheetController.setDimmingAlpha(0) + if sheetView.superview == nil { + container.addSubview(sheetView) + } + } + + let animator = motion.makeAnimator() + animator.addAnimations { + if reduceMotion { + sheetView.alpha = self.presenting ? 1 : 0 + } else { + sheetView.transform = self.presenting ? .identity : offscreen + } + sheetController.setDimmingAlpha(self.presenting ? 1 : 0) + } + animator.addCompletion { position in + let completed = position == .end && !transitionContext.transitionWasCancelled + if !completed { + sheetView.transform = self.presenting ? offscreen : .identity + sheetController.setDimmingAlpha(self.presenting ? 0 : 1) + } + transitionContext.completeTransition(completed) + } + self.animator = animator + return animator + } + + func animationEnded(_ transitionCompleted: Bool) { + animator = nil + } +} + +/// A `.pageSheet` with one custom detent, ported from native-sheet. It is +/// presented from its level window, so sheets of different levels never +/// share a presentation chain. +final class NativeOverlaySheetController: UIViewController, + UIAdaptivePresentationControllerDelegate, + UIViewControllerTransitioningDelegate { + private weak var host: NativeOverlaySheetHost? + private let detentIdentifier = UISheetPresentationController.Detent.Identifier( + "onekey.nativeOverlay.sheet" + ) + private var style: NativeOverlaySheetStyle + private let backgroundView = UIView() + private var dimmingView: UIView? + private var backdropTap: UITapGestureRecognizer? + private var heightAnimator: UIViewPropertyAnimator? + private var shadowSuppressionDisplayLink: CADisplayLink? + /// Set while the host itself asked UIKit to dismiss. + var dismissRequestedByHost = false + + init(host: NativeOverlaySheetHost, style: NativeOverlaySheetStyle) { + self.host = host + self.style = style + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + transitioningDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let rootView = UIView() + rootView.backgroundColor = style.backgroundColor + rootView.clipsToBounds = true + if #available(iOS 26.0, *) { + backgroundView.frame = rootView.bounds + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + backgroundView.backgroundColor = style.backgroundColor + backgroundView.isUserInteractionEnabled = false + rootView.addSubview(backgroundView) + } + view = rootView + } + + deinit { + shadowSuppressionDisplayLink?.invalidate() + dimmingView?.removeFromSuperview() + } + + override func viewDidLoad() { + super.viewDidLoad() + host?.sheetDidLoad(self) + guard let sheet = sheetPresentationController else { return } + let detent = UISheetPresentationController.Detent.custom( + identifier: detentIdentifier + ) { [weak self] context in + min(max(self?.style.height ?? 1, 1), context.maximumDetentValue) + } + if #available(iOS 26.1, *) { + let effect = UIColorEffect(color: style.backgroundColor) + sheet.backgroundEffect = effect + detent.backgroundEffect = effect + } + sheet.detents = [detent] + sheet.selectedDetentIdentifier = detentIdentifier + sheet.largestUndimmedDetentIdentifier = detentIdentifier + sheet.prefersGrabberVisible = style.showHandle + sheet.preferredCornerRadius = style.cornerRadius + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.prefersEdgeAttachedInCompactHeight = true + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = false + presentationController?.delegate = self + isModalInPresentation = !(host?.dismissOnPanDown ?? true) + } + + func update(style next: NativeOverlaySheetStyle, animated: Bool) { + let previousHeight = style.height + style.height = max(next.height, 1) + style.dismissOnBackdropPress = next.dismissOnBackdropPress + style.backdropColor = next.backdropColor + style.exitMotion = next.exitMotion + dimmingView?.backgroundColor = next.backdropColor ?? .clear + isModalInPresentation = !(host?.dismissOnPanDown ?? true) + guard abs(style.height - previousHeight) >= 0.5, + isViewLoaded, + let sheet = sheetPresentationController else { return } + let changes = { [weak self, weak sheet] in + guard let self, let sheet else { return } + sheet.invalidateDetents() + sheet.selectedDetentIdentifier = self.detentIdentifier + sheet.containerView?.layoutIfNeeded() + } + guard animated else { + changes() + return + } + if let heightAnimator { + heightAnimator.stopAnimation(false) + heightAnimator.finishAnimation(at: .current) + } + sheet.containerView?.layoutIfNeeded() + let animator = style.enterMotion.makeAnimator() + heightAnimator = animator + animator.addAnimations(changes) + animator.addCompletion { [weak self, weak animator] _ in + guard let self, self.heightAnimator === animator else { return } + self.heightAnimator = nil + } + animator.startAnimation() + } + + // MARK: Transitions + + func animationController( + forPresented presented: UIViewController, + presenting: UIViewController, + source: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + NativeOverlaySheetTransitionAnimator(presenting: true, motion: style.enterMotion) + } + + func animationController( + forDismissed dismissed: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + NativeOverlaySheetTransitionAnimator(presenting: false, motion: style.exitMotion) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + installDimmingView() + startSuppressingPresentationShadow() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + stopSuppressingPresentationShadow() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopSuppressingPresentationShadow() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if let backdropTap { + dimmingView?.removeGestureRecognizer(backdropTap) + } + backdropTap = nil + dimmingView?.removeFromSuperview() + dimmingView = nil + if presentingViewController == nil && !dismissRequestedByHost { + host?.sheetDidDisappearUnexpectedly(self) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + host?.sheetDidLayout(self) + removePresentationShadow() + if #available(iOS 26.0, *) { + DispatchQueue.main.async { [weak self] in + self?.removePresentationShadow() + } + } + } + + override func accessibilityPerformEscape() -> Bool { + host?.sheetRequestedDismiss(self, reason: "back") + return true + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + host?.dismissOnPanDown ?? true + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dismissRequestedByHost = true + host?.sheetDidDismissInteractively(self) + } + + // MARK: Backdrop + + func setDimmingAlpha(_ alpha: CGFloat) { + dimmingView?.alpha = alpha + } + + private func installDimmingView() { + guard dimmingView == nil, let container = presentationController?.containerView else { return } + let dimmingView = UIView(frame: container.bounds) + dimmingView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dimmingView.backgroundColor = style.backdropColor ?? .clear + dimmingView.alpha = 0 + // Always swallow backdrop touches; a sheet is blocking even without a tap-to-close. + dimmingView.isUserInteractionEnabled = true + container.insertSubview(dimmingView, at: 0) + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleBackdropTap(_:))) + recognizer.cancelsTouchesInView = true + dimmingView.addGestureRecognizer(recognizer) + backdropTap = recognizer + self.dimmingView = dimmingView + } + + @objc private func handleBackdropTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, style.dismissOnBackdropPress else { return } + host?.sheetRequestedDismiss(self, reason: "backdrop") + } + + // MARK: Shadow suppression (same approach as native-sheet) + + private func removePresentationShadow() { + guard let container = presentationController?.containerView else { return } + var ancestor = view.superview + while let current = ancestor, current !== container { + clearShadow(on: current) + ancestor = current.superview + } + removeDropShadowViews(in: container) + } + + private func removeDropShadowViews(in candidate: UIView) { + guard candidate !== view else { return } + let className = NSStringFromClass(type(of: candidate)) + guard className.hasPrefix("UI") || className.hasPrefix("_UI") else { return } + if className.contains("DropShadowView") { + clearShadow(on: candidate) + } + candidate.subviews.forEach(removeDropShadowViews(in:)) + } + + private func clearShadow(on shadowView: UIView) { + CATransaction.begin() + CATransaction.setDisableActions(true) + if #available(iOS 26.0, *), + NSStringFromClass(type(of: shadowView)).contains("UIDropShadowView") { + compensateForPresentationScale(in: shadowView) + } + shadowView.layer.shadowOpacity = 0 + shadowView.layer.shadowRadius = 0 + shadowView.layer.shadowColor = UIColor.clear.cgColor + shadowView.layer.shadowPath = nil + CATransaction.commit() + } + + @available(iOS 26.0, *) + private func compensateForPresentationScale(in shadowView: UIView) { + guard var surfaceView = view else { return } + while let parent = surfaceView.superview, parent !== shadowView { + surfaceView = parent + } + guard surfaceView.superview === shadowView else { return } + let transform = shadowView.transform + guard abs(transform.a) > 0.001, abs(transform.d) > 0.001 else { return } + surfaceView.transform = CGAffineTransform(scaleX: 1 / transform.a, y: 1 / transform.d) + } + + private func startSuppressingPresentationShadow() { + shadowSuppressionDisplayLink?.invalidate() + removePresentationShadow() + let displayLink = CADisplayLink(target: self, selector: #selector(suppressShadowForFrame)) + displayLink.add(to: .main, forMode: .common) + shadowSuppressionDisplayLink = displayLink + if let transitionCoordinator { + transitionCoordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.stopSuppressingPresentationShadow() + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.stopSuppressingPresentationShadow() + } + } + } + + private func stopSuppressingPresentationShadow() { + shadowSuppressionDisplayLink?.invalidate() + shadowSuppressionDisplayLink = nil + removePresentationShadow() + } + + @objc private func suppressShadowForFrame() { + removePresentationShadow() + } +} diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift new file mode 100644 index 000000000..df5361fd2 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -0,0 +1,194 @@ +import UIKit + +/// Levels mirror `OVERLAY_LEVEL_ORDER`. Each active level gets its own +/// window so that order between levels never depends on show order. +enum NativeOverlayLevel: String, CaseIterable { + case modal + case hardware + case secure + case toast + case lock + case debug + + /// Mirrors `OVERLAY_LEVEL_ORDER`. + var order: Int { + switch self { + case .modal: return 100 + case .hardware: return 200 + case .secure: return 300 + case .toast: return 400 + case .lock: return 500 + case .debug: return 900 + } + } + + var windowLevel: UIWindow.Level { + switch self { + // Above the app window (and every VC it presents), below system alerts. + case .modal: return UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 10) + case .hardware: return UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 20) + case .secure: return UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 30) + case .toast: return UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 40) + // Above React Native's Alert window (alert + 1) and perf overlays. + case .lock: return UIWindow.Level(rawValue: UIWindow.Level.alert.rawValue + 10) + case .debug: return UIWindow.Level(rawValue: UIWindow.Level.alert.rawValue + 20) + } + } +} + +/// Lets touches that miss every overlay fall through to lower windows. +final class NativeOverlayPassthroughWindow: UIWindow { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + if hit === self || hit === rootViewController?.view { + return nil + } + return hit + } + + // React Native reads the window dimensions from the key window, and a + // touched level window becomes key. A rotation reported while it is key + // (or while it is hidden with a stale frame) leaves JS with the wrong + // window size. Once the sizes settle, re-report through the frame + // observer React Native keeps on the app window. + override var frame: CGRect { + didSet { + if isKeyWindow && oldValue.size != frame.size { + refreshReactNativeDimensions() + } + } + } + + override func resignKey() { + super.resignKey() + refreshReactNativeDimensions() + } + + private func refreshReactNativeDimensions() { + guard let appWindow = (rootViewController as? NativeOverlayRootViewController)?.appWindow + else { return } + DispatchQueue.main.async { [weak appWindow] in + guard let appWindow else { return } + // Assigning the frame fires React Native's KVO, which recomputes the + // dimensions from the current key window. + let frame = appWindow.frame + appWindow.frame = frame + } + } +} + +/// Status bar and orientation follow the app window underneath, because +/// `childForStatusBarStyle` cannot reach across windows. +final class NativeOverlayRootViewController: UIViewController { + weak var appWindow: UIWindow? + + private var appTopController: UIViewController? { + var controller = appWindow?.rootViewController + while let presented = controller?.presentedViewController, !presented.isBeingDismissed { + controller = presented + } + // Never answer with ourselves: asking it would recurse forever. + return controller === self ? nil : controller + } + + override func loadView() { + let root = UIView() + root.backgroundColor = .clear + view = root + } + + /// Apps without view-controller-based status bar appearance set one + /// app-wide style (React Native's StatusBar does). Level windows above the + /// status bar (`lock`, `debug`) still get asked, so answer with that style. + private static let viewControllerBasedAppearance = + (Bundle.main.object(forInfoDictionaryKey: "UIViewControllerBasedStatusBarAppearance") as? Bool) ?? true + + override var preferredStatusBarStyle: UIStatusBarStyle { + if !Self.viewControllerBasedAppearance { + return UIApplication.shared.statusBarStyle + } + return appTopController?.preferredStatusBarStyle ?? .default + } + + override var prefersStatusBarHidden: Bool { + appTopController?.prefersStatusBarHidden ?? false + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + appTopController?.supportedInterfaceOrientations ?? .all + } +} + +final class NativeOverlayWindowManager { + static let shared = NativeOverlayWindowManager() + + private struct SceneKey: Hashable { + let id: ObjectIdentifier + } + + private var windows: [SceneKey: [NativeOverlayLevel: NativeOverlayPassthroughWindow]] = [:] + + /// The level window for `level` in the scene of `appWindow`, shown on demand. + /// Non-sheet entries are added directly to it so they interleave by insertion + /// order with UIKit presentation containers of sheets in the same level. + func window(for level: NativeOverlayLevel, appWindow hostWindow: UIWindow) -> UIWindow? { + // An overlay opened from inside another overlay (a popover in a password + // prompt) is hosted in a level window; follow it back to the app window. + let appWindow = + ((hostWindow as? NativeOverlayPassthroughWindow)?.rootViewController + as? NativeOverlayRootViewController)?.appWindow ?? hostWindow + guard let scene = appWindow.windowScene else { return nil } + let key = SceneKey(id: ObjectIdentifier(scene)) + pruneDisconnectedScenes() + var sceneWindows = windows[key] ?? [:] + let window: NativeOverlayPassthroughWindow + if let existing = sceneWindows[level] { + window = existing + } else { + window = NativeOverlayPassthroughWindow(windowScene: scene) + window.windowLevel = level.windowLevel + window.backgroundColor = .clear + window.rootViewController = NativeOverlayRootViewController() + sceneWindows[level] = window + windows[key] = sceneWindows + } + (window.rootViewController as? NativeOverlayRootViewController)?.appWindow = appWindow + if window.isHidden { + window.frame = scene.coordinateSpace.bounds + window.isHidden = false + window.rootViewController?.setNeedsStatusBarAppearanceUpdate() + } + return window + } + + /// Top of the level window's presentation chain; sheets of one level stack here. + func presenter(in window: UIWindow) -> UIViewController? { + var controller = window.rootViewController + while let presented = controller?.presentedViewController, !presented.isBeingDismissed { + controller = presented + } + return controller + } + + /// Hides a level window once nothing is shown in it, and hands key status + /// back to the app window if an input in the overlay had taken it. + func entryRemoved(from window: UIWindow?) { + guard let window = window as? NativeOverlayPassthroughWindow, + let root = window.rootViewController else { return } + let hasEntries = window.subviews.contains { $0 is NativeOverlayEntryView } + let presented = root.presentedViewController + guard !hasEntries, presented == nil || presented?.isBeingDismissed == true else { return } + let wasKey = window.isKeyWindow + window.isHidden = true + if wasKey { + (root as? NativeOverlayRootViewController)?.appWindow?.makeKey() + } + } + + private func pruneDisconnectedScenes() { + let connected = Set( + UIApplication.shared.connectedScenes.map { SceneKey(id: ObjectIdentifier($0)) } + ) + windows = windows.filter { connected.contains($0.key) } + } +} diff --git a/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.h b/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.h new file mode 100644 index 000000000..2d5df0327 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.h @@ -0,0 +1,11 @@ +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTNativeOverlayComponentView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END +#endif diff --git a/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.mm b/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.mm new file mode 100644 index 000000000..763532d41 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/RCTNativeOverlayComponentView.mm @@ -0,0 +1,193 @@ +#import "RCTNativeOverlayComponentView.h" + +#include + +#import +#import +#import +#import + +#import +#import +#import +#import + +#if __has_include() +#import +#elif __has_include("NativeOverlay/NativeOverlay-Swift.h") +#import "NativeOverlay/NativeOverlay-Swift.h" +#else +#import "NativeOverlay-Swift.h" +#endif + +using namespace facebook::react; + +static NSString *RNCNativeOverlayLevelString(RNCNativeOverlayLevel level) +{ + switch (level) { + case RNCNativeOverlayLevel::Hardware: + return @"hardware"; + case RNCNativeOverlayLevel::Secure: + return @"secure"; + case RNCNativeOverlayLevel::Toast: + return @"toast"; + case RNCNativeOverlayLevel::Lock: + return @"lock"; + case RNCNativeOverlayLevel::Debug: + return @"debug"; + case RNCNativeOverlayLevel::Modal: + default: + return @"modal"; + } +} + +static NSString *RNCNativeOverlayPresentationString(RNCNativeOverlayPresentation presentation) +{ + switch (presentation) { + case RNCNativeOverlayPresentation::Toast: + return @"toast"; + case RNCNativeOverlayPresentation::Fullscreen: + return @"fullscreen"; + case RNCNativeOverlayPresentation::Sheet: + return @"sheet"; + case RNCNativeOverlayPresentation::Anchored: + return @"anchored"; + case RNCNativeOverlayPresentation::Center: + default: + return @"center"; + } +} + +@implementation RCTNativeOverlayComponentView { + NativeOverlayContainerView *_containerView; + RCTSurfaceTouchHandler *_touchHandler; + RNCNativeOverlayShadowNode::ConcreteState::Shared _state; + CGPoint _contentOffset; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _containerView = [NativeOverlayContainerView new]; + _touchHandler = [RCTSurfaceTouchHandler new]; + _containerView.touchHandler = _touchHandler; + self.contentView = _containerView; + + __weak auto weakSelf = self; + _containerView.onContentOffsetChanged = ^(CGPoint offset) { + auto strongSelf = weakSelf; + if (!strongSelf) return; + strongSelf->_contentOffset = offset; + [strongSelf pushContentOffset]; + }; + _containerView.onPresented = ^(NSDictionary *body) { + auto strongSelf = weakSelf; + if (!strongSelf) return; + auto emitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (!emitter) return; + NSNumber *stackOrder = body[@"stackOrder"] ?: @0; + emitter->onPresented({.stackOrder = stackOrder.intValue}); + }; + _containerView.onDismissed = ^(NSDictionary *body) { + auto strongSelf = weakSelf; + if (!strongSelf) return; + auto emitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (!emitter) return; + NSString *reason = body[@"reason"] ?: @"system"; + emitter->onDismissed({.reason = std::string(reason.UTF8String)}); + }; + _containerView.onRequestDismiss = ^(NSDictionary *body) { + auto strongSelf = weakSelf; + if (!strongSelf) return; + auto emitter = std::static_pointer_cast(strongSelf->_eventEmitter); + if (!emitter) return; + NSString *reason = body[@"reason"] ?: @"back"; + emitter->onRequestDismiss({.reason = std::string(reason.UTF8String)}); + }; + } + return self; +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +- (void)mountChildComponentView:(UIView *)childComponentView + index:(NSInteger)index +{ + [_containerView insertChild:childComponentView atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView + index:(NSInteger)index +{ + [_containerView removeChild:childComponentView]; + [childComponentView removeFromSuperview]; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &newProps = *std::static_pointer_cast(props); + + _containerView.level = RNCNativeOverlayLevelString(newProps.level); + _containerView.presentation = RNCNativeOverlayPresentationString(newProps.presentation); + _containerView.scope = newProps.scope == RNCNativeOverlayScope::Page ? @"page" : @"global"; + _containerView.hostKey = [NSString stringWithUTF8String:newProps.hostKey.c_str()]; + _containerView.ownerKey = [NSString stringWithUTF8String:newProps.ownerKey.c_str()]; + _containerView.stackOrder = newProps.stackOrder; + _containerView.blocking = newProps.blocking; + _containerView.dismissOnBackPress = newProps.dismissOnBackPress; + _containerView.dismissOnBackdropPress = newProps.dismissOnBackdropPress; + _containerView.backdropColor = RCTUIColorFromSharedColor(newProps.backdropColor); + _containerView.sheetHeight = newProps.sheetHeight; + _containerView.sheetCornerRadius = newProps.sheetCornerRadius; + _containerView.showHandle = newProps.showHandle; + _containerView.sheetBackgroundColor = RCTUIColorFromSharedColor(newProps.sheetBackgroundColor); + _containerView.dismissOnPanDown = newProps.dismissOnPanDown; + _containerView.animationConfig = [NSString stringWithUTF8String:newProps.animationConfig.c_str()]; + _containerView.visible = newProps.visible; + [_containerView commitConfiguration]; + + [super updateProps:props oldProps:oldProps]; +} + +- (void)updateState:(State::Shared const &)state oldState:(State::Shared const &)oldState +{ + _state = std::static_pointer_cast(state); + [self pushContentOffset]; +} + +// Keeps the shadow node's measurement origin at the content's window origin. +- (void)pushContentOffset +{ + if (!_state) { + return; + } + const auto current = _state->getData().contentOffset; + if (std::abs(current.x - _contentOffset.x) < 0.5 && std::abs(current.y - _contentOffset.y) < 0.5) { + return; + } + _state->updateState(NativeOverlayState{facebook::react::Point{.x = _contentOffset.x, .y = _contentOffset.y}}); +} + +- (void)invalidate +{ + [_containerView invalidate]; + [super invalidate]; +} + +@end + +Class RNCNativeOverlayCls(void) +{ + return RCTNativeOverlayComponentView.class; +} diff --git a/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.h b/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.h new file mode 100644 index 000000000..701f716c4 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.h @@ -0,0 +1,11 @@ +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTNativeOverlayPageHostComponentView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END +#endif diff --git a/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.mm b/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.mm new file mode 100644 index 000000000..a09ba93c5 --- /dev/null +++ b/native-views/react-native-native-overlay/ios/RCTNativeOverlayPageHostComponentView.mm @@ -0,0 +1,64 @@ +#import "RCTNativeOverlayPageHostComponentView.h" + +#import +#import +#import + +#import +#import + +#if __has_include() +#import +#elif __has_include("NativeOverlay/NativeOverlay-Swift.h") +#import "NativeOverlay/NativeOverlay-Swift.h" +#else +#import "NativeOverlay-Swift.h" +#endif + +using namespace facebook::react; + +@implementation RCTNativeOverlayPageHostComponentView { + NativeOverlayPageHostView *_hostView; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + _hostView = [NativeOverlayPageHostView new]; + self.contentView = _hostView; + } + return self; +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &newProps = *std::static_pointer_cast(props); + _hostView.hostKey = [NSString stringWithUTF8String:newProps.hostKey.c_str()]; + [_hostView setSuspendedOwners:[NSString stringWithUTF8String:newProps.suspendedOwners.c_str()]]; + [super updateProps:props oldProps:oldProps]; +} + +- (void)invalidate +{ + [_hostView invalidate]; + [super invalidate]; +} + +@end + +Class RNCNativeOverlayPageHostCls(void) +{ + return RCTNativeOverlayPageHostComponentView.class; +} diff --git a/native-views/react-native-native-overlay/lefthook.yml b/native-views/react-native-native-overlay/lefthook.yml new file mode 100644 index 000000000..893471620 --- /dev/null +++ b/native-views/react-native-native-overlay/lefthook.yml @@ -0,0 +1,14 @@ +pre-commit: + parallel: true + commands: + lint: + glob: '*.{js,ts,tsx}' + run: yarn lint {staged_files} + typecheck: + files: git diff --name-only --cached + glob: '*.{js,ts,tsx}' + run: yarn typecheck +commit-msg: + commands: + commitlint: + run: npx commitlint --edit {1} diff --git a/native-views/react-native-native-overlay/package.json b/native-views/react-native-native-overlay/package.json new file mode 100644 index 000000000..26e6d77e1 --- /dev/null +++ b/native-views/react-native-native-overlay/package.json @@ -0,0 +1,188 @@ +{ + "name": "@onekeyfe/react-native-native-overlay", + "version": "3.0.162-alpha.268", + "description": "Native and Web overlay hosts with ordered levels, page scope and Fabric measurement", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./lib/typescript/src/index.d.ts", + "react-native": "./src/index.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "common", + "*.podspec", + "react-native.config.js", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "docs/SPEC.md" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,tsx}\" && prettier --check \"src/**/*.{ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules.git", + "directory": "native-views/react-native-native-overlay" + }, + "author": "@onekeyfe (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/tree/main/native-views/react-native-native-overlay#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.86.2", + "@react-native/eslint-config": "0.86.2", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "@types/react-dom": "19.2.3", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.2", + "react-native-builder-bob": "^0.40.13", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "packageManager": "yarn@4.1.0", + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ], + "watchman": false, + "testMatch": [ + "/src/**/__tests__/*.test.ts" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "fabric-view", + "languages": "kotlin-swift", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + }, + "source": "./src/index.ts", + "react-native": "./src/index.ts", + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "codegenConfig": { + "name": "RNCNativeOverlay", + "type": "components", + "jsSrcsDir": "./src", + "android": { + "javaPackageName": "com.onekey.nativeoverlay" + }, + "ios": { + "componentProvider": { + "RNCNativeOverlay": "RCTNativeOverlayComponentView", + "RNCNativeOverlayPageHost": "RCTNativeOverlayPageHostComponentView" + } + } + } +} diff --git a/native-views/react-native-native-overlay/react-native.config.js b/native-views/react-native-native-overlay/react-native.config.js new file mode 100644 index 000000000..41f114ff1 --- /dev/null +++ b/native-views/react-native-native-overlay/react-native.config.js @@ -0,0 +1,16 @@ +module.exports = { + dependency: { + platforms: { + android: { + componentDescriptors: [ + 'RNCNativeOverlayComponentDescriptor', + 'RNCNativeOverlayPageHostComponentDescriptor', + ], + cmakeListsPath: '../android/src/main/jni/CMakeLists.txt', + packageImportPath: + 'import com.onekey.nativeoverlay.NativeOverlayPackage;', + packageInstance: 'new NativeOverlayPackage()', + }, + }, + }, +}; diff --git a/native-views/react-native-native-overlay/src/NativeOverlayNativeComponent.ts b/native-views/react-native-native-overlay/src/NativeOverlayNativeComponent.ts new file mode 100644 index 000000000..2f714d991 --- /dev/null +++ b/native-views/react-native-native-overlay/src/NativeOverlayNativeComponent.ts @@ -0,0 +1,61 @@ +import { codegenNativeComponent } from 'react-native'; + +import type { + CodegenTypes, + ColorValue, + HostComponent, + ViewProps, +} from 'react-native'; +type INativeOverlayDismissedEvent = Readonly<{ + reason: string; +}>; + +type INativeOverlayRequestDismissEvent = Readonly<{ + reason: string; +}>; + +type INativeOverlayPresentedEvent = Readonly<{ + stackOrder: CodegenTypes.Int32; +}>; + +export interface INativeOverlayNativeProps extends ViewProps { + /** Present when true; play the exit animation and emit onDismissed when it turns false. */ + visible: boolean; + level?: CodegenTypes.WithDefault< + 'modal' | 'hardware' | 'secure' | 'toast' | 'lock' | 'debug', + 'modal' + >; + presentation?: CodegenTypes.WithDefault< + 'center' | 'toast' | 'fullscreen' | 'sheet' | 'anchored', + 'center' + >; + scope?: CodegenTypes.WithDefault<'global' | 'page', 'global'>; + /** `page` scope: the `OverlayPageHost` to render in and the owning page. */ + hostKey?: CodegenTypes.WithDefault; + ownerKey?: CodegenTypes.WithDefault; + /** Order inside the level; higher renders above. */ + stackOrder?: CodegenTypes.WithDefault; + /** Swallow touches that miss the content instead of passing them through. */ + blocking?: CodegenTypes.WithDefault; + dismissOnBackPress?: CodegenTypes.WithDefault; + dismissOnBackdropPress?: CodegenTypes.WithDefault; + backdropColor?: ColorValue; + /** `sheet` presentation: resolved content height in points. */ + sheetHeight?: CodegenTypes.WithDefault; + sheetCornerRadius?: CodegenTypes.WithDefault; + showHandle?: CodegenTypes.WithDefault; + sheetBackgroundColor?: ColorValue; + dismissOnPanDown?: CodegenTypes.WithDefault; + /** JSON of the resolved animation (`IResolvedOverlayAnimation`). */ + animationConfig?: CodegenTypes.WithDefault; + onPresented?: CodegenTypes.DirectEventHandler; + onDismissed?: CodegenTypes.DirectEventHandler; + onRequestDismiss?: CodegenTypes.DirectEventHandler; +} + +// The shadow node is hand-written (common/cpp): it carries the on-screen +// content offset so `measure` reports where native presented the content. +export default codegenNativeComponent( + 'RNCNativeOverlay', + { interfaceOnly: true } +) as HostComponent; diff --git a/native-views/react-native-native-overlay/src/NativeOverlayPageHostNativeComponent.ts b/native-views/react-native-native-overlay/src/NativeOverlayPageHostNativeComponent.ts new file mode 100644 index 000000000..61590bda5 --- /dev/null +++ b/native-views/react-native-native-overlay/src/NativeOverlayPageHostNativeComponent.ts @@ -0,0 +1,13 @@ +import { codegenNativeComponent } from 'react-native'; + +import type { HostComponent, ViewProps } from 'react-native'; + +export interface INativeOverlayPageHostNativeProps extends ViewProps { + hostKey: string; + /** Owners whose entries are hidden, newline separated. */ + suspendedOwners?: string; +} + +export default codegenNativeComponent( + 'RNCNativeOverlayPageHost' +) as HostComponent; diff --git a/native-views/react-native-native-overlay/src/OverlayErrors.ts b/native-views/react-native-native-overlay/src/OverlayErrors.ts new file mode 100644 index 000000000..02fd6605f --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayErrors.ts @@ -0,0 +1,3 @@ +export class OverlayRequestError extends Error { + override name = 'OverlayRequestError'; +} diff --git a/native-views/react-native-native-overlay/src/OverlayLevelContext.tsx b/native-views/react-native-native-overlay/src/OverlayLevelContext.tsx new file mode 100644 index 000000000..26f204f5c --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayLevelContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react'; + +import { OVERLAY_LEVEL_ORDER } from './OverlayLevels'; + +import type { IOverlayLevel } from './types'; + +const OverlayLevelContext = createContext(undefined); + +/** Provided by `OverlayView` to its content. */ +export const OverlayLevelProvider = OverlayLevelContext.Provider; + +/** The level of the overlay this component renders in, if any. */ +export function useEnclosingOverlayLevel(): IOverlayLevel | undefined { + return useContext(OverlayLevelContext); +} + +/** + * The level for an overlay opened from inside another one (a popover in a + * password prompt, a tooltip on the lock screen): never below the overlay it + * is opened from, or it would render underneath it. + */ +export function useNestedOverlayLevel( + minimum: IOverlayLevel = 'modal' +): IOverlayLevel { + const enclosing = useEnclosingOverlayLevel(); + if (!enclosing) { + return minimum; + } + return OVERLAY_LEVEL_ORDER[enclosing] > OVERLAY_LEVEL_ORDER[minimum] + ? enclosing + : minimum; +} diff --git a/native-views/react-native-native-overlay/src/OverlayLevels.ts b/native-views/react-native-native-overlay/src/OverlayLevels.ts new file mode 100644 index 000000000..70afbfbcf --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayLevels.ts @@ -0,0 +1,40 @@ +import type { IOverlayLevel } from './types'; + +export const OVERLAY_LEVEL_ORDER: Record = { + modal: 100, + hardware: 200, + secure: 300, + toast: 400, + lock: 500, + debug: 900, +}; + +export const OVERLAY_LEVELS: readonly IOverlayLevel[] = ( + Object.keys(OVERLAY_LEVEL_ORDER) as IOverlayLevel[] +).toSorted((a, b) => OVERLAY_LEVEL_ORDER[a] - OVERLAY_LEVEL_ORDER[b]); + +const NON_BLOCKING_LEVELS: ReadonlySet = new Set([ + 'toast', + 'debug', +]); + +export function isBlockingLevel(level: IOverlayLevel): boolean { + return !NON_BLOCKING_LEVELS.has(level); +} + +/** + * Web z-index bands of the level layer roots (`document.body` children). In-page + * content that must rise above a level (a drag clone over a dialog) uses a + * value just above that level's base. + */ +export const OVERLAY_WEB_Z_INDEX_BASE: Record = { + modal: 100_000, + hardware: 120_000, + secure: 160_000, + toast: 1_000_000, + lock: 100_000_000, + debug: 100_000_005, +}; + +/** Page-scope entries always render below every global entry. */ +export const OVERLAY_WEB_PAGE_Z_INDEX_BASE = 1000; diff --git a/native-views/react-native-native-overlay/src/OverlayPageHost.tsx b/native-views/react-native-native-overlay/src/OverlayPageHost.tsx new file mode 100644 index 000000000..ca796e9d2 --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayPageHost.tsx @@ -0,0 +1,21 @@ +import { StyleSheet } from 'react-native'; + +import NativeOverlayPageHost from './NativeOverlayPageHostNativeComponent'; +import { useSuspendedPageOwners } from './useSuspendedPageOwners'; + +/** + * Renders page-scope overlays of one root route. Place it as the last child + * of the root-route screen, after its navigator, so page overlays cover the + * native header and tab bar of every page in that route. + */ +export function OverlayPageHost({ hostKey }: { hostKey: string }) { + const suspendedOwners = useSuspendedPageOwners(hostKey); + return ( + + ); +} diff --git a/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx b/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx new file mode 100644 index 000000000..143da0ef8 --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; + +import { useSuspendedPageOwners } from './useSuspendedPageOwners'; +import { registerOverlayPageHost } from './web/overlayLayers'; + +const OWNER_ATTRIBUTE = 'data-overlay-owner'; + +/** + * Web page host: an absolutely positioned layer inside the root-route card. + * Page overlays portal into it; entries of covered owners are hidden here + * because their own (possibly inactive) page does not re-render. + */ +export function OverlayPageHost({ hostKey }: { hostKey: string }) { + const ref = useRef(null); + const suspendedOwners = useSuspendedPageOwners(hostKey); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return registerOverlayPageHost(hostKey, element); + }, [hostKey]); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + const suspended = new Set(suspendedOwners); + element + .querySelectorAll(`[${OWNER_ATTRIBUTE}]`) + .forEach((node) => { + const hidden = suspended.has(node.getAttribute(OWNER_ATTRIBUTE) ?? ''); + node.style.visibility = hidden ? 'hidden' : ''; + node.inert = hidden; + }); + }, [suspendedOwners]); + + return ( +
+ ); +} + +export { OWNER_ATTRIBUTE as OVERLAY_OWNER_ATTRIBUTE }; diff --git a/native-views/react-native-native-overlay/src/OverlayPageScope.tsx b/native-views/react-native-native-overlay/src/OverlayPageScope.tsx new file mode 100644 index 000000000..00edbb84b --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayPageScope.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { PropsWithChildren } from 'react'; + +export interface IOverlayPageScope { + /** Root-route host that renders page overlays (see `OverlayPageHost`). */ + hostKey?: string; + /** The nearest page; page overlays hide while it is covered. */ + ownerKey?: string; +} + +const OverlayPageScopeContext = createContext({}); + +export function useOverlayPageScope(): IOverlayPageScope { + return useContext(OverlayPageScopeContext); +} + +/** Provided once per root route, around its navigator and `OverlayPageHost`. */ +export function OverlayPageHostScope({ + hostKey, + children, +}: PropsWithChildren<{ hostKey: string }>) { + const value = useMemo(() => ({ hostKey }), [hostKey]); + return ( + + {children} + + ); +} + +/** Provided by each page; page overlays below it belong to `ownerKey`. */ +export function OverlayPageOwnerScope({ + ownerKey, + children, +}: PropsWithChildren<{ ownerKey: string }>) { + const { hostKey } = useOverlayPageScope(); + const value = useMemo(() => ({ hostKey, ownerKey }), [hostKey, ownerKey]); + return ( + + {children} + + ); +} diff --git a/native-views/react-native-native-overlay/src/OverlayStore.ts b/native-views/react-native-native-overlay/src/OverlayStore.ts new file mode 100644 index 000000000..7a49db5f4 --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayStore.ts @@ -0,0 +1,401 @@ +import { OverlayRequestError } from './OverlayErrors'; +import { OVERLAY_LEVEL_ORDER, isBlockingLevel } from './OverlayLevels'; + +import type { + IOverlayDismissReason, + IOverlayEntry, + IOverlayRequest, + IOverlaySnapshot, +} from './types'; + +export interface IOverlayEntryHandlers { + /** Called once when the entry leaves the store (finalized or dropped from the queue). */ + onRemoved?: (reason: IOverlayDismissReason) => void; +} + +export type IOverlayBackResolution = + | { kind: 'dismiss'; id: string } + | { kind: 'block' } + | { kind: 'pass' }; + +export interface IOverlayStoreOptions { + /** Upper bound for a closing entry to wait for its native exit animation. */ + closingTimeoutMs?: number; +} + +const DEFAULT_CLOSING_TIMEOUT_MS = 5000; + +function laneKeyOf(entry: IOverlayEntry): string { + return entry.scope === 'global' + ? `global|${entry.level}` + : `page|${entry.hostKey ?? ''}|${entry.level}`; +} + +function compareRenderOrder(a: IOverlayEntry, b: IOverlayEntry): number { + if (a.scope !== b.scope) { + return a.scope === 'page' ? -1 : 1; + } + if (a.scope === 'page' && a.hostKey !== b.hostKey) { + return (a.hostKey ?? '') < (b.hostKey ?? '') ? -1 : 1; + } + const levelDiff = OVERLAY_LEVEL_ORDER[a.level] - OVERLAY_LEVEL_ORDER[b.level]; + return levelDiff !== 0 ? levelDiff : a.seq - b.seq; +} + +function compareQueueOrder(a: IOverlayEntry, b: IOverlayEntry): number { + return b.priority !== a.priority ? b.priority - a.priority : a.seq - b.seq; +} + +export class OverlayStore { + private readonly closingTimeoutMs: number; + + private seq = 0; + + private readonly shown = new Map(); + + private queued: IOverlayEntry[] = []; + + private readonly handlers = new Map(); + + private readonly closingTimers = new Map< + string, + ReturnType + >(); + + private readonly pageVisibility = new Map(); + + private readonly listeners = new Set<() => void>(); + + private snapshot: IOverlaySnapshot = { + entries: [], + queued: [], + }; + + constructor(options: IOverlayStoreOptions = {}) { + this.closingTimeoutMs = + options.closingTimeoutMs ?? DEFAULT_CLOSING_TIMEOUT_MS; + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getSnapshot = (): IOverlaySnapshot => this.snapshot; + + getEntry(id: string): IOverlayEntry | undefined { + return this.shown.get(id) ?? this.queued.find((e) => e.id === id); + } + + request( + request: IOverlayRequest, + handlers: IOverlayEntryHandlers = {} + ): IOverlayEntry { + if (request.id) { + const existing = this.getEntry(request.id); + if (existing) { + return existing; + } + } + const scope = request.scope ?? 'global'; + if (scope === 'page' && (!request.hostKey || !request.ownerKey)) { + throw new OverlayRequestError( + 'OverlayStore: page scope requires hostKey and ownerKey' + ); + } + const level = request.level ?? 'modal'; + const blocking = request.blocking ?? isBlockingLevel(level); + this.seq += 1; + const entry: IOverlayEntry = { + id: request.id ?? `overlay-${this.seq}`, + scope, + level, + strategy: request.strategy ?? 'stack', + priority: request.priority ?? 0, + replaceKey: request.replaceKey, + hostKey: scope === 'page' ? request.hostKey : undefined, + ownerKey: scope === 'page' ? request.ownerKey : undefined, + dismissible: request.dismissible ?? blocking, + statusBarStyle: request.statusBarStyle, + blocking, + phase: 'active', + suspended: false, + dismissReason: undefined, + seq: this.seq, + }; + entry.suspended = this.isOwnerHidden(entry); + this.handlers.set(entry.id, handlers); + + if (entry.strategy === 'replace') { + this.replaceIn(entry); + this.shown.set(entry.id, entry); + } else if (entry.strategy === 'queue' && !this.canPromote(entry)) { + entry.phase = 'queued'; + this.queued = [...this.queued, entry].toSorted(compareQueueOrder); + } else { + this.shown.set(entry.id, entry); + } + this.emit(); + return this.getEntry(entry.id) ?? entry; + } + + /** Starts closing an entry; the native side calls `finalize` after its exit animation. */ + dismiss(id: string, reason: IOverlayDismissReason = 'programmatic'): void { + if (this.dismissWithoutEmit(id, reason)) { + this.emit(); + } + } + + /** Removes a closing (or active) entry once its exit animation finished. */ + finalize(id: string): void { + const entry = this.shown.get(id); + if (!entry) { + return; + } + this.clearClosingTimer(id); + this.shown.delete(id); + this.promoteQueued(); + this.emit(); + this.notifyRemoved(id, entry.dismissReason ?? 'programmatic'); + } + + dismissAll( + filter: { + scope?: IOverlayEntry['scope']; + belowLevel?: IOverlayEntry['level']; + reason?: IOverlayDismissReason; + } = {} + ): void { + const reason = filter.reason ?? 'programmatic'; + const matches = (entry: IOverlayEntry) => + (!filter.scope || entry.scope === filter.scope) && + (!filter.belowLevel || + OVERLAY_LEVEL_ORDER[entry.level] < + OVERLAY_LEVEL_ORDER[filter.belowLevel]); + const dropped = this.queued.filter(matches); + this.queued = this.queued.filter((e) => !matches(e)); + let changed = dropped.length > 0; + for (const entry of this.shown.values()) { + if (matches(entry)) { + changed = this.dismissWithoutEmit(entry.id, reason) || changed; + } + } + if (changed) { + this.emit(); + } + dropped.forEach((e) => this.notifyRemoved(e.id, reason)); + } + + /** Native / navigation signal: the owning page was covered, detached or restored. */ + setPageVisible(ownerKey: string, visible: boolean): void { + if ((this.pageVisibility.get(ownerKey) ?? true) === visible) { + return; + } + this.pageVisibility.set(ownerKey, visible); + for (const entry of this.shown.values()) { + if (entry.ownerKey === ownerKey) { + entry.suspended = !visible; + } + } + for (const entry of this.queued) { + if (entry.ownerKey === ownerKey) { + entry.suspended = !visible; + } + } + if (visible) { + this.promoteQueued(); + } + this.emit(); + } + + /** The owning page left the navigation state for good. */ + removePage(ownerKey: string): void { + const dropped = this.queued.filter((e) => e.ownerKey === ownerKey); + this.queued = this.queued.filter((e) => e.ownerKey !== ownerKey); + for (const entry of this.shown.values()) { + if (entry.ownerKey === ownerKey) { + this.dismissWithoutEmit(entry.id, 'page-removed'); + } + } + this.pageVisibility.delete(ownerKey); + this.emit(); + dropped.forEach((e) => this.notifyRemoved(e.id, 'page-removed')); + } + + /** + * Decides what a back press / Escape does. Global entries win over page + * entries; `focusedHostKey` limits page entries to the focused root route + * (split view has one host per pane). + */ + resolveBack(focusedHostKey?: string): IOverlayBackResolution { + // Non-blocking entries take back / Escape only when they ask to be + // dismissible (an anchored popover); toasts do not. + const candidates = this.snapshot.entries.filter( + (e) => + e.phase === 'active' && + !e.suspended && + (e.blocking || e.dismissible) && + (e.scope === 'global' || + focusedHostKey === undefined || + e.hostKey === focusedHostKey) + ); + const top = candidates[candidates.length - 1]; + if (!top) { + return { kind: 'pass' }; + } + return top.dismissible + ? { kind: 'dismiss', id: top.id } + : { kind: 'block' }; + } + + /** Topmost visible blocking entry; everything rendered below it is inert. */ + /** The entry whose `statusBarStyle` applies (see `findStatusBarOwner`). */ + getStatusBarOwner(): IOverlayEntry | undefined { + return findStatusBarOwner(this.snapshot); + } + + getBlockingTop(): IOverlayEntry | undefined { + const { entries } = this.snapshot; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if ( + entry && + entry.phase !== 'closing' && + !entry.suspended && + entry.blocking + ) { + return entry; + } + } + return undefined; + } + + private dismissWithoutEmit( + id: string, + reason: IOverlayDismissReason + ): boolean { + const queuedIndex = this.queued.findIndex((e) => e.id === id); + if (queuedIndex !== -1) { + this.queued = this.queued.filter((e) => e.id !== id); + // Queued entries never rendered, so they leave without an exit animation. + queueMicrotask(() => this.notifyRemoved(id, reason)); + return true; + } + const entry = this.shown.get(id); + if (!entry || entry.phase === 'closing') { + return false; + } + entry.phase = 'closing'; + entry.dismissReason = reason; + this.closingTimers.set( + id, + setTimeout(() => this.finalize(id), this.closingTimeoutMs) + ); + return true; + } + + private replaceIn(entry: IOverlayEntry): void { + const laneKey = laneKeyOf(entry); + const sameLane = [...this.shown.values()].filter( + (e) => e.phase === 'active' && laneKeyOf(e) === laneKey + ); + const targets = entry.replaceKey + ? sameLane.filter((e) => e.replaceKey === entry.replaceKey) + : sameLane.slice(-1); + targets.forEach((e) => this.dismissWithoutEmit(e.id, 'replaced')); + if (entry.replaceKey) { + const dropped = this.queued.filter( + (e) => laneKeyOf(e) === laneKey && e.replaceKey === entry.replaceKey + ); + dropped.forEach((e) => this.dismissWithoutEmit(e.id, 'replaced')); + } + } + + private isOwnerHidden(entry: IOverlayEntry): boolean { + return ( + entry.scope === 'page' && + entry.ownerKey !== undefined && + this.pageVisibility.get(entry.ownerKey) === false + ); + } + + private canPromote(entry: IOverlayEntry): boolean { + if (this.isOwnerHidden(entry)) { + return false; + } + const laneKey = laneKeyOf(entry); + for (const shown of this.shown.values()) { + if (laneKeyOf(shown) === laneKey) { + return false; + } + } + return true; + } + + private promoteQueued(): void { + const promotedLanes = new Set(); + const remaining: IOverlayEntry[] = []; + for (const entry of this.queued) { + const laneKey = laneKeyOf(entry); + if (!promotedLanes.has(laneKey) && this.canPromote(entry)) { + entry.phase = 'active'; + this.shown.set(entry.id, entry); + promotedLanes.add(laneKey); + } else { + remaining.push(entry); + } + } + this.queued = remaining; + } + + private clearClosingTimer(id: string): void { + const timer = this.closingTimers.get(id); + if (timer) { + clearTimeout(timer); + this.closingTimers.delete(id); + } + } + + private notifyRemoved(id: string, reason: IOverlayDismissReason): void { + const handlers = this.handlers.get(id); + this.handlers.delete(id); + handlers?.onRemoved?.(reason); + } + + private emit(): void { + this.snapshot = { + entries: [...this.shown.values()] + .map((e) => ({ ...e })) + .toSorted(compareRenderOrder), + queued: this.queued.map((e) => ({ ...e })), + }; + this.listeners.forEach((listener) => listener()); + } +} + +/** + * The topmost shown, visible entry that styles the status bar. Entries are + * in render order, so a higher level (or a later request in the same level) + * wins; page entries render below global ones and suspended ones never own it. + */ +export function findStatusBarOwner( + snapshot: IOverlaySnapshot +): IOverlayEntry | undefined { + const { entries } = snapshot; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if ( + entry && + entry.statusBarStyle && + entry.phase === 'active' && + !entry.suspended + ) { + return entry; + } + } + return undefined; +} + +export const overlayStore = new OverlayStore(); diff --git a/native-views/react-native-native-overlay/src/OverlayView.tsx b/native-views/react-native-native-overlay/src/OverlayView.tsx new file mode 100644 index 000000000..d4b62e4ab --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayView.tsx @@ -0,0 +1,165 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { StatusBar, StyleSheet, View, useWindowDimensions } from 'react-native'; + +import NativeOverlay from './NativeOverlayNativeComponent'; +import { OverlayLevelProvider } from './OverlayLevelContext'; +import { isBlockingLevel } from './OverlayLevels'; +import { useOverlayController } from './useOverlayController'; + +import type { + IOverlayRequestDismissReason, + IOverlayViewProps, +} from './OverlayViewTypes'; +import type { LayoutChangeEvent, NativeSyntheticEvent } from 'react-native'; + +const DEFAULT_BACKDROP_COLOR = 'rgba(0, 0, 0, 0.4)'; +const DEFAULT_SHEET_MAX_RATIO = 0.92; + +const REQUEST_REASONS: Record = { + back: 'back', + backdrop: 'backdrop', + pan: 'pan', +}; + +const styles = StyleSheet.create({ + // The host view never draws: native moves its child into the level host + // and clips it (zero width) while hidden. The height stays auto on + // purpose; a fixed 0 makes Yoga measure a fitted sheet's content at most + // 0 tall, so the sheet would never learn its height. + host: { + position: 'absolute', + top: 0, + left: 0, + width: 0, + }, +}); + +export function OverlayView(props: IOverlayViewProps) { + const { + level = 'modal', + presentation = 'center', + backdrop, + blocking, + dismissOnBackPress, + sheet, + keepContentMounted = false, + statusBarStyle, + children, + testID, + } = props; + const isSheet = presentation === 'sheet'; + const controller = useOverlayController(props); + const { width, height } = useWindowDimensions(); + const { + entry, + mounted, + presented, + ownsStatusBar, + scope, + hostKey, + ownerKey, + animation, + onHostPresented, + onHostDismissed, + onHostRequestDismiss, + } = controller; + + const [measuredSheetHeight, setMeasuredSheetHeight] = useState(0); + const maxSheetHeight = sheet?.maxHeight ?? height * DEFAULT_SHEET_MAX_RATIO; + const sheetHeight = Math.min( + sheet?.height ?? measuredSheetHeight, + maxSheetHeight + ); + const onSheetLayout = useCallback((event: LayoutChangeEvent) => { + setMeasuredSheetHeight(event.nativeEvent.layout.height); + }, []); + + const animationConfig = useMemo(() => JSON.stringify(animation), [animation]); + const isBlocking = blocking ?? isBlockingLevel(level); + + const onDismissed = useCallback(() => onHostDismissed(), [onHostDismissed]); + const onRequestDismiss = useCallback( + (event: NativeSyntheticEvent<{ reason: string }>) => { + onHostRequestDismiss(REQUEST_REASONS[event.nativeEvent.reason] ?? 'back'); + }, + [onHostRequestDismiss] + ); + + if (!keepContentMounted && (!mounted || !entry)) { + return null; + } + + // The app drives the status bar through React Native's StatusBar stack + // (iOS runs without view-controller-based appearance), so the owning + // overlay pushes onto the same stack. + const statusBar = + ownsStatusBar && statusBarStyle ? ( + + ) : null; + + return ( + <> + {statusBar} + 0)} + level={level} + scope={scope} + hostKey={hostKey ?? ''} + ownerKey={ownerKey ?? ''} + presentation={presentation} + stackOrder={entry?.seq ?? 0} + blocking={isBlocking} + dismissOnBackPress={dismissOnBackPress ?? isBlocking} + dismissOnBackdropPress={backdrop ? !!backdrop.dismissOnPress : false} + backdropColor={ + backdrop ? backdrop.color ?? DEFAULT_BACKDROP_COLOR : undefined + } + sheetHeight={isSheet ? sheetHeight : 0} + sheetCornerRadius={sheet?.cornerRadius} + showHandle={sheet?.showHandle} + sheetBackgroundColor={sheet?.backgroundColor} + dismissOnPanDown={sheet?.dismissOnPanDown ?? true} + animationConfig={animationConfig} + onPresented={onHostPresented} + onDismissed={onDismissed} + onRequestDismiss={onRequestDismiss} + testID={testID} + > + {isSheet ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + + ); +} diff --git a/native-views/react-native-native-overlay/src/OverlayView.web.tsx b/native-views/react-native-native-overlay/src/OverlayView.web.tsx new file mode 100644 index 000000000..3af0b0c3b --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayView.web.tsx @@ -0,0 +1,356 @@ +import { useEffect, useId, useLayoutEffect, useRef } from 'react'; +import type { ReactNode, SyntheticEvent } from 'react'; + +import { createPortal } from 'react-dom'; +import { StyleSheet, View } from 'react-native'; + +import { OverlayLevelProvider } from './OverlayLevelContext'; +import { OVERLAY_LEVEL_ORDER, isBlockingLevel } from './OverlayLevels'; +import { OVERLAY_OWNER_ATTRIBUTE } from './OverlayPageHost.web'; +import { useOverlayController } from './useOverlayController'; +import { animateBackdrop, animateTransition } from './web/animateTransition'; +import { + ENTRY_ATTRIBUTE, + getOverlayLayerRoot, + getOverlayPageHost, + registerOverlayDismissRequester, + scheduleOverlayInertSync, +} from './web/overlayLayers'; +import { useKeyboardShift } from './web/useKeyboardShift'; +import { useSheetDrag } from './web/useSheetDrag'; + +import type { IResolvedOverlayAnimation } from './animation/resolveAnimation'; +import type { + IOverlayRequestDismissReason, + IOverlaySheetOptions, + IOverlayViewProps, +} from './OverlayViewTypes'; + +const DEFAULT_BACKDROP_COLOR = 'rgba(0, 0, 0, 0.4)'; +const DEFAULT_SHEET_MAX_HEIGHT = '92vh'; +const DEFAULT_SHEET_CORNER_RADIUS = 24; + +// Portaled content still bubbles React events to the caller's ancestors; +// stop them at the entry so presses cannot reach the page underneath. +// Mouse and touch events must keep bubbling natively: react-native-web's +// responder system (PanResponder, gesture responders) listens for them on +// `document`, and a React stopPropagation also stops the native event. +const stopPropagation = (event: SyntheticEvent) => event.stopPropagation(); +const ISOLATED_EVENT_HANDLERS = { + onClick: stopPropagation, + onPointerDown: stopPropagation, + onPointerUp: stopPropagation, + onKeyDown: stopPropagation, +}; + +interface IWebOverlayEntryProps { + entryId: string; + /** Closed but kept mounted: hidden and inert. */ + parked?: boolean; + stackOrder: number; + presented: boolean; + animation: IResolvedOverlayAnimation; + blocking: boolean; + backdropColor: string | undefined; + dismissOnBackdropPress: boolean; + onPresented: () => void; + onDismissed: () => void; + onRequestDismiss: (reason: IOverlayRequestDismissReason) => void; + /** Set for `presentation="sheet"`. */ + sheet: IOverlaySheetOptions | undefined; + /** Page scope: stacking inside the page host and the owning page. */ + zIndex: number; + ownerKey: string | undefined; + testID?: string; + children?: ReactNode; +} + +function WebOverlayEntry({ + entryId, + parked = false, + stackOrder, + presented, + animation, + blocking, + backdropColor, + dismissOnBackdropPress, + onPresented, + onDismissed, + onRequestDismiss, + sheet, + zIndex, + ownerKey, + testID, + children, +}: IWebOverlayEntryProps) { + const sheetRef = useRef(null); + // A pan dismissal already moved the sheet off screen; skip the exit run. + const pannedAwayRef = useRef(false); + const isSheet = !!sheet; + const entryRef = useRef(null); + const contentRef = useRef(null); + const keyboardLayerRef = useRef(null); + const backdropRef = useRef(null); + const directionRef = useRef<'in' | 'out' | undefined>(undefined); + const restoreFocusRef = useRef(null); + const callbacksRef = useRef({ onPresented, onDismissed }); + callbacksRef.current = { onPresented, onDismissed }; + + useLayoutEffect(() => { + const content = contentRef.current; + if (!content) { + return; + } + const direction = presented ? 'in' : 'out'; + if (directionRef.current === direction) { + return; + } + const neverEntered = directionRef.current === undefined; + directionRef.current = direction; + scheduleOverlayInertSync(); + if (direction === 'out' && (neverEntered || pannedAwayRef.current)) { + callbacksRef.current.onDismissed(); + return; + } + if (direction === 'in' && blocking) { + restoreFocusRef.current = document.activeElement as HTMLElement | null; + entryRef.current?.focus({ preventScroll: true }); + } + content.getAnimations().forEach((running) => running.cancel()); + backdropRef.current?.getAnimations().forEach((running) => running.cancel()); + // A sheet is measured as a whole; other presentations by the children + // of the full-window React root. + const contentRoot = isSheet + ? content + : (content.firstElementChild as HTMLElement | null) ?? content; + const transition = direction === 'in' ? animation.enter : animation.exit; + const running = [ + animateTransition(content, contentRoot, transition, direction), + backdropRef.current + ? animateBackdrop( + backdropRef.current, + animation.backdrop.motion, + direction + ) + : undefined, + ].filter((item): item is Animation => !!item); + void Promise.all(running.map((item) => item.finished)) + .then(() => { + if (directionRef.current !== direction) { + return; + } + if (direction === 'in') { + callbacksRef.current.onPresented(); + } else { + restoreFocusRef.current?.focus?.({ preventScroll: true }); + restoreFocusRef.current = null; + callbacksRef.current.onDismissed(); + } + }) + .catch(() => { + // Cancelled by a newer transition; that run reports instead. + }); + }, [presented, animation, blocking, isSheet]); + + useEffect( + () => registerOverlayDismissRequester(entryId, onRequestDismiss), + [entryId, onRequestDismiss] + ); + + // A plain DOM listener: the backdrop is a pointer target only; keyboard + // users dismiss with Escape through the layer manager. + useEffect(() => { + const backdropNode = backdropRef.current; + if (!backdropNode || !dismissOnBackdropPress) { + return; + } + const onBackdropClick = () => onRequestDismiss('backdrop'); + backdropNode.addEventListener('click', onBackdropClick); + return () => backdropNode.removeEventListener('click', onBackdropClick); + }, [dismissOnBackdropPress, onRequestDismiss]); + + useKeyboardShift({ + layerRef: keyboardLayerRef, + sheetRef, + contentRef, + isSheet, + enabled: presented, + }); + + useSheetDrag({ + sheetRef, + backdropRef, + enabled: !!sheet && (sheet.dismissOnPanDown ?? true) && presented, + onDismissByPan: () => { + pannedAwayRef.current = true; + onRequestDismiss('pan'); + }, + }); + + const showBackdrop = !!backdropColor || blocking; + const cornerRadius = sheet?.cornerRadius ?? DEFAULT_SHEET_CORNER_RADIUS; + + return ( +
+ {showBackdrop ? ( +
+ ) : null} + {/* The keyboard lift lives on its own layer so it composes with the + enter / exit animation on the content below. It only carries a + transform while lifted: a transform would re-anchor fixed children. */} +
+
+ {sheet ? ( +
+ {sheet.showHandle ? ( +
+ ) : null} + {children} +
+ ) : ( + + {children} + + )} +
+
+
+ ); +} + +export function OverlayView(props: IOverlayViewProps) { + const { + level = 'modal', + presentation = 'center', + backdrop, + blocking, + sheet, + keepContentMounted = false, + children, + testID, + } = props; + const parkedId = useId(); + const { + entry, + mounted, + presented, + animation, + onHostPresented, + onHostDismissed, + onHostRequestDismiss, + scope, + hostKey, + ownerKey, + } = useOverlayController(props); + + // Closed with `keepContentMounted`: the same entry stays in the layer, + // hidden and inert, so the content keeps its state and can be measured. + const parked = !mounted || !entry; + if (parked && !keepContentMounted) { + return null; + } + + const pageHost = + !parked && scope === 'page' ? getOverlayPageHost(hostKey) : undefined; + return createPortal( + + {children} + , + pageHost ?? getOverlayLayerRoot(level) + ); +} diff --git a/native-views/react-native-native-overlay/src/OverlayViewTypes.ts b/native-views/react-native-native-overlay/src/OverlayViewTypes.ts new file mode 100644 index 000000000..42c2a43ff --- /dev/null +++ b/native-views/react-native-native-overlay/src/OverlayViewTypes.ts @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react'; + +import type { + IOverlayAnimation, + IOverlayPresentation, +} from './animation/types'; +import type { + IOverlayDismissReason, + IOverlayLevel, + IOverlayScope, + IOverlayStatusBarStyle, + IOverlayStrategy, +} from './types'; +import type { ColorValue } from 'react-native'; + +export type IOverlayRequestDismissReason = 'back' | 'backdrop' | 'pan'; + +export interface IOverlayBackdrop { + /** Include alpha; e.g. the theme `$bgBackdrop`. */ + color?: ColorValue; + dismissOnPress?: boolean; +} + +export interface IOverlaySheetOptions { + /** Fixed height; when omitted the sheet fits its content. */ + height?: number; + /** Defaults to 92% of the window height. */ + maxHeight?: number; + cornerRadius?: number; + showHandle?: boolean; + backgroundColor?: ColorValue; + /** Swipe down to close. A pan dismissal cannot be vetoed. */ + dismissOnPanDown?: boolean; +} + +export interface IOverlayViewProps { + /** Desired open state. The overlay may still be queued by its strategy. */ + visible: boolean; + /** + * `page` renders in the root-route `OverlayPageHost` and hides while the + * owning page is covered. Keys default to the nearest scope providers. + */ + scope?: IOverlayScope; + hostKey?: string; + ownerKey?: string; + level?: IOverlayLevel; + strategy?: IOverlayStrategy; + priority?: number; + replaceKey?: string; + presentation?: IOverlayPresentation; + animation?: IOverlayAnimation; + /** `presentation="sheet"` only. */ + sheet?: IOverlaySheetOptions; + /** + * Keep the content mounted (hidden, inert) while closed, so it keeps its + * state and callers can measure it before opening, as native-sheet did. + */ + keepContentMounted?: boolean; + /** `false` renders no backdrop. */ + backdrop?: IOverlayBackdrop | false; + /** Defaults by level: toast and debug pass touches through. */ + blocking?: boolean; + /** + * Status bar content (iOS / Android) while this is the topmost shown + * overlay that sets one, e.g. `light` for a dark full-screen overlay. + * Unset leaves the status bar to the page below. + */ + statusBarStyle?: IOverlayStatusBarStyle; + dismissOnBackPress?: boolean; + /** + * Back press or backdrop press asked to close. When omitted the overlay + * closes itself; when provided the caller decides by toggling `visible`. + * A `pan` dismissal already happened natively and always closes. + */ + onRequestDismiss?: (reason: IOverlayRequestDismissReason) => void; + onPresented?: () => void; + /** The exit animation finished and the content unmounted. */ + onClose?: (reason: IOverlayDismissReason) => void; + children?: ReactNode; + testID?: string; +} diff --git a/native-views/react-native-native-overlay/src/__tests__/OverlayStore.test.ts b/native-views/react-native-native-overlay/src/__tests__/OverlayStore.test.ts new file mode 100644 index 000000000..633d6d039 --- /dev/null +++ b/native-views/react-native-native-overlay/src/__tests__/OverlayStore.test.ts @@ -0,0 +1,298 @@ +import { OverlayStore } from '../OverlayStore'; + +import type { IOverlayDismissReason, IOverlayRequest } from '../types'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +function ids(store: OverlayStore): string[] { + return store.getSnapshot().entries.map((e) => e.id); +} + +function activeIds(store: OverlayStore): string[] { + return store + .getSnapshot() + .entries.filter((e) => e.phase === 'active' && !e.suspended) + .map((e) => e.id); +} + +function page( + id: string, + ownerKey: string, + extra: Partial = {} +): IOverlayRequest { + return { id, scope: 'page', hostKey: 'main', ownerKey, ...extra }; +} + +describe('OverlayStore ordering', () => { + it('orders global entries by level regardless of request order', () => { + const store = new OverlayStore(); + store.request({ id: 'toast', level: 'toast' }); + store.request({ id: 'lock', level: 'lock' }); + store.request({ id: 'sheet', level: 'modal' }); + store.request({ id: 'pin', level: 'hardware' }); + store.request({ id: 'password', level: 'secure' }); + expect(ids(store)).toEqual(['sheet', 'pin', 'password', 'toast', 'lock']); + }); + + it('stacks newest on top within a level', () => { + const store = new OverlayStore(); + store.request({ id: 'a' }); + store.request({ id: 'b' }); + store.request({ id: 'c' }); + expect(ids(store)).toEqual(['a', 'b', 'c']); + }); + + it('renders every page entry below every global entry', () => { + const store = new OverlayStore(); + store.request({ id: 'global-modal', level: 'modal' }); + store.request(page('page-secure', 'swap', { level: 'secure' })); + expect(ids(store)).toEqual(['page-secure', 'global-modal']); + }); + + it('ignores a repeated request id', () => { + const store = new OverlayStore(); + store.request({ id: 'a' }); + store.request({ id: 'a', level: 'toast' }); + expect(store.getSnapshot().entries).toHaveLength(1); + expect(store.getEntry('a')?.level).toBe('modal'); + }); + + it('requires host and owner keys for page scope', () => { + const store = new OverlayStore(); + expect(() => store.request({ scope: 'page', hostKey: 'main' })).toThrow(); + }); +}); + +describe('OverlayStore strategies', () => { + it('queue waits for the lane to empty, ordered by priority', () => { + const store = new OverlayStore(); + store.request({ id: 'first', level: 'hardware', strategy: 'queue' }); + store.request({ id: 'low', level: 'hardware', strategy: 'queue' }); + store.request({ + id: 'urgent', + level: 'hardware', + strategy: 'queue', + priority: 10, + }); + expect(activeIds(store)).toEqual(['first']); + expect(store.getSnapshot().queued.map((e) => e.id)).toEqual([ + 'urgent', + 'low', + ]); + + store.dismiss('first'); + // The next queued entry waits for the exit animation to finish. + expect(activeIds(store)).toEqual([]); + store.finalize('first'); + expect(activeIds(store)).toEqual(['urgent']); + store.dismiss('urgent'); + store.finalize('urgent'); + expect(activeIds(store)).toEqual(['low']); + }); + + it('queue only waits on its own lane', () => { + const store = new OverlayStore(); + store.request({ id: 'modal', level: 'modal' }); + store.request({ id: 'pin', level: 'hardware', strategy: 'queue' }); + expect(activeIds(store)).toEqual(['modal', 'pin']); + }); + + it('stack ignores queued entries', () => { + const store = new OverlayStore(); + store.request({ id: 'a', strategy: 'queue' }); + store.request({ id: 'b', strategy: 'queue' }); + store.request({ id: 'c' }); + expect(activeIds(store)).toEqual(['a', 'c']); + }); + + it('replace closes the top active entry of the lane', () => { + const store = new OverlayStore(); + const removed: IOverlayDismissReason[] = []; + store.request({ id: 'a' }); + store.request( + { id: 'loading-1' }, + { onRemoved: (reason) => removed.push(reason) } + ); + store.request({ id: 'loading-2', strategy: 'replace' }); + expect(store.getEntry('loading-1')?.phase).toBe('closing'); + expect(store.getEntry('loading-1')?.dismissReason).toBe('replaced'); + expect(activeIds(store)).toEqual(['a', 'loading-2']); + store.finalize('loading-1'); + expect(removed).toEqual(['replaced']); + }); + + it('replace with a key only closes entries sharing that key', () => { + const store = new OverlayStore(); + store.request({ id: 'other' }); + store.request({ id: 'status-1', replaceKey: 'hw-status' }); + store.request({ + id: 'status-2', + strategy: 'replace', + replaceKey: 'hw-status', + }); + expect(activeIds(store)).toEqual(['other', 'status-2']); + }); +}); + +describe('OverlayStore closing lifecycle', () => { + it('finalizes a closing entry after the fallback timeout', () => { + const store = new OverlayStore({ closingTimeoutMs: 100 }); + const onRemoved = jest.fn(); + store.request({ id: 'a' }, { onRemoved }); + store.dismiss('a', 'backdrop'); + jest.advanceTimersByTime(99); + expect(store.getEntry('a')).toBeDefined(); + jest.advanceTimersByTime(1); + expect(store.getEntry('a')).toBeUndefined(); + expect(onRemoved).toHaveBeenCalledWith('backdrop'); + }); + + it('calls onRemoved once even when finalize races the timeout', () => { + const store = new OverlayStore({ closingTimeoutMs: 100 }); + const onRemoved = jest.fn(); + store.request({ id: 'a' }, { onRemoved }); + store.dismiss('a'); + store.dismiss('a'); + store.finalize('a'); + jest.advanceTimersByTime(200); + expect(onRemoved).toHaveBeenCalledTimes(1); + }); + + it('dismissAll below a level keeps higher levels', () => { + const store = new OverlayStore(); + store.request({ id: 'modal' }); + store.request({ id: 'pin', level: 'hardware' }); + store.request({ id: 'lock', level: 'lock' }); + store.dismissAll({ belowLevel: 'secure' }); + expect(activeIds(store)).toEqual(['lock']); + }); +}); + +describe('OverlayStore page scope', () => { + it('suspends entries while the owning page is hidden and restores them', () => { + const store = new OverlayStore(); + store.request(page('dialog', 'swap')); + store.setPageVisible('swap', false); + expect(activeIds(store)).toEqual([]); + expect(store.getEntry('dialog')?.phase).toBe('active'); + store.setPageVisible('swap', true); + expect(activeIds(store)).toEqual(['dialog']); + }); + + it('does not promote queued entries into a hidden page', () => { + const store = new OverlayStore(); + store.setPageVisible('swap', false); + store.request(page('queued', 'swap', { strategy: 'queue' })); + expect(store.getSnapshot().queued.map((e) => e.id)).toEqual(['queued']); + store.setPageVisible('swap', true); + expect(activeIds(store)).toEqual(['queued']); + }); + + it('closes entries with page-removed when the page leaves', () => { + const store = new OverlayStore(); + const shownRemoved = jest.fn(); + const queuedRemoved = jest.fn(); + store.request(page('shown', 'swap'), { onRemoved: shownRemoved }); + store.request(page('waiting', 'swap', { strategy: 'queue' }), { + onRemoved: queuedRemoved, + }); + store.removePage('swap'); + expect(store.getEntry('shown')?.dismissReason).toBe('page-removed'); + expect(queuedRemoved).toHaveBeenCalledWith('page-removed'); + store.finalize('shown'); + expect(shownRemoved).toHaveBeenCalledWith('page-removed'); + }); +}); + +describe('OverlayStore back resolution', () => { + it('routes back to the topmost blocking global entry first', () => { + const store = new OverlayStore(); + store.request(page('page-dialog', 'swap')); + store.request({ id: 'sheet' }); + store.request({ id: 'toast', level: 'toast' }); + expect(store.resolveBack()).toEqual({ kind: 'dismiss', id: 'sheet' }); + store.dismiss('sheet'); + expect(store.resolveBack()).toEqual({ kind: 'dismiss', id: 'page-dialog' }); + }); + + it('blocks back under a non-dismissible entry', () => { + const store = new OverlayStore(); + store.request({ id: 'sheet' }); + store.request({ id: 'lock', level: 'lock', dismissible: false }); + expect(store.resolveBack()).toEqual({ kind: 'block' }); + }); + + it('passes back when only non-blocking entries remain', () => { + const store = new OverlayStore(); + store.request({ id: 'toast', level: 'toast' }); + expect(store.resolveBack()).toEqual({ kind: 'pass' }); + }); + + it('limits page entries to the focused host', () => { + const store = new OverlayStore(); + store.request({ + id: 'detail-dialog', + scope: 'page', + hostKey: 'detail', + ownerKey: 'd1', + }); + expect(store.resolveBack('main')).toEqual({ kind: 'pass' }); + expect(store.resolveBack('detail')).toEqual({ + kind: 'dismiss', + id: 'detail-dialog', + }); + }); + + it('reports the topmost blocking entry for inert handling', () => { + const store = new OverlayStore(); + store.request({ id: 'sheet' }); + store.request({ id: 'toast', level: 'toast' }); + expect(store.getBlockingTop()?.id).toBe('sheet'); + }); +}); + +describe('OverlayStore status bar owner', () => { + it('follows the topmost shown entry that sets a style', () => { + const store = new OverlayStore(); + store.request({ id: 'lock', level: 'lock', statusBarStyle: 'light' }); + store.request({ id: 'dialog', statusBarStyle: 'dark' }); + store.request({ id: 'toast', level: 'toast', blocking: false }); + expect(store.getStatusBarOwner()?.id).toBe('lock'); + + store.dismiss('lock'); + expect(store.getStatusBarOwner()?.id).toBe('dialog'); + store.finalize('lock'); + store.dismiss('dialog'); + expect(store.getStatusBarOwner()).toBeUndefined(); + }); + + it('skips suspended page entries', () => { + const store = new OverlayStore(); + store.request({ + id: 'page', + scope: 'page', + hostKey: 'host', + ownerKey: 'owner', + statusBarStyle: 'light', + }); + expect(store.getStatusBarOwner()?.id).toBe('page'); + store.setPageVisible('owner', false); + expect(store.getStatusBarOwner()).toBeUndefined(); + }); +}); + +describe('OverlayStore back resolution for non-blocking entries', () => { + it('lets a dismissible non-blocking entry take back, but not a toast', () => { + const store = new OverlayStore(); + store.request({ id: 'toast', level: 'toast', blocking: false }); + expect(store.resolveBack()).toEqual({ kind: 'pass' }); + store.request({ id: 'popover', blocking: false, dismissible: true }); + expect(store.resolveBack()).toEqual({ kind: 'dismiss', id: 'popover' }); + }); +}); diff --git a/native-views/react-native-native-overlay/src/__tests__/animation.test.ts b/native-views/react-native-native-overlay/src/__tests__/animation.test.ts new file mode 100644 index 000000000..11815e295 --- /dev/null +++ b/native-views/react-native-native-overlay/src/__tests__/animation.test.ts @@ -0,0 +1,121 @@ +import { + NATIVE_MOTION_PRESETS, + WEB_MOTION_PRESETS, +} from '../animation/motionPresets'; +import { resolveOverlayAnimation } from '../animation/resolveAnimation'; +import { + springDampingRatio, + springProgressAt, + springSettleTimeMs, + springToAndroidSpringForce, + springToCssLinear, +} from '../animation/spring'; + +import type { IOverlaySpring } from '../animation/types'; + +const QUICK: IOverlaySpring = { mass: 0.1, stiffness: 100, damping: 20 }; + +describe('spring math', () => { + it('maps Tamagui quick to an overdamped Android SpringForce', () => { + const force = springToAndroidSpringForce(QUICK); + expect(force.stiffness).toBeCloseTo(1000); + expect(force.dampingRatio).toBeCloseTo(3.162, 3); + }); + + it('starts at 0 and converges to 1 for every damping regime', () => { + const regimes: IOverlaySpring[] = [ + QUICK, + { mass: 1, stiffness: 100, damping: 20 }, + { mass: 0.9, stiffness: 100, damping: 10 }, + ]; + expect(springDampingRatio({ mass: 1, stiffness: 100, damping: 20 })).toBe( + 1 + ); + for (const spring of regimes) { + expect(springProgressAt(spring, 0)).toBeCloseTo(0, 6); + expect(springProgressAt(spring, 5000)).toBeCloseTo(1, 4); + } + }); + + it('overshoots when underdamped unless clamped', () => { + const medium = { mass: 0.9, stiffness: 100, damping: 10 }; + const peak = Math.max( + ...Array.from({ length: 1000 }, (_, t) => springProgressAt(medium, t)) + ); + expect(peak).toBeGreaterThan(1.1); + const clamped = { ...medium, overshootClamping: true }; + const clampedPeak = Math.max( + ...Array.from({ length: 1000 }, (_, t) => springProgressAt(clamped, t)) + ); + expect(clampedPeak).toBeLessThanOrEqual(1); + }); + + it('settles quick in roughly 1.3s at 0.1% precision', () => { + const settle = springSettleTimeMs(QUICK); + expect(settle).toBeGreaterThan(1200); + expect(settle).toBeLessThan(1500); + expect(springProgressAt(QUICK, 600)).toBeGreaterThan(0.95); + }); + + it('samples a CSS linear() curve ending at 1', () => { + const { durationMs, easing } = springToCssLinear(QUICK, 10); + expect(durationMs).toBe(springSettleTimeMs(QUICK)); + expect(easing.startsWith('linear(0, ')).toBe(true); + expect(easing.endsWith(', 1)')).toBe(true); + expect(easing.split(',')).toHaveLength(11); + }); +}); + +describe('resolveOverlayAnimation', () => { + it('uses the Dialog center defaults with native springs', () => { + const resolved = resolveOverlayAnimation( + 'center', + undefined, + NATIVE_MOTION_PRESETS + ); + expect(resolved.enter).toMatchObject({ + type: 'scale', + scale: 0.85, + fade: true, + }); + expect(resolved.enter.motion).toEqual(NATIVE_MOTION_PRESETS.quick); + expect(resolved.exit).toEqual(resolved.enter); + }); + + it('uses short web curves for the same preset names', () => { + const resolved = resolveOverlayAnimation( + 'sheet', + undefined, + WEB_MOTION_PRESETS + ); + expect(resolved.enter.motion).toEqual({ + type: 'timing', + timing: { durationMs: 150, easing: [0.25, 0.1, 0.25, 1] }, + }); + }); + + it('lets the lock screen appear instantly and fade out', () => { + const resolved = resolveOverlayAnimation( + 'fullscreen', + { enter: { type: 'none' }, exit: { type: 'fade', motion: 'lockFade' } }, + NATIVE_MOTION_PRESETS + ); + expect(resolved.enter.type).toBe('none'); + expect(resolved.exit.motion).toEqual(NATIVE_MOTION_PRESETS.lockFade); + }); + + it('accepts a custom spring and keeps the presentation fallback motion', () => { + const custom = { + type: 'spring', + spring: { mass: 1, stiffness: 230, damping: 26 }, + } as const; + const resolved = resolveOverlayAnimation( + 'toast', + { enter: { type: 'scale', scale: 0.8, offsetY: -20, motion: custom } }, + NATIVE_MOTION_PRESETS + ); + expect(resolved.enter.motion).toEqual(custom); + expect(resolved.enter.fade).toBe(true); + expect(resolved.backdrop.motion).toEqual(NATIVE_MOTION_PRESETS.quick); + }); +}); diff --git a/native-views/react-native-native-overlay/src/__tests__/keyboardShift.test.ts b/native-views/react-native-native-overlay/src/__tests__/keyboardShift.test.ts new file mode 100644 index 000000000..ba699181a --- /dev/null +++ b/native-views/react-native-native-overlay/src/__tests__/keyboardShift.test.ts @@ -0,0 +1,71 @@ +import { KEYBOARD_MARGIN, computeKeyboardShift } from '../web/useKeyboardShift'; + +describe('computeKeyboardShift', () => { + it('does not move without a keyboard', () => { + expect( + computeKeyboardShift({ + keyboardTop: undefined, + contentTop: 300, + contentBottom: 500, + isSheet: false, + }) + ).toBe(0); + }); + + it('lifts centered content until it clears the keyboard by the margin', () => { + expect( + computeKeyboardShift({ + keyboardTop: 450, + contentTop: 300, + contentBottom: 500, + isSheet: false, + }) + ).toBe(50 + KEYBOARD_MARGIN); + }); + + it('leaves content that already clears the keyboard alone', () => { + expect( + computeKeyboardShift({ + keyboardTop: 600, + contentTop: 300, + contentBottom: 500, + isSheet: false, + }) + ).toBe(0); + }); + + it('lifts a sheet by the covered part without a margin', () => { + expect( + computeKeyboardShift({ + keyboardTop: 500, + contentTop: 600, + contentBottom: 844, + isSheet: true, + }) + ).toBe(344); + }); + + it('never lifts above the top safe area', () => { + expect( + computeKeyboardShift({ + keyboardTop: 400, + contentTop: 100, + contentBottom: 800, + isSheet: true, + safeTop: 60, + }) + ).toBe(40); + }); + + it('never moves full-window content', () => { + expect( + computeKeyboardShift({ + keyboardTop: 400, + contentTop: 0, + contentBottom: 844, + isSheet: false, + safeTop: 47, + }) + ).toBe(0); + }); +}); diff --git a/native-views/react-native-native-overlay/src/animation/motionPresets.ts b/native-views/react-native-native-overlay/src/animation/motionPresets.ts new file mode 100644 index 000000000..0a4da3d0c --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/motionPresets.ts @@ -0,0 +1,52 @@ +import type { IOverlayMotion, IOverlayMotionPreset } from './types'; + +const EASE: readonly [number, number, number, number] = [0.25, 0.1, 0.25, 1]; +const EASE_OUT: readonly [number, number, number, number] = [0, 0, 0.58, 1]; + +function spring(mass: number, stiffness: number, damping: number) { + return { type: 'spring', spring: { mass, stiffness, damping } } as const; +} + +function timing( + durationMs: number, + easing: readonly [number, number, number, number] +) { + return { type: 'timing', timing: { durationMs, easing } } as const; +} + +/** + * Native tokens mirror `packages/components/tamagui.animations.native.ts` + * (Reanimated driver). `quick` intentionally uses a damping ratio of ~3.16 (no overshoot). + */ +export const NATIVE_MOTION_PRESETS: Record< + IOverlayMotionPreset, + IOverlayMotion +> = { + quick: spring(0.1, 100, 20), + popoverQuick: timing(150, [0.215, 0.61, 0.355, 1]), + smooth: spring(1, 438, 42), + fast: spring(1.2, 250, 20), + medium: spring(0.9, 100, 10), + slow: spring(1, 60, 20), + // @backpackapp-io/react-native-toast: 300ms Easing.inOut(Easing.quad). + toastSlide: timing(300, [0.455, 0.03, 0.515, 0.955]), + lockFade: timing(150, EASE_OUT), +}; + +/** + * Web tokens mirror `packages/components/tamagui.animations.web.ts` (CSS + * driver). Web keeps its own short curves; converting the native springs + * would make web overlays several times slower than today. + */ +export const WEB_MOTION_PRESETS: Record = + { + quick: timing(150, EASE), + popoverQuick: timing(150, [0.215, 0.61, 0.355, 1]), + smooth: timing(300, [0.2, 0, 0, 1]), + fast: timing(200, EASE_OUT), + medium: timing(300, EASE_OUT), + slow: timing(450, EASE_OUT), + // sonner: transform / opacity 400ms ease. + toastSlide: timing(400, EASE), + lockFade: timing(150, EASE_OUT), + }; diff --git a/native-views/react-native-native-overlay/src/animation/platformMotionPresets.ts b/native-views/react-native-native-overlay/src/animation/platformMotionPresets.ts new file mode 100644 index 000000000..68c22d171 --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/platformMotionPresets.ts @@ -0,0 +1 @@ +export { NATIVE_MOTION_PRESETS as OVERLAY_MOTION_PRESETS } from './motionPresets'; diff --git a/native-views/react-native-native-overlay/src/animation/platformMotionPresets.web.ts b/native-views/react-native-native-overlay/src/animation/platformMotionPresets.web.ts new file mode 100644 index 000000000..72f38934a --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/platformMotionPresets.web.ts @@ -0,0 +1 @@ +export { WEB_MOTION_PRESETS as OVERLAY_MOTION_PRESETS } from './motionPresets'; diff --git a/native-views/react-native-native-overlay/src/animation/resolveAnimation.ts b/native-views/react-native-native-overlay/src/animation/resolveAnimation.ts new file mode 100644 index 000000000..4fac4a52c --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/resolveAnimation.ts @@ -0,0 +1,103 @@ +import type { + IOverlayAnimation, + IOverlayMotion, + IOverlayMotionInput, + IOverlayMotionPreset, + IOverlayPresentation, + IOverlayTransition, +} from './types'; + +export interface IResolvedOverlayTransition + extends Omit { + motion: IOverlayMotion; +} + +export interface IResolvedOverlayAnimation { + enter: IResolvedOverlayTransition; + exit: IResolvedOverlayTransition; + backdrop: { motion: IOverlayMotion }; +} + +/** + * Defaults match today's visuals: Dialog center (scale 0.85 + fade, quick), + * Tamagui Sheet (slide from bottom, quick), message toasts (slide from top, + * 300ms quad), Popover floating content (scale 0.95 + fade, popoverQuick). + */ +export const DEFAULT_OVERLAY_ANIMATIONS: Record< + IOverlayPresentation, + { enter: IOverlayTransition; backdrop: IOverlayMotionPreset } +> = { + sheet: { + enter: { type: 'slide', edge: 'bottom', motion: 'quick' }, + backdrop: 'quick', + }, + center: { + enter: { type: 'scale', scale: 0.85, fade: true, motion: 'quick' }, + backdrop: 'quick', + }, + toast: { + enter: { type: 'slide', edge: 'top', fade: true, motion: 'toastSlide' }, + backdrop: 'quick', + }, + fullscreen: { + enter: { type: 'fade', motion: 'quick' }, + backdrop: 'quick', + }, + anchored: { + enter: { + type: 'scale', + scale: 0.95, + fade: true, + origin: 'anchor', + motion: 'popoverQuick', + }, + backdrop: 'popoverQuick', + }, +}; + +function resolveMotion( + input: IOverlayMotionInput | undefined, + fallback: IOverlayMotionInput, + presets: Record +): IOverlayMotion { + const value = input ?? fallback; + return typeof value === 'string' ? presets[value] : value; +} + +function resolveTransition( + transition: IOverlayTransition, + fallbackMotion: IOverlayMotionInput, + presets: Record +): IResolvedOverlayTransition { + return { + ...transition, + fade: transition.fade ?? transition.type === 'scale', + motion: resolveMotion(transition.motion, fallbackMotion, presets), + }; +} + +/** Fills presentation defaults and turns preset names into concrete curves. */ +export function resolveOverlayAnimation( + presentation: IOverlayPresentation, + animation: IOverlayAnimation | undefined, + presets: Record +): IResolvedOverlayAnimation { + const defaults = DEFAULT_OVERLAY_ANIMATIONS[presentation]; + const enterInput = animation?.enter ?? defaults.enter; + const fallbackMotion = defaults.enter.motion ?? 'quick'; + const enter = resolveTransition(enterInput, fallbackMotion, presets); + const exit = animation?.exit + ? resolveTransition(animation.exit, fallbackMotion, presets) + : enter; + return { + enter, + exit, + backdrop: { + motion: resolveMotion( + animation?.backdrop?.motion, + defaults.backdrop, + presets + ), + }, + }; +} diff --git a/native-views/react-native-native-overlay/src/animation/spring.ts b/native-views/react-native-native-overlay/src/animation/spring.ts new file mode 100644 index 000000000..3bd8d39c2 --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/spring.ts @@ -0,0 +1,80 @@ +import type { IOverlaySpring } from './types'; + +export function springNaturalFrequency(spring: IOverlaySpring): number { + return Math.sqrt(spring.stiffness / spring.mass); +} + +export function springDampingRatio(spring: IOverlaySpring): number { + return spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass)); +} + +/** + * Android `SpringForce` assumes unit mass, so it takes + * stiffness / mass and the damping ratio. + */ +export function springToAndroidSpringForce(spring: IOverlaySpring): { + stiffness: number; + dampingRatio: number; +} { + return { + stiffness: spring.stiffness / spring.mass, + dampingRatio: springDampingRatio(spring), + }; +} + +/** Normalized step response: progress from 0 to 1 at `tMs`, zero start velocity. */ +export function springProgressAt(spring: IOverlaySpring, tMs: number): number { + const t = tMs / 1000; + const w0 = springNaturalFrequency(spring); + const zeta = springDampingRatio(spring); + let progress: number; + if (zeta < 1) { + const wd = w0 * Math.sqrt(1 - zeta * zeta); + progress = + 1 - + Math.exp(-zeta * w0 * t) * + (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t)); + } else if (zeta === 1) { + progress = 1 - Math.exp(-w0 * t) * (1 + w0 * t); + } else { + const root = Math.sqrt(zeta * zeta - 1); + const r1 = -w0 * (zeta - root); + const r2 = -w0 * (zeta + root); + progress = 1 - (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r2 - r1); + } + return spring.overshootClamping ? Math.min(progress, 1) : progress; +} + +const MAX_SETTLE_MS = 10_000; + +/** First time after which the response stays within `epsilon` of the target. */ +export function springSettleTimeMs( + spring: IOverlaySpring, + epsilon = 0.001 +): number { + let lastOutside = 0; + for (let t = 0; t <= MAX_SETTLE_MS; t += 1) { + if (Math.abs(1 - springProgressAt(spring, t)) >= epsilon) { + lastOutside = t; + } + } + return lastOutside + 1; +} + +/** + * CSS `linear()` easing sampled from the spring, for the Web Animations API when a + * caller passes a custom spring. Presets use `WEB_MOTION_PRESETS` instead. + */ +export function springToCssLinear( + spring: IOverlaySpring, + stops = 40 +): { durationMs: number; easing: string } { + const durationMs = springSettleTimeMs(spring); + const points: string[] = []; + for (let i = 0; i <= stops; i += 1) { + const value = + i === stops ? 1 : springProgressAt(spring, (durationMs * i) / stops); + points.push(String(Math.round(value * 10_000) / 10_000)); + } + return { durationMs, easing: `linear(${points.join(', ')})` }; +} diff --git a/native-views/react-native-native-overlay/src/animation/types.ts b/native-views/react-native-native-overlay/src/animation/types.ts new file mode 100644 index 000000000..823aebfa3 --- /dev/null +++ b/native-views/react-native-native-overlay/src/animation/types.ts @@ -0,0 +1,71 @@ +/** + * Physical spring, identical to Reanimated / Tamagui: + * mass * x'' + damping * x' + stiffness * x = 0. + * Springs with a damping ratio above 1 are valid and common (`quick`). + */ +export interface IOverlaySpring { + mass: number; + stiffness: number; + damping: number; + /** Clamp the value at the target instead of overshooting. */ + overshootClamping?: boolean; +} + +export type IOverlayCubicBezier = readonly [number, number, number, number]; + +export interface IOverlayTiming { + durationMs: number; + easing: IOverlayCubicBezier; +} + +export type IOverlayMotion = + | { type: 'spring'; spring: IOverlaySpring } + | { type: 'timing'; timing: IOverlayTiming }; + +/** Named motion tokens; each platform maps them to its own curve. */ +export type IOverlayMotionPreset = + | 'quick' + | 'popoverQuick' + | 'smooth' + | 'fast' + | 'medium' + | 'slow' + | 'toastSlide' + | 'lockFade'; + +export type IOverlayMotionInput = IOverlayMotionPreset | IOverlayMotion; + +export type IOverlayEdge = 'top' | 'bottom' | 'left' | 'right'; + +export interface IOverlayTransition { + /** `none` shows / hides immediately; content may animate itself. */ + type: 'none' | 'fade' | 'slide' | 'scale'; + /** `slide`: the edge the overlay travels from (enter) or to (exit). */ + edge?: IOverlayEdge; + /** `slide`: travel in points; defaults to off-screen. */ + distance?: number; + /** `scale`: start (enter) or end (exit) scale. */ + scale?: number; + /** `scale`: extra translate along Y in points, e.g. -20 for custom toasts. */ + offsetY?: number; + /** Fade opacity alongside slide / scale. Defaults to true for scale. */ + fade?: boolean; + /** `scale`: `anchor` scales from the resolved anchor placement. */ + origin?: 'center' | 'anchor'; + motion?: IOverlayMotionInput; +} + +export interface IOverlayAnimation { + enter?: IOverlayTransition; + /** Defaults to the reverse of `enter`. */ + exit?: IOverlayTransition; + /** Backdrop fade; runs on the same clock as the content by default. */ + backdrop?: { motion?: IOverlayMotionInput }; +} + +export type IOverlayPresentation = + | 'sheet' + | 'center' + | 'fullscreen' + | 'toast' + | 'anchored'; diff --git a/native-views/react-native-native-overlay/src/index.ts b/native-views/react-native-native-overlay/src/index.ts new file mode 100644 index 000000000..76ccd0872 --- /dev/null +++ b/native-views/react-native-native-overlay/src/index.ts @@ -0,0 +1,55 @@ +export * from './types'; +export { + OVERLAY_LEVELS, + OVERLAY_LEVEL_ORDER, + OVERLAY_WEB_PAGE_Z_INDEX_BASE, + OVERLAY_WEB_Z_INDEX_BASE, + isBlockingLevel, +} from './OverlayLevels'; +export { OverlayRequestError } from './OverlayErrors'; +export { OverlayStore, findStatusBarOwner, overlayStore } from './OverlayStore'; +export type { + IOverlayBackResolution, + IOverlayEntryHandlers, + IOverlayStoreOptions, +} from './OverlayStore'; +export * from './animation/types'; +export { + NATIVE_MOTION_PRESETS, + WEB_MOTION_PRESETS, +} from './animation/motionPresets'; +export { OVERLAY_MOTION_PRESETS } from './animation/platformMotionPresets'; +export { + DEFAULT_OVERLAY_ANIMATIONS, + resolveOverlayAnimation, +} from './animation/resolveAnimation'; +export type { + IResolvedOverlayAnimation, + IResolvedOverlayTransition, +} from './animation/resolveAnimation'; +export { + springDampingRatio, + springNaturalFrequency, + springProgressAt, + springSettleTimeMs, + springToAndroidSpringForce, + springToCssLinear, +} from './animation/spring'; +export { OverlayView } from './OverlayView'; +export { + useEnclosingOverlayLevel, + useNestedOverlayLevel, +} from './OverlayLevelContext'; +export type { + IOverlayBackdrop, + IOverlayRequestDismissReason, + IOverlaySheetOptions, + IOverlayViewProps, +} from './OverlayViewTypes'; +export { + OverlayPageHostScope, + OverlayPageOwnerScope, + useOverlayPageScope, +} from './OverlayPageScope'; +export type { IOverlayPageScope } from './OverlayPageScope'; +export { OverlayPageHost } from './OverlayPageHost'; diff --git a/native-views/react-native-native-overlay/src/types.ts b/native-views/react-native-native-overlay/src/types.ts new file mode 100644 index 000000000..3f25bbc76 --- /dev/null +++ b/native-views/react-native-native-overlay/src/types.ts @@ -0,0 +1,79 @@ +export type IOverlayScope = 'global' | 'page'; + +export type IOverlayLevel = + | 'modal' + | 'hardware' + | 'secure' + | 'toast' + | 'lock' + | 'debug'; + +export type IOverlayStrategy = 'stack' | 'queue' | 'replace'; + +export type IOverlayPhase = 'queued' | 'active' | 'closing'; + +export type IOverlayDismissReason = + | 'programmatic' + | 'back' + | 'backdrop' + | 'pan' + | 'replaced' + | 'page-removed' + | 'system'; + +/** `light`: light content for dark overlays; `dark`: dark content. */ +export type IOverlayStatusBarStyle = 'light' | 'dark'; + +export interface IOverlayRequest { + /** Stable id; generated when omitted. Re-requesting an existing id is a no-op. */ + id?: string; + scope?: IOverlayScope; + level?: IOverlayLevel; + strategy?: IOverlayStrategy; + /** Queue ordering only: higher runs first, ties keep request order. */ + priority?: number; + /** + * `replace` only: entries in the same lane with this key are replaced. + * When omitted, the topmost active entry of the lane is replaced. + */ + replaceKey?: string; + /** Required for `page` scope: the root-route host that renders the entry. */ + hostKey?: string; + /** Required for `page` scope: the nearest page that owns the entry. */ + ownerKey?: string; + /** Receives back / Escape. Defaults to true for blocking levels. */ + dismissible?: boolean; + /** Blocks interaction with everything below. Defaults by level. */ + blocking?: boolean; + /** + * Status bar content while this is the topmost styled overlay; unset + * leaves the status bar alone. + */ + statusBarStyle?: IOverlayStatusBarStyle; +} + +export interface IOverlayEntry { + id: string; + scope: IOverlayScope; + level: IOverlayLevel; + strategy: IOverlayStrategy; + priority: number; + replaceKey: string | undefined; + hostKey: string | undefined; + ownerKey: string | undefined; + dismissible: boolean; + blocking: boolean; + statusBarStyle: IOverlayStatusBarStyle | undefined; + phase: IOverlayPhase; + /** True while the owning page is covered or detached; page scope only. */ + suspended: boolean; + dismissReason: IOverlayDismissReason | undefined; + /** Monotonic request order. */ + seq: number; +} + +export interface IOverlaySnapshot { + /** Every non-queued entry in bottom-to-top render order. */ + entries: readonly IOverlayEntry[]; + queued: readonly IOverlayEntry[]; +} diff --git a/native-views/react-native-native-overlay/src/useOverlayController.ts b/native-views/react-native-native-overlay/src/useOverlayController.ts new file mode 100644 index 000000000..cd630a6b0 --- /dev/null +++ b/native-views/react-native-native-overlay/src/useOverlayController.ts @@ -0,0 +1,199 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; + +import { OVERLAY_MOTION_PRESETS } from './animation/platformMotionPresets'; +import { resolveOverlayAnimation } from './animation/resolveAnimation'; +import { useOverlayPageScope } from './OverlayPageScope'; +import { findStatusBarOwner, overlayStore } from './OverlayStore'; + +import type { IResolvedOverlayAnimation } from './animation/resolveAnimation'; +import type { + IOverlayRequestDismissReason, + IOverlayViewProps, +} from './OverlayViewTypes'; +import type { IOverlayEntry } from './types'; + +export interface IOverlayController { + entry: IOverlayEntry | undefined; + /** Render the content (entry is active or closing). */ + mounted: boolean; + /** + * Ask the host to show the content. Page entries stay presented while + * suspended; the page host hides them instead (the owner may be frozen). + */ + presented: boolean; + scope: 'global' | 'page'; + /** This entry's `statusBarStyle` currently drives the status bar. */ + ownsStatusBar: boolean; + hostKey: string | undefined; + ownerKey: string | undefined; + animation: IResolvedOverlayAnimation; + onHostPresented: () => void; + onHostDismissed: () => void; + onHostRequestDismiss: (reason: IOverlayRequestDismissReason) => void; +} + +function useLatest(value: T) { + const ref = useRef(value); + ref.current = value; + return ref; +} + +/** + * Bridges a declarative overlay to the shared `overlayStore`: requests an + * entry while `visible`, follows the store's phase, and finalizes the entry + * once the host reports the end of the exit animation. + */ +export function useOverlayController( + props: IOverlayViewProps +): IOverlayController { + const { + visible, + level, + strategy, + priority, + replaceKey, + blocking, + dismissOnBackPress, + presentation = 'center', + animation, + statusBarStyle, + } = props; + const pageScope = useOverlayPageScope(); + const hostKey = props.hostKey ?? pageScope.hostKey; + const ownerKey = props.ownerKey ?? pageScope.ownerKey; + // Without a host or owner there is nowhere to anchor a page overlay. + const scope = + props.scope === 'page' && hostKey && ownerKey ? 'page' : 'global'; + const baseId = useId(); + const cycleRef = useRef(0); + const [entryId, setEntryId] = useState(); + // Set when the store closed the entry (replace, back) while `visible` is + // still true, so the effect below does not immediately reopen it. + const closedByStoreRef = useRef(false); + const propsRef = useLatest(props); + + const snapshot = useSyncExternalStore( + overlayStore.subscribe, + overlayStore.getSnapshot, + overlayStore.getSnapshot + ); + const entry = useMemo( + () => + entryId + ? snapshot.entries.find((e) => e.id === entryId) ?? + snapshot.queued.find((e) => e.id === entryId) + : undefined, + [entryId, snapshot] + ); + + useEffect(() => { + if (!visible) { + closedByStoreRef.current = false; + if (entryId) { + overlayStore.dismiss(entryId, 'programmatic'); + } + return; + } + if (entryId || closedByStoreRef.current) { + return; + } + cycleRef.current += 1; + const id = `overlay-view${baseId}${cycleRef.current}`; + overlayStore.request( + { + id, + scope, + hostKey, + ownerKey, + level, + strategy, + priority, + replaceKey, + blocking, + dismissible: dismissOnBackPress, + statusBarStyle, + }, + { + onRemoved: (reason) => { + setEntryId((current) => (current === id ? undefined : current)); + if (reason !== 'programmatic') { + closedByStoreRef.current = true; + } + propsRef.current.onClose?.(reason); + }, + } + ); + setEntryId(id); + // Level / strategy changes apply to the next open cycle only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, entryId]); + + useEffect( + () => () => { + if (entryId) { + overlayStore.dismiss(entryId, 'system'); + overlayStore.finalize(entryId); + } + }, + [entryId] + ); + + const resolvedAnimation = useMemo( + () => + resolveOverlayAnimation(presentation, animation, OVERLAY_MOTION_PRESETS), + [presentation, animation] + ); + + const onHostPresented = useCallback(() => { + propsRef.current.onPresented?.(); + }, [propsRef]); + + const onHostDismissed = useCallback(() => { + if (!entryId) { + return; + } + const phase = overlayStore.getEntry(entryId)?.phase; + if (phase === 'active') { + // Native closed it on its own, e.g. a lower sheet in the same level + // window was dismissed and UIKit took the ones above with it. + overlayStore.dismiss(entryId, 'system'); + } + overlayStore.finalize(entryId); + }, [entryId]); + + const onHostRequestDismiss = useCallback( + (reason: IOverlayRequestDismissReason) => { + const handler = propsRef.current.onRequestDismiss; + handler?.(reason); + if (entryId && (!handler || reason === 'pan')) { + overlayStore.dismiss(entryId, reason); + } + }, + [entryId, propsRef] + ); + + return { + entry, + mounted: !!entry && entry.phase !== 'queued', + presented: + !!entry && + entry.phase === 'active' && + (scope === 'page' || !entry.suspended), + scope, + ownsStatusBar: !!entryId && findStatusBarOwner(snapshot)?.id === entryId, + hostKey: scope === 'page' ? hostKey : undefined, + ownerKey: scope === 'page' ? ownerKey : undefined, + animation: resolvedAnimation, + onHostPresented, + onHostDismissed, + onHostRequestDismiss, + }; +} diff --git a/native-views/react-native-native-overlay/src/useSuspendedPageOwners.ts b/native-views/react-native-native-overlay/src/useSuspendedPageOwners.ts new file mode 100644 index 000000000..94c30ef9e --- /dev/null +++ b/native-views/react-native-native-overlay/src/useSuspendedPageOwners.ts @@ -0,0 +1,26 @@ +import { useMemo, useSyncExternalStore } from 'react'; + +import { overlayStore } from './OverlayStore'; + +/** + * Owners whose page overlays in `hostKey` must be hidden. The page host + * applies this natively: the owning page may be frozen (freezeOnBlur) and + * cannot re-render its own overlays. + */ +export function useSuspendedPageOwners(hostKey: string): string[] { + const snapshot = useSyncExternalStore( + overlayStore.subscribe, + overlayStore.getSnapshot, + overlayStore.getSnapshot + ); + const joined = snapshot.entries + .filter((e) => e.scope === 'page' && e.hostKey === hostKey && e.suspended) + .map((e) => e.ownerKey ?? '') + .filter(Boolean) + .toSorted() + .join('\n'); + return useMemo( + () => (joined ? [...new Set(joined.split('\n'))] : []), + [joined] + ); +} diff --git a/native-views/react-native-native-overlay/src/web/animateTransition.ts b/native-views/react-native-native-overlay/src/web/animateTransition.ts new file mode 100644 index 000000000..65661412c --- /dev/null +++ b/native-views/react-native-native-overlay/src/web/animateTransition.ts @@ -0,0 +1,137 @@ +import { springToCssLinear } from '../animation/spring'; + +import type { IResolvedOverlayTransition } from '../animation/resolveAnimation'; +import type { IOverlayMotion } from '../animation/types'; + +export interface IContentExtent { + top: number; + bottom: number; + left: number; + right: number; +} + +/** Union of the rendered children rects; the content wrapper is full-window. */ +export function measureContentExtent(container: HTMLElement): IContentExtent { + const rects = Array.from(container.children).map((child) => + child.getBoundingClientRect() + ); + if (rects.length === 0) { + return { + top: 0, + bottom: window.innerHeight, + left: 0, + right: window.innerWidth, + }; + } + return { + top: Math.min(...rects.map((r) => r.top)), + bottom: Math.max(...rects.map((r) => r.bottom)), + left: Math.min(...rects.map((r) => r.left)), + right: Math.max(...rects.map((r) => r.right)), + }; +} + +function toTiming(motion: IOverlayMotion): { + duration: number; + easing: string; +} { + if (motion.type === 'timing') { + const [x1, y1, x2, y2] = motion.timing.easing; + return { + duration: motion.timing.durationMs, + easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, + }; + } + const { durationMs, easing } = springToCssLinear(motion.spring); + return { duration: durationMs, easing }; +} + +function prefersReducedMotion(): boolean { + return ( + typeof globalThis.matchMedia === 'function' && + globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +/** The off-stage keyframe for a transition: where enter starts / exit ends. */ +function hiddenKeyframe( + transition: IResolvedOverlayTransition, + extent: IContentExtent +): Keyframe { + const frame: Keyframe = {}; + if (transition.fade || transition.type === 'fade') { + frame.opacity = 0; + } + if (transition.type === 'slide') { + const edge = transition.edge ?? 'bottom'; + const distance = + transition.distance ?? + { + top: extent.bottom, + bottom: window.innerHeight - extent.top, + left: extent.right, + right: window.innerWidth - extent.left, + }[edge]; + frame.transform = { + top: `translate3d(0, ${-distance}px, 0)`, + bottom: `translate3d(0, ${distance}px, 0)`, + left: `translate3d(${-distance}px, 0, 0)`, + right: `translate3d(${distance}px, 0, 0)`, + }[edge]; + } else if (transition.type === 'scale') { + frame.transform = `translate3d(0, ${transition.offsetY ?? 0}px, 0) scale(${ + transition.scale ?? 0.95 + })`; + } + return frame; +} + +const VISIBLE_KEYFRAME: Keyframe = { + opacity: 1, + transform: 'translate3d(0, 0, 0) scale(1)', +}; + +/** + * Runs an enter (`direction: 'in'`) or exit transition on `element`. The + * slide distance and scale origin come from the children of `contentRoot`, + * because the content wrapper itself always fills the window. + */ +export function animateTransition( + element: HTMLElement, + contentRoot: HTMLElement, + transition: IResolvedOverlayTransition, + direction: 'in' | 'out' +): Animation | undefined { + if (transition.type === 'none') { + return undefined; + } + const effective: IResolvedOverlayTransition = prefersReducedMotion() + ? { ...transition, type: 'fade', fade: true } + : transition; + const extent = measureContentExtent(contentRoot); + element.style.transformOrigin = `${(extent.left + extent.right) / 2}px ${ + (extent.top + extent.bottom) / 2 + }px`; + const hidden = hiddenKeyframe(effective, extent); + const visible: Keyframe = {}; + Object.keys(hidden).forEach((key) => { + visible[key] = VISIBLE_KEYFRAME[key]; + }); + const keyframes = direction === 'in' ? [hidden, visible] : [visible, hidden]; + return element.animate(keyframes, { + ...toTiming(effective.motion), + fill: 'forwards', + }); +} + +export function animateBackdrop( + element: HTMLElement, + motion: IOverlayMotion, + direction: 'in' | 'out' +): Animation { + const keyframes = + direction === 'in' + ? [{ opacity: 0 }, { opacity: 1 }] + : [{ opacity: 1 }, { opacity: 0 }]; + return element.animate(keyframes, { ...toTiming(motion), fill: 'forwards' }); +} diff --git a/native-views/react-native-native-overlay/src/web/overlayLayers.ts b/native-views/react-native-native-overlay/src/web/overlayLayers.ts new file mode 100644 index 000000000..e2096768a --- /dev/null +++ b/native-views/react-native-native-overlay/src/web/overlayLayers.ts @@ -0,0 +1,182 @@ +import { + OVERLAY_LEVELS, + OVERLAY_LEVEL_ORDER, + OVERLAY_WEB_Z_INDEX_BASE, +} from '../OverlayLevels'; +import { overlayStore } from '../OverlayStore'; + +import type { IOverlayRequestDismissReason } from '../OverlayViewTypes'; +import type { IOverlayLevel } from '../types'; + +const LAYER_ATTRIBUTE = 'data-onekey-overlay-layer'; +export const ENTRY_ATTRIBUTE = 'data-onekey-overlay-entry'; + +const APP_ROOT_ID = 'root'; + +const layerRoots = new Map(); +const pageHosts = new Map(); +const dismissRequesters = new Map< + string, + (reason: IOverlayRequestDismissReason) => void +>(); +let installed = false; + +function createLayerRoot(level: IOverlayLevel): HTMLDivElement { + const root = document.createElement('div'); + root.setAttribute(LAYER_ATTRIBUTE, level); + Object.assign(root.style, { + position: 'fixed', + inset: '0', + pointerEvents: 'none', + zIndex: String(OVERLAY_WEB_Z_INDEX_BASE[level]), + isolation: 'isolate', + }); + // Keep DOM order equal to level order so equal z-index ties stay correct. + const next = OVERLAY_LEVELS.slice(OVERLAY_LEVELS.indexOf(level) + 1) + .map((l) => layerRoots.get(l)) + .find(Boolean); + document.body.insertBefore(root, next ?? null); + return root; +} + +function setInert(element: HTMLElement | null | undefined, inert: boolean) { + if (element && element.inert !== inert) { + element.inert = inert; + } +} + +// Elements outside a page host made inert for a blocking page overlay. +let pageInertElements = new Set(); + +/** + * A page host lives inside the app root, so a blocking page overlay cannot + * inert the app root; inert every sibling on the path from the host up to it + * instead (other routes, the page itself, navigation chrome). + */ +function siblingsOutside(host: HTMLElement, root: HTMLElement) { + const siblings = new Set(); + let node: HTMLElement = host; + while (node !== root && node.parentElement) { + const parent: HTMLElement = node.parentElement; + for (const sibling of Array.from(parent.children)) { + if (sibling !== node && sibling instanceof HTMLElement) { + siblings.add(sibling); + } + } + node = parent; + } + return siblings; +} + +function inertEntriesBelow(container: ParentNode, seq: number) { + container + .querySelectorAll(`[${ENTRY_ATTRIBUTE}]`) + .forEach((node) => { + setInert(node, Number(node.dataset.stackOrder) < seq); + }); +} + +/** Everything rendered below the topmost blocking entry becomes inert. */ +function syncInert() { + const top = overlayStore.getBlockingTop(); + const appRoot = document.getElementById(APP_ROOT_ID); + let nextPageInert = new Set(); + const pageHost = + top?.scope === 'page' ? getOverlayPageHost(top.hostKey) : undefined; + if (top && pageHost && appRoot?.contains(pageHost)) { + setInert(appRoot, false); + nextPageInert = siblingsOutside(pageHost, appRoot); + inertEntriesBelow(pageHost, top.seq); + } else { + setInert(appRoot, !!top); + } + pageInertElements.forEach((element) => { + if (!nextPageInert.has(element)) { + setInert(element, false); + } + }); + nextPageInert.forEach((element) => setInert(element, true)); + pageInertElements = nextPageInert; + + for (const [level, root] of layerRoots) { + // Global layers render above every page overlay. + const below = + top?.scope === 'global' && + OVERLAY_LEVEL_ORDER[level] < OVERLAY_LEVEL_ORDER[top.level]; + setInert(root, below); + if (top?.scope === 'global' && level === top.level) { + inertEntriesBelow(root, top.seq); + } + } +} + +function onKeyDown(event: KeyboardEvent) { + // Escape while composing belongs to the IME. + if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) { + return; + } + const resolution = overlayStore.resolveBack(); + if (resolution.kind === 'pass') { + return; + } + event.stopPropagation(); + event.preventDefault(); + if (resolution.kind === 'dismiss') { + dismissRequesters.get(resolution.id)?.('back'); + } +} + +function installOverlayWebManager() { + if (installed || typeof document === 'undefined') { + return; + } + installed = true; + overlayStore.subscribe(syncInert); + document.addEventListener('keydown', onKeyDown, true); +} + +export function getOverlayLayerRoot(level: IOverlayLevel): HTMLDivElement { + installOverlayWebManager(); + let root = layerRoots.get(level); + if (!root || !root.isConnected) { + root = createLayerRoot(level); + layerRoots.set(level, root); + } + return root; +} + +export function registerOverlayDismissRequester( + id: string, + requester: (reason: IOverlayRequestDismissReason) => void +): () => void { + dismissRequesters.set(id, requester); + return () => { + if (dismissRequesters.get(id) === requester) { + dismissRequesters.delete(id); + } + }; +} + +/** Re-run after entry DOM nodes mount, since the store emits before commit. */ +export function scheduleOverlayInertSync() { + requestAnimationFrame(syncInert); +} + +export function registerOverlayPageHost( + hostKey: string, + element: HTMLDivElement +): () => void { + installOverlayWebManager(); + pageHosts.set(hostKey, element); + return () => { + if (pageHosts.get(hostKey) === element) { + pageHosts.delete(hostKey); + } + }; +} + +export function getOverlayPageHost( + hostKey: string | undefined +): HTMLDivElement | undefined { + return hostKey ? pageHosts.get(hostKey) : undefined; +} diff --git a/native-views/react-native-native-overlay/src/web/useKeyboardShift.ts b/native-views/react-native-native-overlay/src/web/useKeyboardShift.ts new file mode 100644 index 000000000..b296c25c9 --- /dev/null +++ b/native-views/react-native-native-overlay/src/web/useKeyboardShift.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef } from 'react'; +import type { RefObject } from 'react'; + +/** Gap between centered content and the keyboard; matches native. */ +export const KEYBOARD_MARGIN = 16; + +/** + * How far to lift overlay content above the software keyboard (>= 0), the + * same rule as iOS and Android: a sheet until its bottom clears the + * keyboard, other content until its bottom is `KEYBOARD_MARGIN` above it, + * never above the top edge. Full-window content never moves. + */ +export function computeKeyboardShift({ + keyboardTop, + contentTop, + contentBottom, + isSheet, + safeTop = 0, +}: { + keyboardTop: number | undefined; + contentTop: number; + contentBottom: number; + isSheet: boolean; + safeTop?: number; +}): number { + if (keyboardTop === undefined) { + return 0; + } + const needed = isSheet + ? contentBottom - keyboardTop + : contentBottom + KEYBOARD_MARGIN - keyboardTop; + const room = contentTop - safeTop; + return Math.max(0, Math.min(needed, room)); +} + +/** + * Vertical extent of the rendered children of the full-window React root, + * relative to the content wrapper, from layout offsets (no transforms). + */ +function layoutExtent( + content: HTMLElement | null +): { top: number; bottom: number } | undefined { + const root = content?.firstElementChild as HTMLElement | null | undefined; + if (!root) { + return undefined; + } + const children = Array.from(root.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement + ); + if (children.length === 0) { + return undefined; + } + return { + top: Math.min(...children.map((c) => root.offsetTop + c.offsetTop)), + bottom: Math.max( + ...children.map((c) => root.offsetTop + c.offsetTop + c.offsetHeight) + ), + }; +} + +/** + * Mobile browsers shrink the visual viewport for the keyboard while fixed + * layers keep the layout viewport; lift the entry's keyboard layer by the + * covered part. + */ +export function useKeyboardShift({ + layerRef, + sheetRef, + contentRef, + isSheet, + enabled, +}: { + layerRef: RefObject; + sheetRef: RefObject; + contentRef: RefObject; + isSheet: boolean; + enabled: boolean; +}) { + const shiftRef = useRef(0); + + useEffect(() => { + const viewport = globalThis.visualViewport; + const layer = layerRef.current; + if (!viewport || !layer || !enabled) { + return; + } + const update = () => { + const keyboardBottom = viewport.offsetTop + viewport.height; + const covered = globalThis.innerHeight - keyboardBottom; + const current = shiftRef.current; + // Layout geometry (offsets ignore transforms), placed from the entry, + // which never transforms: neither the lift (maybe mid-transition) nor + // the enter / exit animation may skew the resting frame. + const origin = layer.parentElement?.getBoundingClientRect().top ?? 0; + let top = origin; + let bottom = origin + layer.offsetHeight; + const sheet = sheetRef.current; + if (isSheet && sheet) { + top = origin + sheet.offsetTop; + bottom = top + sheet.offsetHeight; + } else { + const extent = layoutExtent(contentRef.current); + if (extent) { + top = origin + extent.top; + bottom = origin + extent.bottom; + } + } + const shift = computeKeyboardShift({ + keyboardTop: covered > 1 ? keyboardBottom : undefined, + contentTop: top, + contentBottom: bottom, + isSheet, + }); + if (Math.abs(shift - current) < 0.5) { + return; + } + shiftRef.current = shift; + layer.style.transform = shift ? `translate3d(0, ${-shift}px, 0)` : ''; + }; + viewport.addEventListener('resize', update); + viewport.addEventListener('scroll', update); + update(); + return () => { + viewport.removeEventListener('resize', update); + viewport.removeEventListener('scroll', update); + shiftRef.current = 0; + layer.style.transform = ''; + }; + }, [contentRef, enabled, isSheet, layerRef, sheetRef]); +} diff --git a/native-views/react-native-native-overlay/src/web/useSheetDrag.ts b/native-views/react-native-native-overlay/src/web/useSheetDrag.ts new file mode 100644 index 000000000..bbbecbf91 --- /dev/null +++ b/native-views/react-native-native-overlay/src/web/useSheetDrag.ts @@ -0,0 +1,143 @@ +import { useEffect, useRef } from 'react'; +import type { RefObject } from 'react'; + +const DRAG_START_THRESHOLD = 6; +const DISMISS_DISTANCE_RATIO = 0.4; +const DISMISS_VELOCITY = 0.5; // px per ms +const UPWARD_RESISTANCE = 0.2; +const SETTLE_TRANSITION = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)'; + +function scrolledAncestorWithin( + target: EventTarget | null, + sheet: HTMLElement +) { + let node = target instanceof HTMLElement ? target : null; + while (node && node !== sheet) { + if (node.scrollTop > 0 && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return undefined; +} + +/** + * Pointer-driven drag-to-dismiss for the web bottom sheet: follows the + * finger downward, resists upward, and dismisses past 40% of the height or + * on a fast flick. A drag never starts from inside scrolled content. + */ +export function useSheetDrag({ + sheetRef, + backdropRef, + enabled, + onDismissByPan, +}: { + sheetRef: RefObject; + backdropRef: RefObject; + enabled: boolean; + onDismissByPan: () => void; +}) { + const onDismissRef = useRef(onDismissByPan); + onDismissRef.current = onDismissByPan; + + useEffect(() => { + const sheet = sheetRef.current; + if (!sheet || !enabled) { + return; + } + let startY = 0; + let lastY = 0; + let lastTime = 0; + let velocity = 0; + let tracking = false; + let dragging = false; + let pointerId = -1; + + const setOffset = (offset: number) => { + sheet.style.transform = `translate3d(0, ${offset}px, 0)`; + const backdrop = backdropRef.current; + if (backdrop && offset > 0) { + backdrop.style.opacity = String( + Math.max(0, 1 - offset / sheet.offsetHeight) + ); + } + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || scrolledAncestorWithin(event.target, sheet)) { + return; + } + tracking = true; + dragging = false; + pointerId = event.pointerId; + startY = event.clientY; + lastY = event.clientY; + lastTime = event.timeStamp; + velocity = 0; + }; + + const onPointerMove = (event: PointerEvent) => { + if (!tracking || event.pointerId !== pointerId) { + return; + } + const dy = event.clientY - startY; + if (!dragging) { + if (dy < DRAG_START_THRESHOLD) { + return; + } + dragging = true; + sheet.setPointerCapture(pointerId); + sheet.style.transition = 'none'; + } + const dt = Math.max(1, event.timeStamp - lastTime); + velocity = (event.clientY - lastY) / dt; + lastY = event.clientY; + lastTime = event.timeStamp; + setOffset(dy > 0 ? dy : dy * UPWARD_RESISTANCE); + }; + + const onPointerUp = (event: PointerEvent) => { + if (!tracking || event.pointerId !== pointerId) { + return; + } + tracking = false; + if (!dragging) { + return; + } + dragging = false; + const dy = event.clientY - startY; + const height = sheet.offsetHeight; + sheet.style.transition = SETTLE_TRANSITION; + if (dy > height * DISMISS_DISTANCE_RATIO || velocity > DISMISS_VELOCITY) { + setOffset(height); + onDismissRef.current(); + } else { + setOffset(0); + if (backdropRef.current) { + backdropRef.current.style.opacity = '1'; + } + } + // Swallow the click that follows a drag so buttons under the finger + // do not fire. + sheet.addEventListener( + 'click', + (clickEvent) => { + clickEvent.stopPropagation(); + clickEvent.preventDefault(); + }, + { capture: true, once: true } + ); + }; + + sheet.addEventListener('pointerdown', onPointerDown); + sheet.addEventListener('pointermove', onPointerMove); + sheet.addEventListener('pointerup', onPointerUp); + sheet.addEventListener('pointercancel', onPointerUp); + return () => { + sheet.removeEventListener('pointerdown', onPointerDown); + sheet.removeEventListener('pointermove', onPointerMove); + sheet.removeEventListener('pointerup', onPointerUp); + sheet.removeEventListener('pointercancel', onPointerUp); + }; + }, [sheetRef, backdropRef, enabled]); +} diff --git a/native-views/react-native-native-overlay/tsconfig.build.json b/native-views/react-native-native-overlay/tsconfig.build.json new file mode 100644 index 000000000..57d6e23e5 --- /dev/null +++ b/native-views/react-native-native-overlay/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/__tests__/**" + ] +} diff --git a/native-views/react-native-native-overlay/tsconfig.json b/native-views/react-native-native-overlay/tsconfig.json new file mode 100644 index 000000000..6544c38fc --- /dev/null +++ b/native-views/react-native-native-overlay/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-native-overlay": [ + "./src/index" + ] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": [ + "react-native-strict-api" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + }, + "include": [ + "src" + ] +} diff --git a/native-views/react-native-native-overlay/turbo.json b/native-views/react-native-native-overlay/turbo.json new file mode 100644 index 000000000..ce55c407a --- /dev/null +++ b/native-views/react-native-native-overlay/turbo.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["lib/**"] + }, + "typecheck": { + "dependsOn": ["^build"], + "outputs": [] + }, + "lint": { + "dependsOn": [], + "outputs": [] + }, + "clean": { + "cache": false + }, + "//#clean": { + "cache": false + } + } +} diff --git a/native-views/react-native-native-sheet/package.json b/native-views/react-native-native-sheet/package.json index 9c4a30b85..bee75b954 100644 --- a/native-views/react-native-native-sheet/package.json +++ b/native-views/react-native-native-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-sheet", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Native bottom sheet host for arbitrary React Native content", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 1c6189222..461539334 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -69,7 +69,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-native-logger": "3.0.156", + "@onekeyfe/react-native-native-logger": "3.0.162-alpha.268", "react": "*", "react-native": "*" }, diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 917d89f2e..1688637c1 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 07f1e0488..4e83ab1e8 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 986a85614..fb06669d1 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 61711d83a..a589a5b64 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index fa36cb2b7..ae49f9a68 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text-input/package.json b/native-views/react-native-text-input/package.json index 7d508994d..18ae87410 100644 --- a/native-views/react-native-text-input/package.json +++ b/native-views/react-native-text-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text-input", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "React Native TextInput with native paste events", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text/package.json b/native-views/react-native-text/package.json index 8f79fe087..e05c40e76 100644 --- a/native-views/react-native-text/package.json +++ b/native-views/react-native-text/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text", - "version": "3.0.156", + "version": "3.0.162-alpha.268", "description": "Opt-in native text rendering for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", diff --git a/scripts/sync-app-monorepo.mjs b/scripts/sync-app-monorepo.mjs index d9ed72a7e..45c7f7946 100644 --- a/scripts/sync-app-monorepo.mjs +++ b/scripts/sync-app-monorepo.mjs @@ -6,6 +6,14 @@ import { loadReleaseWorkspaces } from "./validate-npm-dist-tag.mjs"; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +export const appMonorepoManifests = [ + ["apps/mobile/package.json", ["dependencies"]], + ["packages/components/package.json", ["dependencies"]], + ["packages/kit/package.json", ["dependencies"]], + ["apps/playground/package.json", ["dependencies"]], + ["package.json", ["dependencies", "resolutions"]], +]; + function compareVersions(left, right) { const parse = (version) => { const match = /^(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?$/.exec(version); @@ -45,6 +53,9 @@ export function updateMobileManifest( const byName = new Map( workspaces.map(({ name, version }) => [name, version]) ); + for (const version of versions) { + compareVersions(version, version); + } const updated = []; for (const section of sections) { for (const [name, current] of Object.entries(manifest[section] ?? {})) { @@ -55,7 +66,10 @@ export function updateMobileManifest( if (!target || currentVersion === target) { continue; } - if (compareVersions(currentVersion, target) > 0) { + const migratingOverlay = + workspaceName === "@onekeyfe/react-native-native-overlay" && + currentVersion === "workspace:*"; + if (!migratingOverlay && compareVersions(currentVersion, target) > 0) { throw new Error( `Refusing to downgrade ${name} from ${currentVersion} to ${target}` ); @@ -75,22 +89,8 @@ export function updateMobileManifest( }; } -async function main() { - const [appMonorepoArg, releaseVersion] = process.argv.slice(2); - if (!appMonorepoArg || !releaseVersion) { - throw new Error( - "Usage: node scripts/sync-app-monorepo.mjs " - ); - } - const workspaces = (await loadReleaseWorkspaces(repoRoot)).map( - (workspace) => ({ ...workspace, version: releaseVersion }) - ); - const manifests = [ - ["apps/mobile/package.json", ["dependencies"]], - ["packages/components/package.json", ["dependencies"]], - ["package.json", ["dependencies", "resolutions"]], - ]; - for (const [relativePath, sections] of manifests) { +export async function syncAppMonorepo(appMonorepoArg, workspaces) { + for (const [relativePath, sections] of appMonorepoManifests) { const manifestPath = join(resolve(appMonorepoArg), relativePath); const original = await readFile(manifestPath, "utf8"); const { text, updated } = updateMobileManifest( @@ -105,6 +105,19 @@ async function main() { } } +async function main() { + const [appMonorepoArg, releaseVersion] = process.argv.slice(2); + if (!appMonorepoArg || !releaseVersion) { + throw new Error( + "Usage: node scripts/sync-app-monorepo.mjs " + ); + } + const workspaces = (await loadReleaseWorkspaces(repoRoot)).map( + (workspace) => ({ ...workspace, version: releaseVersion }) + ); + await syncAppMonorepo(appMonorepoArg, workspaces); +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((error) => { console.error(error.message); diff --git a/scripts/sync-app-monorepo.test.mjs b/scripts/sync-app-monorepo.test.mjs index 3f70aa661..a0b67f052 100644 --- a/scripts/sync-app-monorepo.test.mjs +++ b/scripts/sync-app-monorepo.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; -import { updateMobileManifest } from "./sync-app-monorepo.mjs"; +import { syncAppMonorepo, updateMobileManifest } from "./sync-app-monorepo.mjs"; const workspaces = [ { name: "@onekeyfe/react-native-image", version: "3.0.152" }, @@ -148,3 +151,67 @@ test("requires a synchronized app-modules release", () => { /versions must match/ ); }); + +test("migrates the temporary overlay workspace to its published exact version", () => { + const result = updateMobileManifest( + JSON.stringify({ + dependencies: { + "@onekeyfe/react-native-native-overlay": "workspace:*", + "@onekeyhq/components": "workspace:*", + }, + }), + [ + { + name: "@onekeyfe/react-native-native-overlay", + version: "3.0.162-alpha.268", + }, + ] + ); + assert.deepEqual(JSON.parse(result.text).dependencies, { + "@onekeyfe/react-native-native-overlay": "3.0.162-alpha.268", + "@onekeyhq/components": "workspace:*", + }); +}); + +test("does not silently migrate other published development workspaces", () => { + assert.throws( + () => + updateMobileManifest( + JSON.stringify({ + dependencies: { + "@onekeyfe/react-native-image": "workspace:*", + }, + }), + workspaces + ), + /Expected an exact stable or alpha version/ + ); +}); + +test("syncs overlay consumers in mobile, components, kit and playground", async () => { + const root = await mkdtemp(join(tmpdir(), "overlay-sync-")); + const paths = [ + "apps/mobile/package.json", + "packages/components/package.json", + "packages/kit/package.json", + "apps/playground/package.json", + "package.json", + ]; + const name = "@onekeyfe/react-native-native-overlay"; + try { + for (const path of paths) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile( + join(root, path), + JSON.stringify({ dependencies: { [name]: "workspace:*" } }) + ); + } + await syncAppMonorepo(root, [{ name, version: "3.0.162-alpha.268" }]); + for (const path of paths) { + const manifest = JSON.parse(await readFile(join(root, path), "utf8")); + assert.equal(manifest.dependencies[name], "3.0.162-alpha.268"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/yarn.lock b/yarn.lock index 4d31dfa42..c4e823da9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2962,6 +2962,7 @@ __metadata: "@onekeyfe/react-native-lite-card": "workspace:*" "@onekeyfe/react-native-native-list": "workspace:*" "@onekeyfe/react-native-native-logger": "workspace:*" + "@onekeyfe/react-native-native-overlay": "workspace:*" "@onekeyfe/react-native-native-sheet": "workspace:*" "@onekeyfe/react-native-network-info": "workspace:*" "@onekeyfe/react-native-pager-view": "workspace:*" @@ -3590,7 +3591,7 @@ __metadata: react-test-renderer: "npm:19.2.3" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.156 + "@onekeyfe/react-native-skeleton": 3.0.162-alpha.268 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3691,8 +3692,8 @@ __metadata: react-native-nitro-modules: "npm:0.37.0" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-image": 3.0.156 - "@onekeyfe/react-native-native-logger": 3.0.156 + "@onekeyfe/react-native-image": 3.0.162-alpha.268 + "@onekeyfe/react-native-native-logger": 3.0.162-alpha.268 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3735,6 +3736,45 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-native-overlay@workspace:*, @onekeyfe/react-native-native-overlay@workspace:native-views/react-native-native-overlay": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-native-overlay@workspace:native-views/react-native-native-overlay" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.86.2" + "@react-native/eslint-config": "npm:0.86.2" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + "@types/react-dom": "npm:19.2.3" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" + react-native: "npm:0.86.2" + react-native-builder-bob: "npm:^0.40.13" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-dom: "*" + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + languageName: unknown + linkType: soft + "@onekeyfe/react-native-native-sheet@workspace:*, @onekeyfe/react-native-native-sheet@workspace:native-views/react-native-native-sheet": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-native-sheet@workspace:native-views/react-native-native-sheet" @@ -3844,7 +3884,7 @@ __metadata: react-native-builder-bob: "npm:^0.40.13" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-native-logger": 3.0.156 + "@onekeyfe/react-native-native-logger": 3.0.162-alpha.268 react: "*" react-native: "*" languageName: unknown @@ -5358,6 +5398,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "@types/react-dom@npm:19.2.3" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10/616c4a8aee250ea05fb1e7b98e7e00475dd3a6c1c30d7be18b4b93caba832f4203106b3a496a6b147e5acc2da14575eca47bce234c633bca1f8430ef8ffb234a + languageName: node + linkType: hard + "@types/react-test-renderer@npm:^19.1.0": version: 19.1.0 resolution: "@types/react-test-renderer@npm:19.1.0" From 392adf293a864b995df61580120ae76593928671 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 1 Oct 2026 16:13:33 +0800 Subject: [PATCH 2/6] ci: expose native overlay pod lockfile differences --- .github/workflows/native-tests.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 24ce873d7..e276f7bbd 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -229,7 +229,22 @@ jobs: ['git', 'show', 'HEAD:example/react-native/ios/Podfile.lock'], &:read ) installed = File.read('Podfile.lock') - unless normalized_lockfile(committed) == normalized_lockfile(installed) + expected = normalized_lockfile(committed) + actual = normalized_lockfile(installed) + unless expected == actual + (expected.keys | actual.keys).sort.each do |section| + next if expected[section] == actual[section] + warn "Changed lockfile section: #{section}" + if expected[section].is_a?(Hash) && actual[section].is_a?(Hash) + (expected[section].keys | actual[section].keys).sort.each do |name| + next if expected[section][name] == actual[section][name] + warn " #{name}: #{expected[section][name].inspect} -> #{actual[section][name].inspect}" + end + else + warn " expected: #{expected[section].inspect}" + warn " installed: #{actual[section].inspect}" + end + end abort 'CocoaPods changed the locked dependencies or checksums' end puts 'Podfile.lock matches: only the Hermes checksum and Specs CDN URL may differ' @@ -264,5 +279,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ios-native-test-results - path: artifacts/ios/ + path: | + artifacts/ios/ + example/react-native/ios/Podfile.lock if-no-files-found: warn From 41d7cd002d96336bb0942dfde51b3a848ed8d0bd Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 1 Oct 2026 16:20:13 +0800 Subject: [PATCH 3/6] fix: align overlay pod lockfile with pinned cocoapods --- example/react-native/ios/Podfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 8169c0552..48d084b61 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -3500,7 +3500,7 @@ SPEC CHECKSUMS: DnsLookup: e2ffe9f751521e6627a05d9ee54ad1c6f8c556f6 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: 9a9e5e2971450b2ff2a64057438c7f33c3c71d9f + hermes-engine: 5edd079b6e0281f44be1c80892a0e12250e54fef KeychainModule: e0a925c20b5ad33f09faa58745687c29eca880f4 libwebp: af5937a13536ef73f3784d69cc342b98961acf33 MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 @@ -3620,4 +3620,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d163efd24ff4c7dc7da7c6c0f1023634b164196e -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 From a1786d43945d9eb5a05e15259f13177f164ae974 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 1 Oct 2026 16:28:48 +0800 Subject: [PATCH 4/6] chore: recover overlay release with a fresh preview version --- .github/workflows/native-tests.yml | 11 ++ example/react-native/ios/Podfile.lock | 182 +++++++++--------- native-modules/native-logger/package.json | 2 +- .../react-native-aes-crypto/package.json | 2 +- .../react-native-app-update/package.json | 2 +- .../react-native-async-storage/package.json | 2 +- .../package.json | 2 +- .../react-native-bundle-crypto/package.json | 2 +- .../react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- .../react-native-cloud-fs/package.json | 2 +- .../package.json | 2 +- .../react-native-device-utils/package.json | 2 +- .../react-native-dns-lookup/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../react-native-keychain-module/package.json | 2 +- .../react-native-lite-card/package.json | 2 +- .../react-native-network-info/package.json | 2 +- .../package.json | 2 +- .../react-native-pbkdf2/package.json | 2 +- .../react-native-perf-memory/package.json | 2 +- .../react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- .../package.json | 2 +- .../react-native-sni-connect/package.json | 2 +- .../react-native-splash-screen/package.json | 2 +- .../package.json | 2 +- .../react-native-tcp-socket/package.json | 2 +- .../react-native-zip-archive/package.json | 2 +- .../react-native-auto-size-input/package.json | 2 +- .../react-native-chart-webview/package.json | 2 +- native-views/react-native-image/package.json | 4 +- .../react-native-native-list/package.json | 6 +- .../react-native-native-overlay/README.md | 2 +- .../react-native-native-overlay/docs/SPEC.md | 10 +- .../react-native-native-overlay/package.json | 2 +- .../react-native-native-sheet/package.json | 2 +- .../react-native-pager-view/package.json | 4 +- .../react-native-perp-depth-bar/package.json | 2 +- .../react-native-scroll-guard/package.json | 2 +- .../react-native-segment-slider/package.json | 2 +- .../react-native-skeleton/package.json | 2 +- .../react-native-tab-view/package.json | 2 +- .../react-native-text-input/package.json | 2 +- native-views/react-native-text/package.json | 2 +- yarn.lock | 8 +- 47 files changed, 162 insertions(+), 143 deletions(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index e276f7bbd..44fbfac64 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -249,6 +249,17 @@ jobs: end puts 'Podfile.lock matches: only the Hermes checksum and Specs CDN URL may differ' RUBY + - name: Compile native overlay pod on iOS Simulator + shell: bash + run: | + set -euo pipefail + mkdir -p artifacts/ios + xcodebuild build -workspace example/react-native/ios/example.xcworkspace \ + -scheme NativeOverlay -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/native-tests-derived-data" \ + -jobs 2 CODE_SIGNING_ALLOWED=NO IPHONEOS_DEPLOYMENT_TARGET=16.4 \ + 'OTHER_LDFLAGS=$(inherited) -lc++' \ + 2>&1 | tee artifacts/ios/NativeOverlay-build.log - name: Run six pod XCTest suites on iOS Simulator shell: bash run: | diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 48d084b61..fc3d064d1 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.162-alpha.268): + - AesCrypto (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -21,7 +21,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AsyncStorage (3.0.162-alpha.268): + - AsyncStorage (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -43,7 +43,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.162-alpha.268): + - AutoSizeInput (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -67,7 +67,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.162-alpha.268): + - BackgroundThread (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -90,7 +90,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ChartWebview (3.0.162-alpha.268): + - ChartWebview (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -115,7 +115,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.162-alpha.268): + - CloudFs (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -137,7 +137,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.162-alpha.268): + - CloudKitModule (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -165,7 +165,7 @@ PODS: - CocoaLumberjack/Core (3.9.1) - CocoaLumberjack/Swift (3.9.1): - CocoaLumberjack/Core - - DnsLookup (3.0.162-alpha.268): + - DnsLookup (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -194,7 +194,7 @@ PODS: - hermes-engine (250829098.0.16): - hermes-engine/Pre-built (= 250829098.0.16) - hermes-engine/Pre-built (250829098.0.16) - - KeychainModule (3.0.162-alpha.268): + - KeychainModule (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -256,7 +256,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NativeSheet (3.0.162-alpha.268): + - NativeSheet (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -278,7 +278,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NativeSheet/Tests (3.0.162-alpha.268): + - NativeSheet/Tests (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -300,7 +300,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NetworkInfo (3.0.162-alpha.268): + - NetworkInfo (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -370,7 +370,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.162-alpha.268): + - OneKeyImage (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -396,9 +396,9 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.162-alpha.268) + - Skeleton (= 3.0.162-alpha.270) - Yoga - - OneKeyImage/Tests (3.0.162-alpha.268): + - OneKeyImage/Tests (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -424,13 +424,13 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.162-alpha.268) + - Skeleton (= 3.0.162-alpha.270) - Yoga - - OneKeyTextInput (3.0.162-alpha.268): + - OneKeyTextInput (3.0.162-alpha.270): - React-Core - - OneKeyTextInput/Tests (3.0.162-alpha.268): + - OneKeyTextInput/Tests (3.0.162-alpha.270): - React-Core - - Pbkdf2 (3.0.162-alpha.268): + - Pbkdf2 (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -452,7 +452,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.162-alpha.268): + - PerpDepthBar (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -476,7 +476,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.162-alpha.268): + - Ping (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1921,10 +1921,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list (3.0.162-alpha.268): + - react-native-native-list (3.0.162-alpha.270): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.162-alpha.268) + - OneKeyImage (= 3.0.162-alpha.270) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -1946,10 +1946,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list/Tests (3.0.162-alpha.268): + - react-native-native-list/Tests (3.0.162-alpha.270): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.162-alpha.268) + - OneKeyImage (= 3.0.162-alpha.270) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -1971,7 +1971,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.162-alpha.268): + - react-native-pager-view (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1993,7 +1993,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view/Tests (3.0.162-alpha.268): + - react-native-pager-view/Tests (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2084,7 +2084,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.162-alpha.268): + - react-native-tab-view (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2096,7 +2096,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.162-alpha.268) + - react-native-tab-view/common (= 3.0.162-alpha.270) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2107,7 +2107,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.162-alpha.268): + - react-native-tab-view/common (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2525,7 +2525,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.162-alpha.268): + - ReactNativeAppUpdate (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2550,7 +2550,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.162-alpha.268): + - ReactNativeBundleCrypto (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2575,7 +2575,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto/Tests (3.0.162-alpha.268): + - ReactNativeBundleCrypto/Tests (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2600,7 +2600,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.162-alpha.268): + - ReactNativeBundleUpdate (3.0.162-alpha.270): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2629,7 +2629,7 @@ PODS: - ReactNativeRangeDownloader - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.162-alpha.268): + - ReactNativeCheckBiometricAuthChanged (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2655,7 +2655,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.162-alpha.268): + - ReactNativeDeviceUtils (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2680,7 +2680,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeGetRandomValues (3.0.162-alpha.268): + - ReactNativeGetRandomValues (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2705,7 +2705,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeImageCropPicker (3.0.162-alpha.268): + - ReactNativeImageCropPicker (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2730,7 +2730,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.162-alpha.268): + - ReactNativeLiteCard (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2753,7 +2753,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.162-alpha.268): + - ReactNativeNativeLogger (3.0.162-alpha.270): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -2778,7 +2778,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativePerfMemory (3.0.162-alpha.268): + - ReactNativePerfMemory (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2803,7 +2803,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.162-alpha.268): + - ReactNativePerfStats (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2828,7 +2828,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.162-alpha.268): + - ReactNativeRangeDownloader (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2854,7 +2854,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.162-alpha.268): + - ReactNativeSplashScreen (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2879,7 +2879,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeZipArchive (3.0.162-alpha.268): + - ReactNativeZipArchive (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2950,7 +2950,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.162-alpha.268): + - ScrollGuard (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -2982,7 +2982,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.162-alpha.268): + - SegmentSlider (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -3006,7 +3006,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Skeleton (3.0.162-alpha.268): + - Skeleton (3.0.162-alpha.270): - hermes-engine - NitroModules - RCTRequired @@ -3030,7 +3030,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.162-alpha.268): + - SniConnect (3.0.162-alpha.270): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -3054,7 +3054,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SplitBundleLoader (3.0.162-alpha.268): + - SplitBundleLoader (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3078,7 +3078,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.162-alpha.268): + - TcpSocket (3.0.162-alpha.270): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3489,32 +3489,32 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 4f4586887134bc6c31bc9bd9a028053fa3fc88b9 - AsyncStorage: cd22db0ec9f5e5dfbdb1d3f9d40783a84c624719 - AutoSizeInput: 7fa6dd744560e8c3bb28c66f81b4c027d7d1c991 - BackgroundThread: 6315864406c78f89b9c31861624d52328a2a0911 - ChartWebview: 354c4a521104dbb790ee74e0d1fb6ca91bddf12e - CloudFs: 4d8fdefe1f62c82b00b89c1f696f5d5d734b2dc8 - CloudKitModule: c306c4d74171761f1f40fb0add47b29e19ad19df + AesCrypto: 845f5c3adaabfaba154c5abf203c92a78af31bf7 + AsyncStorage: 9009944011214a9aa533582091a5940064a6009c + AutoSizeInput: e41a395d1561efe45b4eb81cc93f4266a901f29f + BackgroundThread: 78f0534064176a560063ffbfccfd9bc42bf2225b + ChartWebview: c42ad70296362fdba765d512de21360fb907d0da + CloudFs: 06d0a58d432ba57093dfed2140c8ed5a95e0566a + CloudKitModule: a3c3ff6555b003103b6bd187827526c8ea00f2fd CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6 - DnsLookup: e2ffe9f751521e6627a05d9ee54ad1c6f8c556f6 + DnsLookup: 15c0f70b7465d753c75c9b1248358d77978b0efe EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: 5edd079b6e0281f44be1c80892a0e12250e54fef - KeychainModule: e0a925c20b5ad33f09faa58745687c29eca880f4 + hermes-engine: 9a9e5e2971450b2ff2a64057438c7f33c3c71d9f + KeychainModule: bb4c5f45814934f52f6c650dbe6cd60217859914 libwebp: af5937a13536ef73f3784d69cc342b98961acf33 MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa NativeOverlay: 305b5b498d8126310829423de213c909e8e77051 - NativeSheet: 436bd0c5085ddb46d4375bdf0e213f57ccfd7cfe - NetworkInfo: 5b8949bc070d48b084537fc146e10b8d1f40722c + NativeSheet: 9aa01af46eeb86b5c64359bf376a3dff45eb2b3d + NetworkInfo: 9af8e6b5a609e4883715951a9abc4b4e931e0be0 NitroMmkv: 85a251e15c39d7c95fe58cf40786fa126b8d143c NitroModules: cda827b31eb05c1bfcc0e2bbd9b647a69c6c314f - OneKeyImage: 7f2dab3b6cf93a070d66593b07ad3496a404a5eb - OneKeyTextInput: f2d8b807789688f71cce622196635cb8e565f1f0 - Pbkdf2: dc477b4ad8ed12928f863714d0eefa6685f7d57b - PerpDepthBar: 4fedba5d815bb718d8dfe5d3529c3b0dd8f61a5d - Ping: d8358ecab86a108971523447ee94abb7f8d827d9 + OneKeyImage: 6ac4e13b04241ba20046135c78d9d4517b2b9cd5 + OneKeyTextInput: c6987830269e5438f6183a56c1d186e7a1e66b0c + Pbkdf2: 1dbebaeca839a45b4f90b0dd972307b85c99def3 + PerpDepthBar: 0ea4f006358c8751bfa9100269bf80bb2b731db3 + Ping: c56582c4b6931adf4729dafac7c15a10b16bf6fe RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c @@ -3552,10 +3552,10 @@ SPEC CHECKSUMS: React-Mapbuffer: 44dd007f917e2396afa0e70bbd70273237d93fde React-microtasksnativemodule: f413ee411341fb3c34d20e85e9f6f524921fab15 React-mutationobservernativemodule: 58f6d2adf7d98e72b241608dfa2ddce414d2a4e7 - react-native-native-list: cd97dca2ee3128a89b8adbfc1c756a1453022b90 - react-native-pager-view: d8dfa199e49707927668c802025c28bc150cdd8c + react-native-native-list: 19fff69d43df09f2b3167c902845ec5e994cd0e5 + react-native-pager-view: 5a72dea96d9af38d72647f06537ca36198b592a5 react-native-safe-area-context: 91a90d98c310adcc90a511e5aeb6046d7c19d885 - react-native-tab-view: e20f34d20ccf39e109b9e6dff3a283dcf7532424 + react-native-tab-view: 5f90c580c6cda5e799265f6e3aa35911fdb268f6 React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 React-networking: 89f6b69755a3176f30e56ba1ba8e214b1518ecfb React-oscompat: db6675ddaef3bddd1b41533627f3d26ec710c05f @@ -3590,32 +3590,32 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 01cbf7a08c30e8b71ad71489bb56b5bed195fa5e ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 - ReactNativeAppUpdate: 5cc672abd43959b95bbb57df21fe95ebdb7bc440 - ReactNativeBundleCrypto: 7fc3f568fcbaa1e78dc6cedc1b848c2e9dbabe6f - ReactNativeBundleUpdate: e77018a3b75cd2343502258d3ea7ebc8e38e5db1 - ReactNativeCheckBiometricAuthChanged: d403480d82350083bb2b99c1b0f932c0e3b0ebad + ReactNativeAppUpdate: bd637bf00790808dd5b3ef6ca8ea27e1b5b01898 + ReactNativeBundleCrypto: 0880266e043f786b439da83df3df198a0feff788 + ReactNativeBundleUpdate: b368da3e49051a54980fb5c5b69dbe02bf33fb42 + ReactNativeCheckBiometricAuthChanged: 2cb77ef6c3072bd2c2e8d9f36c3ed01a787c85ca ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 9f7a8bc3d3137bece30f291ab97255f0b34fa5d7 - ReactNativeGetRandomValues: caec968297a7b6cf06c589dd3c4e55514b6ead3d - ReactNativeImageCropPicker: a112fdeae5f15583901e2299c7b69ee15719c3f8 - ReactNativeLiteCard: 59725bd7f02666dd2aed476b0e820ce2e3db7d7b - ReactNativeNativeLogger: 5231d4b8461297579f210df2981b67ae8c7aef0a - ReactNativePerfMemory: bc7f3ed64ca31ed77b919291e8ef97a8f9261ef8 - ReactNativePerfStats: 35fbabfc65e8f3bcc03f59f443c5d4db54aedc54 - ReactNativeRangeDownloader: 0539c3d3a52db8354d4a316fb8cfab94a317de6c - ReactNativeSplashScreen: b26ba6a5264471466d6f043a45e24cf4ca8e4d90 - ReactNativeZipArchive: f196744e993c80da0c44959c768615dce5fa7f2f + ReactNativeDeviceUtils: c4b8306da8bf43db1929708f316435b71f248b88 + ReactNativeGetRandomValues: fc5513fd2c442ff55b9fe71c7795a0f17b485541 + ReactNativeImageCropPicker: 9cc600a133aa4582076e2e564559adb7105bde88 + ReactNativeLiteCard: 33890b48047b015c6fbdb5e75d7637ed23dae455 + ReactNativeNativeLogger: 6b8b70a9d4621a69f1a649186b0a71d06b76feaf + ReactNativePerfMemory: cc92d64de80e357fa189e928d2a81e4a39dc6631 + ReactNativePerfStats: 1413b1c4cbf28f5eba83393db50dec95ca0daad3 + ReactNativeRangeDownloader: 58d7a35a355bb0e515f99f118407dc3964aa6a08 + ReactNativeSplashScreen: d7277717a7ddebaa048617986172d68534d63752 + ReactNativeZipArchive: 59766c35bc29c85964fea1bc52d4f9f2b91c8119 RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41 - ScrollGuard: 0bd6ead25884e76f24a500e7a328919a8363e4ca + ScrollGuard: 2301db77f008622280042034045098595d4a55f8 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 4f14afebf028e3994d100c5dc288f2529717938e - Skeleton: 32d4e2603c1675ebc4731fd3b31abc4dd6bb13fd - SniConnect: f52f780df1b5cdd502b93c4ed070e82c6bff73c4 - SplitBundleLoader: c177f56e9b48fa25deeb2e0b5bc569dc71deaa43 + SegmentSlider: 1611d3a8eebc2b982cc81cebc82ec21b1352f2ef + Skeleton: e87370f3b2fdbbebdc5ea0500325fdc7d996a738 + SniConnect: a51d3acf59bbd29b55ec3004e877c07f36a2ba2d + SplitBundleLoader: 7a630663d9d52e34194c2245d4ec726059ad0671 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: c4f9950854242c86c1af69c32ca906e13d900d95 + TcpSocket: c9e7f2996b9f95b0afdd5189c4b52c9cd423f50f Yoga: 9891cfb1c680f64de9c41b09e7453dea33454d2b PODFILE CHECKSUM: d163efd24ff4c7dc7da7c6c0f1023634b164196e diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index f2ab71286..048ea37bc 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 81cf14530..edbc657a7 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index d570c9697..b066a729f 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index e87ec7131..6b4f43375 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index ae328d987..790f0bce1 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 0f5df76bf..7505e5e38 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index ee1bc9eec..91b321ca5 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 62090783d..0cbdb85df 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 4a21ac6ae..2e6736409 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 6337c0f80..4f189d5ec 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 4823fc86c..c28551458 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index e1212d3c4..de0da4611 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index aa32f5b56..d1811363b 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json index cb14c3a42..6dda9eeac 100644 --- a/native-modules/react-native-image-crop-picker/package.json +++ b/native-modules/react-native-image-crop-picker/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image-crop-picker", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index cefcce472..7032060c5 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index 0aba6fee4..b23b5197a 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index e2fcd2cd1..1d5a290ac 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 263587eb3..2134647a1 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index be2a89b5f..f704a6cea 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 6b9e556df..ac19d90b2 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index f3c27ead6..ef17bff6f 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 7ea1240db..4aa343679 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 45477f290..cbfda3c10 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 60bbf9cb6..07b778163 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 68735951f..ec6073365 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index edb6f2fa1..db0427fb4 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 59aafa32a..515a1968b 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 322c7f405..2dd12754a 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index e8a8c372c..73446c97f 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 257272fbf..13a285228 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-image/package.json b/native-views/react-native-image/package.json index 51f6dba1b..13782ffa2 100644 --- a/native-views/react-native-image/package.json +++ b/native-views/react-native-image/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "High-performance native image view for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -81,7 +81,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-skeleton": "3.0.162-alpha.268", + "@onekeyfe/react-native-skeleton": "3.0.162-alpha.270", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index 29c5d0630..a4f46212b 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-list", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Template-driven native RecyclerView and UICollectionView for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", @@ -84,8 +84,8 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-image": "3.0.162-alpha.268", - "@onekeyfe/react-native-native-logger": "3.0.162-alpha.268", + "@onekeyfe/react-native-image": "3.0.162-alpha.270", + "@onekeyfe/react-native-native-logger": "3.0.162-alpha.270", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-overlay/README.md b/native-views/react-native-native-overlay/README.md index 5c63ff677..d72a237bb 100644 --- a/native-views/react-native-native-overlay/README.md +++ b/native-views/react-native-native-overlay/README.md @@ -27,7 +27,7 @@ Fabric rather than Nitro under the explicit exception recorded in the SPEC. ## Release Use the repository `package-publish` CI workflow. This branch's synchronized -42-package batch is `3.0.162-alpha.268`, published under `next`; no PR merge or +42-package batch is `3.0.162-alpha.270`, published under `next`; no PR merge or stable-tag change is required. Consumers must pin the exact verified version. ## License diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index fca20825a..f65f82706 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -35,7 +35,7 @@ retains its original implementation attribution in source comments. ### Release boundary -This branch aligns all 42 publishable workspaces to `3.0.162-alpha.268`. +This branch aligns all 42 publishable workspaces to `3.0.162-alpha.270`. Publish with the existing `package-publish` workflow dispatch, `npm_dist_tag=next` and an empty `only_workspace`, which publishes and registry-verifies all 42. Do not merge either repository PR or change the `latest` tag. The app-monorepo @@ -691,3 +691,11 @@ Known gaps: the handoff. iOS runtime, physical devices, iPad, screen-reader cross-window modality and real password-lock flows remain outstanding; CI adds packaging checks and builds this pod through the example application. + + +Release recovery: the first batch `3.0.162-alpha.268` left lite-card staged +but unavailable. A single-package recovery returned npm E409 (previously +staged version). Following the repository's verification guidance, the entire +batch advances to `3.0.162-alpha.270`. Existing versions are never overwritten. +The iOS lockfile failure was only local CocoaPods 1.17.0 versus the Gemfile's +locked 1.16.2; the actual CI lockfile was adopted, preserving semantic checks. diff --git a/native-views/react-native-native-overlay/package.json b/native-views/react-native-native-overlay/package.json index 26e6d77e1..dda496600 100644 --- a/native-views/react-native-native-overlay/package.json +++ b/native-views/react-native-native-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-overlay", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Native and Web overlay hosts with ordered levels, page scope and Fabric measurement", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-native-sheet/package.json b/native-views/react-native-native-sheet/package.json index bee75b954..c6480e9cf 100644 --- a/native-views/react-native-native-sheet/package.json +++ b/native-views/react-native-native-sheet/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-sheet", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Native bottom sheet host for arbitrary React Native content", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 461539334..f541a3799 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -69,7 +69,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-native-logger": "3.0.162-alpha.268", + "@onekeyfe/react-native-native-logger": "3.0.162-alpha.270", "react": "*", "react-native": "*" }, diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 1688637c1..134044c71 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 4e83ab1e8..1cd49e4a0 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index fb06669d1..c8713ab0f 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index a589a5b64..d3eeac22d 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index ae49f9a68..14bbfdb34 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text-input/package.json b/native-views/react-native-text-input/package.json index 18ae87410..cb01725c8 100644 --- a/native-views/react-native-text-input/package.json +++ b/native-views/react-native-text-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text-input", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "React Native TextInput with native paste events", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-text/package.json b/native-views/react-native-text/package.json index e05c40e76..e5b8b3fb6 100644 --- a/native-views/react-native-text/package.json +++ b/native-views/react-native-text/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-text", - "version": "3.0.162-alpha.268", + "version": "3.0.162-alpha.270", "description": "Opt-in native text rendering for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", diff --git a/yarn.lock b/yarn.lock index c4e823da9..2dc52e8ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3591,7 +3591,7 @@ __metadata: react-test-renderer: "npm:19.2.3" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.162-alpha.268 + "@onekeyfe/react-native-skeleton": 3.0.162-alpha.270 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3692,8 +3692,8 @@ __metadata: react-native-nitro-modules: "npm:0.37.0" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-image": 3.0.162-alpha.268 - "@onekeyfe/react-native-native-logger": 3.0.162-alpha.268 + "@onekeyfe/react-native-image": 3.0.162-alpha.270 + "@onekeyfe/react-native-native-logger": 3.0.162-alpha.270 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 @@ -3884,7 +3884,7 @@ __metadata: react-native-builder-bob: "npm:^0.40.13" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-native-logger": 3.0.162-alpha.268 + "@onekeyfe/react-native-native-logger": 3.0.162-alpha.270 react: "*" react-native: "*" languageName: unknown From 59b3f142d6e4b9d25cfdd359574c2947700dd559 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 1 Oct 2026 18:32:00 +0800 Subject: [PATCH 5/6] fix: isolate suspended and blocking page overlays --- .github/workflows/native-tests.yml | 4 +- native-modules/native-logger/package.json | 2 +- .../react-native-aes-crypto/package.json | 2 +- .../react-native-app-update/package.json | 2 +- .../react-native-async-storage/package.json | 2 +- .../package.json | 2 +- .../react-native-bundle-crypto/package.json | 2 +- .../react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- .../react-native-cloud-fs/package.json | 2 +- .../package.json | 2 +- .../react-native-device-utils/package.json | 2 +- .../react-native-dns-lookup/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../react-native-keychain-module/package.json | 2 +- .../react-native-lite-card/package.json | 2 +- .../react-native-network-info/package.json | 2 +- .../package.json | 2 +- .../react-native-pbkdf2/package.json | 2 +- .../react-native-perf-memory/package.json | 2 +- .../react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- .../package.json | 2 +- .../react-native-sni-connect/package.json | 2 +- .../react-native-splash-screen/package.json | 2 +- .../package.json | 2 +- .../react-native-tcp-socket/package.json | 2 +- .../react-native-zip-archive/package.json | 2 +- .../react-native-auto-size-input/package.json | 2 +- .../react-native-chart-webview/package.json | 2 +- native-views/react-native-image/package.json | 4 +- .../react-native-native-list/package.json | 6 +- .../android/build.gradle | 6 + .../NativeOverlayAccessibilityIsolation.kt | 37 ++ .../onekey/nativeoverlay/NativeOverlayHost.kt | 34 +- .../NativeOverlayPageHostView.kt | 21 + .../onekey/nativeoverlay/NativeOverlayView.kt | 9 +- ...NativeOverlayAccessibilityIsolationTest.kt | 112 +++++ .../react-native-native-overlay/docs/SPEC.md | 29 +- .../react-native-native-overlay/package.json | 3 +- .../src/OverlayPageHost.web.tsx | 33 +- .../src/OverlayView.web.tsx | 4 + .../src/__tests__/pageIsolation.web.test.ts | 151 +++++++ .../src/web/overlayLayers.ts | 38 +- .../react-native-native-sheet/package.json | 2 +- .../react-native-pager-view/package.json | 4 +- .../react-native-perp-depth-bar/package.json | 2 +- .../react-native-scroll-guard/package.json | 2 +- .../react-native-segment-slider/package.json | 2 +- .../react-native-skeleton/package.json | 2 +- .../react-native-tab-view/package.json | 2 +- .../react-native-text-input/package.json | 2 +- native-views/react-native-text/package.json | 2 +- yarn.lock | 407 +++++++++++++++++- 55 files changed, 872 insertions(+), 106 deletions(-) create mode 100644 native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt create mode 100644 native-views/react-native-native-overlay/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt create mode 100644 native-views/react-native-native-overlay/src/__tests__/pageIsolation.web.test.ts diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 44fbfac64..ddb1f77a4 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -58,7 +58,7 @@ jobs: # shellcheck disable=SC2016 find native-modules native-views -mindepth 2 -maxdepth 2 -name nitro.json -print0 | xargs -0 -n1 -P4 bash -c 'cd "$(dirname "$0")" && yarn nitrogen > /dev/null' - - name: Run nine Android unit test suites + - name: Run ten Android unit test suites working-directory: example/react-native/android run: | ./gradlew --continue --no-daemon --max-workers=2 --console=plain \ @@ -70,6 +70,7 @@ jobs: :onekeyfe_react-native-auto-size-input:testDebugUnitTest \ :onekeyfe_react-native-image:testDebugUnitTest \ :onekeyfe_react-native-native-list:testDebugUnitTest \ + :onekeyfe_react-native-native-overlay:testDebugUnitTest \ :onekeyfe_react-native-text-input:testDebugUnitTest - name: Verify every Android suite executed tests if: always() @@ -87,6 +88,7 @@ jobs: 'native-views/react-native-auto-size-input', 'native-views/react-native-image', 'native-views/react-native-native-list', + 'native-views/react-native-native-overlay', 'native-views/react-native-text-input', } total = 0 diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 048ea37bc..61729aec3 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index edbc657a7..013c653fb 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index b066a729f..13fd085ac 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 6b4f43375..aa513b35f 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 790f0bce1..f8d6cb86f 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 7505e5e38..f31679caa 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 91b321ca5..749981ac2 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 0cbdb85df..07d4416e2 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 2e6736409..17b416714 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 4f189d5ec..191afaf36 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index c28551458..79fb9f5a8 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index de0da4611..137402297 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index d1811363b..7438540cf 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-image-crop-picker/package.json b/native-modules/react-native-image-crop-picker/package.json index 6dda9eeac..c7a00e440 100644 --- a/native-modules/react-native-image-crop-picker/package.json +++ b/native-modules/react-native-image-crop-picker/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image-crop-picker", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "Single photo picker and cropper Nitro module for OneKey, replacing react-native-image-crop-picker", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 7032060c5..3a28de07c 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index b23b5197a..50be50e96 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 1d5a290ac..5d47fb11a 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 2134647a1..1fa66c95c 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index f704a6cea..481ce3987 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index ac19d90b2..4a28d7667 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index ef17bff6f..279b5b1a9 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 4aa343679..7ddfb14a6 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index cbfda3c10..069e9a18c 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 07b778163..d612f0ac3 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index ec6073365..1e0a99bc7 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index db0427fb4..98800c868 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 515a1968b..2b1b90b68 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 2dd12754a..3ff4ee0ea 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 73446c97f..f34d1555a 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 13a285228..32a9a6fec 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-image/package.json b/native-views/react-native-image/package.json index 13782ffa2..774e7bea3 100644 --- a/native-views/react-native-image/package.json +++ b/native-views/react-native-image/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-image", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "High-performance native image view for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -81,7 +81,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-skeleton": "3.0.162-alpha.270", + "@onekeyfe/react-native-skeleton": "3.0.162-alpha.272", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index a4f46212b..085d97295 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-list", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "Template-driven native RecyclerView and UICollectionView for React Native", "source": "./src/index.ts", "main": "./lib/module/index.js", @@ -84,8 +84,8 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-image": "3.0.162-alpha.270", - "@onekeyfe/react-native-native-logger": "3.0.162-alpha.270", + "@onekeyfe/react-native-image": "3.0.162-alpha.272", + "@onekeyfe/react-native-native-logger": "3.0.162-alpha.272", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.37.0" diff --git a/native-views/react-native-native-overlay/android/build.gradle b/native-views/react-native-native-overlay/android/build.gradle index 50042ca24..83b562d38 100644 --- a/native-views/react-native-native-overlay/android/build.gradle +++ b/native-views/react-native-native-overlay/android/build.gradle @@ -60,6 +60,10 @@ android { ] } } + + testOptions { + unitTests.includeAndroidResources = true + } } repositories { @@ -73,6 +77,8 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.google.android.material:material:1.14.0-alpha05" + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.17" } react { diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt new file mode 100644 index 000000000..08bf4b03e --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt @@ -0,0 +1,37 @@ +package com.onekey.nativeoverlay + +import android.view.View +import android.view.ViewGroup + +/** Restores the caller's flags when a blocking overlay no longer covers it. */ +internal class NativeOverlayAccessibilityIsolation { + private val hidden = mutableMapOf() + + fun update( + root: ViewGroup, + activeContainer: View?, + entriesBelow: List, + globalHost: View, + ) { + val below = mutableSetOf() + if (activeContainer != null) { + var node: View = activeContainer + while (node !== root && node.parent is ViewGroup) { + val parent = node.parent as ViewGroup + for (index in 0 until parent.childCount) { + val sibling = parent.getChildAt(index) + if (sibling !== node && sibling !== globalHost) below.add(sibling) + } + node = parent + } + if (node === root) below.addAll(entriesBelow) else below.clear() + } + hidden.keys.filter { it !in below }.forEach { view -> + view.importantForAccessibility = hidden.remove(view) ?: View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + } + below.forEach { view -> + if (view !in hidden) hidden[view] = view.importantForAccessibility + view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + } +} diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt index e9f4eb53f..0ce530043 100644 --- a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt @@ -2,7 +2,6 @@ package com.onekey.nativeoverlay import android.app.Activity import android.view.KeyEvent -import android.view.View import android.view.ViewGroup import android.view.Window import android.widget.FrameLayout @@ -54,7 +53,7 @@ internal class NativeOverlayHost private constructor( } } } - private val hiddenForAccessibility = mutableMapOf() + private val accessibilityIsolation = NativeOverlayAccessibilityIsolation() init { clipChildren = false @@ -136,33 +135,28 @@ internal class NativeOverlayHost private constructor( /** Global overlays win; otherwise the topmost visible page overlay. */ private fun topBlockingEntry(): NativeOverlayEntryRootView? = orderedEntries().lastOrNull { it.blocking && it.isShownForInput } - ?: NativeOverlayPageHostView.allHosts() - .filter { it.isAttachedToWindow && it.isShown } + ?: shownPageHosts() .flatMap { it.shownEntries() } .lastOrNull { it.blocking } + private fun shownPageHosts(): List = + NativeOverlayPageHostView.allHosts().filter { + it.isAttachedToWindow && it.isShown && it.rootView === activity.window.decorView + } + /** TalkBack only reads the topmost blocking overlay and what is above it. */ private fun updateAccessibility() { val entries = orderedEntries() val topIndex = entries.indexOfLast { it.blocking && it.isShownForInput } - val content = parent as? ViewGroup - val below = mutableListOf() + val content = activity.findViewById(android.R.id.content) ?: return if (topIndex >= 0) { - content?.let { group -> - (0 until group.childCount).map(group::getChildAt).filter { it !== this }.forEach(below::add) - } - below.addAll(entries.subList(0, topIndex)) - } - val restore = hiddenForAccessibility.keys.filter { it !in below } - restore.forEach { view -> - view.importantForAccessibility = hiddenForAccessibility.remove(view) ?: IMPORTANT_FOR_ACCESSIBILITY_AUTO - } - below.forEach { view -> - if (view !in hiddenForAccessibility) { - hiddenForAccessibility[view] = view.importantForAccessibility - view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - } + accessibilityIsolation.update(content, this, entries.take(topIndex), this) + return } + val pageEntry = shownPageHosts().flatMap { it.shownEntries() }.lastOrNull { it.blocking } + val pageHost = pageEntry?.parent as? NativeOverlayPageHostView + val below = pageHost?.shownEntries()?.takeWhile { it !== pageEntry }.orEmpty() + accessibilityIsolation.update(content, pageHost, below, this) } companion object { diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt index 123c8cb88..502e03b90 100644 --- a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt @@ -3,6 +3,7 @@ package com.onekey.nativeoverlay import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup +import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactPointerEventsView import java.lang.ref.WeakReference @@ -33,6 +34,7 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi if (owners == suspendedOwners) return suspendedOwners = owners entries().forEach(::applySuspension) + onEntriesChanged() } internal fun attach(entry: NativeOverlayEntryRootView) { @@ -44,10 +46,12 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi } layoutEntries() applySuspension(entry) + onEntriesChanged() } internal fun detach(entry: NativeOverlayEntryRootView) { removeView(entry) + onEntriesChanged() } /** Visible, input-ready entries in bottom-to-top order. */ @@ -56,6 +60,23 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi fun invalidateHost() { if (hostKey.isNotEmpty() && registry[hostKey]?.get() === this) registry.remove(hostKey) + onEntriesChanged() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + onEntriesChanged() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + onEntriesChanged() + } + + private fun onEntriesChanged() { + (context as? ReactContext)?.currentActivity?.let { + NativeOverlayHost.of(it).onEntriesChanged() + } } private var layoutScheduled = false diff --git a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt index 326219300..db6855212 100644 --- a/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt +++ b/native-views/react-native-native-overlay/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt @@ -135,6 +135,7 @@ class NativeOverlayView( config = OverlayAnimationConfig.parse(animationConfig) } entry?.let(::applyEntryConfiguration) + notifyHostEntriesChanged() if (visible) { cycleOpen = true if (phase == Phase.HIDDEN || phase == Phase.EXITING) present() @@ -200,7 +201,7 @@ class NativeOverlayView( cancelAnimators() phase = Phase.ENTERING target.isShownForInput = true - host?.onEntriesChanged() + notifyHostEntriesChanged() if (blocking) { target.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) } @@ -226,7 +227,7 @@ class NativeOverlayView( cancelAnimators() phase = Phase.EXITING target.isShownForInput = false - host?.onEntriesChanged() + notifyHostEntriesChanged() val transition = config.exit.reduceMotionAdjusted(isReduceMotionEnabled()) if (transition.kind == "none") return finishDismiss("programmatic") animate(target, hiddenState(target, transition), 0f, transition.motion) { @@ -389,6 +390,10 @@ class NativeOverlayView( } } + private fun notifyHostEntriesChanged() { + reactContext.currentActivity?.let { NativeOverlayHost.of(it).onEntriesChanged() } + } + /** * While hidden, keep the React child inside this invisible view. Fabric only * reports onLayout for attached views, and fitted sheets size themselves diff --git a/native-views/react-native-native-overlay/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt b/native-views/react-native-native-overlay/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt new file mode 100644 index 000000000..0c7c0a6a1 --- /dev/null +++ b/native-views/react-native-native-overlay/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt @@ -0,0 +1,112 @@ +package com.onekey.nativeoverlay + +import android.view.View +import android.widget.FrameLayout +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NativeOverlayAccessibilityIsolationTest { + private val context get() = RuntimeEnvironment.getApplication() + + private class Tree(context: android.content.Context) { + val root = FrameLayout(context) + val reactRoot = FrameLayout(context) + val card = FrameLayout(context) + val content = View(context).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES } + val chrome = View(context) + val pageHost = FrameLayout(context) + val lower = View(context) + val blocker = View(context) + val above = View(context) + val globalHost = FrameLayout(context) + + init { + root.addView(reactRoot) + root.addView(globalHost) + reactRoot.addView(card) + reactRoot.addView(chrome) + card.addView(content) + card.addView(pageHost) + pageHost.addView(lower) + pageHost.addView(blocker) + pageHost.addView(above) + } + } + + @Test + fun pageBlockerHidesPageAndNavigationWithoutHidingTheHost() { + val tree = Tree(context) + val isolation = NativeOverlayAccessibilityIsolation() + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.chrome.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.pageHost.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility) + } + + @Test + fun entriesAbovePageBlockerAndGlobalHostStayAccessible() { + val tree = Tree(context) + NativeOverlayAccessibilityIsolation().update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.above.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.globalHost.importantForAccessibility) + } + + @Test + fun dismissalOrSuspensionRestoresOriginalFlags() { + val tree = Tree(context) + tree.lower.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + val isolation = NativeOverlayAccessibilityIsolation() + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + isolation.update(tree.root, null, emptyList(), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, tree.lower.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility) + } + + @Test + fun globalBlockerHidesReactRootThenPageIsolationReturns() { + val tree = Tree(context) + val globalLower = View(context) + tree.globalHost.addView(globalLower) + val isolation = NativeOverlayAccessibilityIsolation() + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + isolation.update(tree.root, tree.globalHost, listOf(globalLower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.reactRoot.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, globalLower.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility) + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.reactRoot.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, globalLower.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility) + } + + @Test + fun detachedHostRestoresIsolationInsteadOfHidingAnotherTree() { + val tree = Tree(context) + val isolation = NativeOverlayAccessibilityIsolation() + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + tree.card.removeView(tree.pageHost) + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.lower.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility) + } + + @Test + fun changingTheBlockingEntryRestoresEntriesNoLongerBelowIt() { + val tree = Tree(context) + val isolation = NativeOverlayAccessibilityIsolation() + isolation.update(tree.root, tree.pageHost, listOf(tree.lower, tree.blocker), tree.globalHost) + isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility) + assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility) + } +} diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index f65f82706..51fb0a6b3 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -35,7 +35,7 @@ retains its original implementation attribution in source comments. ### Release boundary -This branch aligns all 42 publishable workspaces to `3.0.162-alpha.270`. +This branch aligns all 42 publishable workspaces to `3.0.162-alpha.272`. Publish with the existing `package-publish` workflow dispatch, `npm_dist_tag=next` and an empty `only_workspace`, which publishes and registry-verifies all 42. Do not merge either repository PR or change the `latest` tag. The app-monorepo @@ -273,10 +273,37 @@ Known divergences: such as the DatePicker month-row interpolation, are clipped inside the content. +### Page interaction and accessibility isolation (review correction) + +The existing ordering and suspension contract also applies when a page entry +mounts after its owner was suspended. Such entries MUST remain hidden and inert +until that owner resumes. Resuming an owner MUST NOT clear isolation imposed by +a higher blocking entry. Web page isolation compares level first, then request +order within that level; dismissal restores entries no longer below a blocker. + +On Android, a blocking page entry MUST hide underlying page/navigation siblings +from accessibility while preserving the page host and the blocking entry. Entries +above it, including non-blocking global overlays, remain accessible. Global +blockers still win over page entries. Attachment, detachment, suspension, +resumption, input-readiness and blocking changes MUST refresh isolation and +restore each view's original accessibility flag when it is no longer covered. + +These are corrections within the existing contract, not new public props. DOM +and Robolectric tests will verify lifecycle and native flags; manual TalkBack +navigation remains a separate runtime acceptance item. + ## 5.9 Conformance and acceptance Automated (P0): +- `src/__tests__/pageIsolation.web.test.ts` mounts a real React portal in a + DOM host and verifies late suspension, level ordering and restoration after + the blocking entry is removed. +- `NativeOverlayAccessibilityIsolationTest` runs six Robolectric cases against + Android views: page/navigation siblings, entries above the blocker, original + flags, global/page transitions, detached hosts and changing blockers. These + tests do not replace manual TalkBack navigation. + - `src/__tests__/OverlayStore.test.ts` covers: - level ordering regardless of request order; - page below global; diff --git a/native-views/react-native-native-overlay/package.json b/native-views/react-native-native-overlay/package.json index dda496600..b796036c8 100644 --- a/native-views/react-native-native-overlay/package.json +++ b/native-views/react-native-native-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-overlay", - "version": "3.0.162-alpha.270", + "version": "3.0.162-alpha.272", "description": "Native and Web overlay hosts with ordered levels, page scope and Fabric measurement", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -77,6 +77,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "jest": "^29.7.0", + "jest-environment-jsdom": "29.7.0", "lefthook": "^2.0.3", "prettier": "^2.8.8", "react": "19.2.3", diff --git a/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx b/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx index 143da0ef8..7515b074b 100644 --- a/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx +++ b/native-views/react-native-native-overlay/src/OverlayPageHost.web.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { useSuspendedPageOwners } from './useSuspendedPageOwners'; -import { registerOverlayPageHost } from './web/overlayLayers'; +import { registerOverlayPageHost, syncOverlayInert } from './web/overlayLayers'; const OWNER_ATTRIBUTE = 'data-overlay-owner'; @@ -28,14 +28,29 @@ export function OverlayPageHost({ hostKey }: { hostKey: string }) { return; } const suspended = new Set(suspendedOwners); - element - .querySelectorAll(`[${OWNER_ATTRIBUTE}]`) - .forEach((node) => { - const hidden = suspended.has(node.getAttribute(OWNER_ATTRIBUTE) ?? ''); - node.style.visibility = hidden ? 'hidden' : ''; - node.inert = hidden; - }); - }, [suspendedOwners]); + const applySuspension = () => { + element + .querySelectorAll(`[${OWNER_ATTRIBUTE}]`) + .forEach((node) => { + const hidden = + suspended.has(node.getAttribute(OWNER_ATTRIBUTE) ?? '') || + node.getAttribute('aria-hidden') === 'true'; + node.style.visibility = hidden ? 'hidden' : ''; + }); + // Combine suspension with blocking-level isolation; resuming an owner + // must not make an entry below a blocker interactive. + syncOverlayInert(); + }; + applySuspension(); + const observer = new MutationObserver(applySuspension); + observer.observe(element, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [OWNER_ATTRIBUTE, 'aria-hidden'], + }); + return () => observer.disconnect(); + }, [hostKey, suspendedOwners]); return (