From f21b9e7bf033a40b7092f2366917f8a0d4c6875d Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 19 Mar 2025 22:35:49 +0800 Subject: [PATCH] refactor: replace @react-navigation and expo-router (#3151) * init * init * refactor(mobile): enhance tab navigation and structure - Added `TabScreenModule` and `TabScreenView` to support dynamic tab management. - Replaced `FOTabBarController` with a more streamlined `UITabBarController` implementation. - Updated `TabBarModule` to utilize new tab switching logic and event handling. - Modified `TabBarRootView` to manage tab views more effectively and handle tab index changes. - Adjusted main application file to integrate new tab components and improve navigation flow. These changes aim to improve the tab navigation experience and maintainability of the codebase. * tabbar * refactor(mobile): streamline navigation and tab management - Refactored main application structure to enhance tab navigation, integrating `BottomTabs` and `TabRoot` components for improved tab management. - Updated `NavigationHeader` and `SafeNavigationScrollView` to better handle header visibility and scrolling behavior. - Introduced `ScreenItemContext` for managing screen state and scroll position across navigation. - Cleaned up unused imports and optimized component structure for better maintainability. These changes aim to improve the overall navigation experience and code organization within the mobile application. * update * feat(mobile): implement BottomTabProvider for enhanced tab management - Introduced `BottomTabProvider` component to manage tab visibility and scroll view context. - Refactored `BottomTabs` to utilize context for background opacity and tab visibility, simplifying the component structure. - Removed the deprecated `TabClient` component to streamline the tab implementation. - Added a null check in `Tabbar` to prevent rendering when there are no tab screens. These changes aim to improve the organization and functionality of the tab navigation system within the mobile application. Signed-off-by: Innei * update * update * update * update * update * feat(mobile): integrate ErrorBoundary and enhance navigation context - Added `ErrorBoundary` component to handle errors gracefully within the application. - Integrated `BottomTabProvider` to manage tab visibility and scroll view context more effectively. - Refactored navigation components to utilize new context for improved state management. - Updated `SafeNavigationScrollView` and `Settings` components to streamline scrolling behavior and context usage. - Removed deprecated code and optimized component structure for better maintainability. These changes enhance the user experience by providing error handling and improving navigation management. * feat(mobile): enhance navigation and layout components - Registered new screens (`TermsScreen`, `LoginScreen`, `SignUpScreen`, `ForgetPasswordScreen`, `TwoFactorAuthScreen`) with the `NavigationSitemapRegistry` for improved navigation management. - Adjusted layout properties in `HeaderElements` and `NavigationHeader` for better alignment and responsiveness. - Refactored `Tabbar` to remove unnecessary animated reactions, optimizing performance. - Exported `ActionGroup` component for better reusability in `TimelineSelectorProvider`. - Updated `DebugScreen` to utilize the new navigation methods for component presentation. These changes improve the overall navigation experience and component structure within the mobile application. Signed-off-by: Innei * feat(mobile): implement new architecture support and refactor TabBar components - Added support for the new architecture in the iOS podspec by conditionally defining `OTHER_SWIFT_FLAGS`. - Refactored `TabBarPortalModule`, `TabBarRootView`, and `TabScreenView` to improve structure and maintainability. - Replaced native view components with standard React Native views in `TabBarPortal`, `TabRoot`, and `TabScreen` for better compatibility and performance. - Updated import paths for `TabScreenProps` to ensure correct type usage. These changes enhance the navigation experience and prepare the codebase for future architecture improvements. Signed-off-by: Innei * feat(mobile): refactor navigation and onboarding components - Removed outdated navigation dependencies from `package.json` to streamline the project. - Enhanced `TabBarRootView` to improve tab switching animations and manage view transitions more effectively. - Updated `App` component structure for better readability and integration of onboarding logic. - Introduced `OnboardingScreen` with improved layout and navigation handling. - Refactored navigation context types to include additional screen options for better flexibility. These changes enhance the navigation experience and prepare the application for future onboarding features. Signed-off-by: Innei * feat(mobile): enhance intent handling with navigation integration - Added navigation support in `useIntentHandler` to handle incoming URLs more effectively. - Integrated `FollowScreen` for presenting user-specific content based on URL parameters. - Updated effect dependencies to include navigation for improved functionality. These changes improve the handling of deep links and enhance the user experience when navigating to the follow screen. Signed-off-by: Innei * chore: clean up pnpm-lock.yaml by removing unused navigation dependencies - Removed outdated navigation packages including `@react-navigation/bottom-tabs`, `@react-navigation/drawer`, and `@react-navigation/native` to streamline the lock file. - Cleaned up unnecessary entries and peer dependencies to improve maintainability and reduce clutter. These changes help in optimizing the dependency management for the project. Signed-off-by: Innei * lint Signed-off-by: Innei * chore: auto-fix linting and formatting issues * fix Signed-off-by: Innei * update Signed-off-by: Innei * refactor: update PickerIos component and remove FeedDrawer - Added import for Portal in PickerIos component. - Removed the FeedDrawer component entirely, cleaning up unused code. - Replaced router.dismiss() with navigation.dismiss() in list screen options for better navigation handling. Signed-off-by: Innei --------- Signed-off-by: Innei --- .github/dependabot.yaml | 2 + apps/mobile/app.config.ts | 17 +- apps/mobile/native/expo-module.config.json | 5 +- apps/mobile/native/ios/FollowNative.podspec | 2 + .../ios/Modules/TabBar/TabBarModule.swift | 37 ++ .../Modules/TabBar/TabBarPortalModule.swift | 21 + .../ios/Modules/TabBar/TabBarRootView.swift | 126 ++++++ .../ios/Modules/TabBar/TabScreenModule.swift | 16 + .../ios/Modules/TabBar/TabScreenView.swift | 16 + apps/mobile/package.json | 9 +- apps/mobile/src/Analytics.tsx | 23 + apps/mobile/src/App.tsx | 41 ++ .../src/components/common/ErrorBoundary.tsx | 18 + .../components/common/HeaderTitleExtra.tsx | 49 --- apps/mobile/src/components/common/Link.tsx | 24 ++ .../layouts/header/FakeNativeHeaderTitle.tsx | 27 ++ .../header/HeaderElements.tsx} | 58 +-- .../components/layouts/header/ModalHeader.tsx | 105 ----- .../layouts/header/NavigationHeader.tsx | 178 ++++---- .../layouts/tabbar/BottomTabProvider.tsx | 24 ++ .../components/layouts/tabbar/BottomTabs.tsx | 84 +--- .../components/layouts/tabbar/TabClient.tsx | 55 --- .../src/components/layouts/tabbar/Tabbar.tsx | 146 +++---- .../src/components/layouts/tabbar/hooks.ts | 16 +- .../layouts/views/NavigationContext.tsx | 6 - .../views/SafeNavigationScrollView.tsx | 192 +++++---- .../native/webview/EntryContentWebView.tsx | 2 +- .../src/components/ui/form/PickerIos.tsx | 2 +- .../src/components/ui/portal/Portal.tsx | 47 --- .../components/ui/portal/PortalContext.tsx | 5 - .../src/components/ui/portal/PortalHost.tsx | 87 ---- .../components/ui/portal/PortalManager.tsx | 46 -- .../mobile/src/components/ui/portal/index.tsx | 2 - apps/mobile/src/components/ui/portal/type.ts | 44 -- apps/mobile/src/hooks/useIntentHandler.ts | 14 +- apps/mobile/src/hooks/useIsRouteOnlyOne.ts | 10 - .../AttachNavigationScrollViewContext.tsx | 0 .../lib/navigation/ChainNavigationContext.tsx | 23 + .../GroupedNavigationRouteContext.ts | 5 + apps/mobile/src/lib/navigation/Navigation.ts | 180 ++++++++ .../navigation/NavigationInstanceContext.ts | 5 + .../src/lib/navigation/NavigationLink.tsx | 39 ++ .../src/lib/navigation/ScreenItemContext.ts | 22 + .../src/lib/navigation/ScreenNameContext.tsx | 13 + .../lib/navigation/ScreenOptionsContext.ts | 46 ++ .../src/lib/navigation/StackNavigation.tsx | 201 +++++++++ .../navigation/StackScreenHeaderPortal.tsx | 19 + .../src/lib/navigation/WrappedScreenItem.tsx | 143 +++++++ .../src/lib/navigation/__internal/hooks.ts | 55 +++ .../bottom-tab/BottomTabContext.tsx | 14 + .../bottom-tab/CalculateTabBarOpacity.tsx | 41 ++ .../bottom-tab/TabBarPortal.ios.tsx | 34 ++ .../navigation/bottom-tab/TabBarPortal.tsx | 30 ++ .../lib/navigation/bottom-tab/TabRoot.ios.tsx | 69 +++ .../src/lib/navigation/bottom-tab/TabRoot.tsx | 48 +++ .../navigation/bottom-tab/TabScreen.ios.tsx | 106 +++++ .../lib/navigation/bottom-tab/TabScreen.tsx | 102 +++++ .../bottom-tab/TabScreenContext.tsx | 9 + .../src/lib/navigation/bottom-tab/hooks.ts | 38 ++ .../src/lib/navigation/bottom-tab/shared.tsx | 40 ++ .../src/lib/navigation/bottom-tab/types.ts | 11 + apps/mobile/src/lib/navigation/config.ts | 5 + .../lib/navigation/debug/DebugButtonGroup.tsx | 159 +++++++ apps/mobile/src/lib/navigation/hooks.ts | 74 ++++ .../src/lib/navigation/sitemap/registry.ts | 41 ++ apps/mobile/src/lib/navigation/types.ts | 36 ++ apps/mobile/src/main.tsx | 77 +++- .../mobile/src/modules/context-menu/entry.tsx | 17 +- .../mobile/src/modules/context-menu/feeds.tsx | 9 +- apps/mobile/src/modules/debug/index.tsx | 8 +- .../src/modules/dialogs/AddFeedDialog.tsx | 23 +- .../discover/RecommendationListItem.tsx | 16 +- .../src/modules/discover/Recommendations.tsx | 9 +- .../discover/search-tabs/SearchFeed.tsx | 9 +- .../discover/search-tabs/SearchList.tsx | 9 +- apps/mobile/src/modules/discover/search.tsx | 17 +- .../entry-content/EntryReadHistory.tsx | 8 +- .../src/modules/entry-content/EntryTitle.tsx | 108 +++-- .../entry-list/templates/EntryNormalItem.tsx | 12 +- .../entry-list/templates/EntrySocialItem.tsx | 18 +- .../mobile/src/modules/feed-drawer/drawer.tsx | 71 ---- .../src/modules/feed-drawer/feed-panel.tsx | 15 +- apps/mobile/src/modules/feed/FollowFeed.tsx | 50 ++- apps/mobile/src/modules/list/FollowList.tsx | 27 +- apps/mobile/src/modules/login/email.tsx | 19 +- apps/mobile/src/modules/login/index.tsx | 18 +- .../modules/onboarding/step-preferences.tsx | 9 +- .../src/modules/player/PlayerTabBar.tsx | 17 +- apps/mobile/src/modules/player/control.tsx | 5 +- .../modules/screen/TimelineSelectorList.tsx | 33 +- .../screen/TimelineSelectorProvider.tsx | 34 +- apps/mobile/src/modules/screen/action.tsx | 13 +- .../src/modules/settings/SettingsList.tsx | 119 +++--- .../src/modules/settings/UserHeaderBanner.tsx | 10 +- .../src/modules/settings/actions/constant.ts | 10 +- apps/mobile/src/modules/settings/hooks.ts | 10 - .../modules/settings/routes/2FASetting.tsx | 18 +- .../src/modules/settings/routes/About.tsx | 2 +- .../src/modules/settings/routes/Account.tsx | 10 +- .../src/modules/settings/routes/Actions.tsx | 11 +- .../modules/settings/routes/EditCondition.tsx | 19 +- .../modules/settings/routes/EditProfile.tsx | 38 +- .../settings/routes/EditRewriteRules.tsx | 15 +- .../src/modules/settings/routes/EditRule.tsx | 23 +- .../modules/settings/routes/EditWebhooks.tsx | 15 +- .../src/modules/settings/routes/General.tsx | 3 +- .../src/modules/settings/routes/Lists.tsx | 21 +- .../modules/settings/routes/ManageList.tsx | 21 +- .../src/modules/settings/routes/Privacy.tsx | 7 +- .../src/modules/settings/routes/Profile.tsx | 11 +- .../src/modules/settings/routes/index.tsx | 86 ---- apps/mobile/src/modules/settings/types.ts | 24 -- .../modules/subscription/CategoryGrouped.tsx | 8 +- .../subscription/SubscriptionLists.tsx | 8 +- .../src/modules/subscription/ViewTab.tsx | 165 -------- .../modules/subscription/items/InboxItem.tsx | 8 +- .../items/ListSubscriptionItem.tsx | 8 +- .../subscription/items/SubscriptionItem.tsx | 8 +- apps/mobile/src/providers/index.tsx | 29 +- .../mobile/src/screens/(headless)/_layout.tsx | 12 - apps/mobile/src/screens/(headless)/debug.tsx | 39 +- apps/mobile/src/screens/(headless)/search.tsx | 34 +- apps/mobile/src/screens/(headless)/terms.tsx | 19 +- .../mobile/src/screens/(headless)/webview.tsx | 76 ---- apps/mobile/src/screens/(modal)/2fa.tsx | 11 +- apps/mobile/src/screens/(modal)/_layout.tsx | 86 ---- .../mobile/src/screens/(modal)/edit-email.tsx | 18 +- apps/mobile/src/screens/(modal)/follow.tsx | 11 +- .../src/screens/(modal)/forget-password.tsx | 10 +- apps/mobile/src/screens/(modal)/list.tsx | 36 +- apps/mobile/src/screens/(modal)/login.tsx | 10 +- .../(modal)/onboarding/edit-profile.tsx | 2 +- .../onboarding/select-reading-mode.tsx | 9 +- apps/mobile/src/screens/(modal)/profile.tsx | 10 +- .../src/screens/(modal)/rsshub-form.tsx | 69 +-- apps/mobile/src/screens/(modal)/sign-up.tsx | 9 +- .../src/screens/(stack)/(tabs)/_layout.tsx | 96 ----- .../src/screens/(stack)/(tabs)/discover.tsx | 32 +- .../src/screens/(stack)/(tabs)/index.tsx | 12 +- .../src/screens/(stack)/(tabs)/settings.tsx | 127 ++---- .../screens/(stack)/(tabs)/subscriptions.tsx | 11 + apps/mobile/src/screens/(stack)/_layout.tsx | 23 - .../(stack)/entries/[entryId]/index.tsx | 19 +- .../screens/(stack)/feeds/[feedId]/index.tsx | 9 +- apps/mobile/src/screens/_layout.tsx | 102 ----- apps/mobile/src/screens/onboarding.tsx | 80 ++-- apps/mobile/src/screens/player.tsx | 81 ++-- apps/mobile/src/store/action/hooks.ts | 5 +- apps/mobile/src/store/user/hooks.ts | 8 +- apps/mobile/src/theme/navigation.ts | 25 -- patches/react-native-sheet-transitions.patch | 132 +++++- pnpm-lock.yaml | 393 ++---------------- 152 files changed, 3382 insertions(+), 2756 deletions(-) create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabBarModule.swift create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabBarPortalModule.swift create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabScreenModule.swift create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabScreenView.swift create mode 100644 apps/mobile/src/Analytics.tsx create mode 100644 apps/mobile/src/App.tsx create mode 100644 apps/mobile/src/components/common/ErrorBoundary.tsx delete mode 100644 apps/mobile/src/components/common/HeaderTitleExtra.tsx create mode 100644 apps/mobile/src/components/common/Link.tsx create mode 100644 apps/mobile/src/components/layouts/header/FakeNativeHeaderTitle.tsx rename apps/mobile/src/components/{common/ModalSharedComponents.tsx => layouts/header/HeaderElements.tsx} (54%) delete mode 100644 apps/mobile/src/components/layouts/header/ModalHeader.tsx create mode 100644 apps/mobile/src/components/layouts/tabbar/BottomTabProvider.tsx delete mode 100644 apps/mobile/src/components/layouts/tabbar/TabClient.tsx delete mode 100644 apps/mobile/src/components/layouts/views/NavigationContext.tsx delete mode 100644 apps/mobile/src/components/ui/portal/Portal.tsx delete mode 100644 apps/mobile/src/components/ui/portal/PortalContext.tsx delete mode 100644 apps/mobile/src/components/ui/portal/PortalHost.tsx delete mode 100644 apps/mobile/src/components/ui/portal/PortalManager.tsx delete mode 100644 apps/mobile/src/components/ui/portal/index.tsx delete mode 100644 apps/mobile/src/components/ui/portal/type.ts delete mode 100644 apps/mobile/src/hooks/useIsRouteOnlyOne.ts rename apps/mobile/src/{components/layouts/tabbar/contexts => lib/navigation}/AttachNavigationScrollViewContext.tsx (100%) create mode 100644 apps/mobile/src/lib/navigation/ChainNavigationContext.tsx create mode 100644 apps/mobile/src/lib/navigation/GroupedNavigationRouteContext.ts create mode 100644 apps/mobile/src/lib/navigation/Navigation.ts create mode 100644 apps/mobile/src/lib/navigation/NavigationInstanceContext.ts create mode 100644 apps/mobile/src/lib/navigation/NavigationLink.tsx create mode 100644 apps/mobile/src/lib/navigation/ScreenItemContext.ts create mode 100644 apps/mobile/src/lib/navigation/ScreenNameContext.tsx create mode 100644 apps/mobile/src/lib/navigation/ScreenOptionsContext.ts create mode 100644 apps/mobile/src/lib/navigation/StackNavigation.tsx create mode 100644 apps/mobile/src/lib/navigation/StackScreenHeaderPortal.tsx create mode 100644 apps/mobile/src/lib/navigation/WrappedScreenItem.tsx create mode 100644 apps/mobile/src/lib/navigation/__internal/hooks.ts create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/BottomTabContext.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.ios.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabRoot.ios.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabRoot.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabScreen.ios.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabScreen.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/TabScreenContext.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/hooks.ts create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/shared.tsx create mode 100644 apps/mobile/src/lib/navigation/bottom-tab/types.ts create mode 100644 apps/mobile/src/lib/navigation/config.ts create mode 100644 apps/mobile/src/lib/navigation/debug/DebugButtonGroup.tsx create mode 100644 apps/mobile/src/lib/navigation/hooks.ts create mode 100644 apps/mobile/src/lib/navigation/sitemap/registry.ts create mode 100644 apps/mobile/src/lib/navigation/types.ts delete mode 100644 apps/mobile/src/modules/feed-drawer/drawer.tsx delete mode 100644 apps/mobile/src/modules/settings/hooks.ts delete mode 100644 apps/mobile/src/modules/settings/routes/index.tsx delete mode 100644 apps/mobile/src/modules/settings/types.ts delete mode 100644 apps/mobile/src/modules/subscription/ViewTab.tsx delete mode 100644 apps/mobile/src/screens/(headless)/_layout.tsx delete mode 100644 apps/mobile/src/screens/(headless)/webview.tsx delete mode 100644 apps/mobile/src/screens/(modal)/_layout.tsx delete mode 100644 apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx delete mode 100644 apps/mobile/src/screens/(stack)/_layout.tsx delete mode 100644 apps/mobile/src/screens/_layout.tsx delete mode 100644 apps/mobile/src/theme/navigation.ts diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index bc0e1ea98..891918c26 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -41,6 +41,8 @@ updates: # electron 35 - dependency-name: electron versions: [">=35.0.0"] + - dependency-name: react-native-sheet-transitions + versions: [">0.1.2"] open-pull-requests-limit: 100 groups: minor: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5911ac73e..fcf6ca2e0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -53,11 +53,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, googleServicesFile: "./build/google-services.json", }, - web: { - bundler: "metro", - output: "static", - favicon: iconPath, - }, + // web: { + // bundler: "metro", + // output: "static", + // favicon: iconPath, + // }, plugins: [ [ "expo-document-picker", @@ -66,12 +66,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ], "expo-localization", - [ - "expo-router", - { - root: "./src/screens", - }, - ], + [ "expo-splash-screen", { diff --git a/apps/mobile/native/expo-module.config.json b/apps/mobile/native/expo-module.config.json index 3e4942bda..1e2f2b786 100644 --- a/apps/mobile/native/expo-module.config.json +++ b/apps/mobile/native/expo-module.config.json @@ -7,7 +7,10 @@ "ToasterModule", "AppleIntelligenceGlowEffectModule", "FollowNative.GaleriaModule", - "GaleriaAccessoryModule" + "GaleriaAccessoryModule", + "TabBarModule", + "TabScreenModule", + "TabBarPortalModule" ] }, "android": { diff --git a/apps/mobile/native/ios/FollowNative.podspec b/apps/mobile/native/ios/FollowNative.podspec index 5b730fda0..e54627831 100644 --- a/apps/mobile/native/ios/FollowNative.podspec +++ b/apps/mobile/native/ios/FollowNative.podspec @@ -1,4 +1,5 @@ require 'json' +new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) @@ -25,6 +26,7 @@ Pod::Spec.new do |s| # Swift/Objective-C compatibility s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', + 'OTHER_SWIFT_FLAGS' => "$(inherited) #{new_arch_enabled ? '-DRCT_NEW_ARCH_ENABLED' : ''}", } s.source_files = "**/*.{h,m,mm,swift,hpp,cpp,js}" diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarModule.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarModule.swift new file mode 100644 index 000000000..597ed9b02 --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarModule.swift @@ -0,0 +1,37 @@ +// +// TabBarModule.swift +// FollowNative +// +// Created by Innei on 2025/3/16. +// + +import ExpoModulesCore + +public class TabBarModule: Module { + + public func definition() -> ModuleDefinition { + Name("TabBarRoot") + + Function("switchTab") { [weak self] (reactTag: Int, index: Int) in + DispatchQueue.main.async { + guard let bridge = self?.appContext?.reactBridge else { + return + } + + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) as! TabBarRootView? + + { + sourceView.switchToTab(index: index) + } + } + } + + View(TabBarRootView.self) { + Prop("selectedIndex") { (view, index: Int) in + view.switchToTab(index: index) + } + + Events("onTabIndexChange") + } + } +} diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarPortalModule.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarPortalModule.swift new file mode 100644 index 000000000..0bdf659bc --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarPortalModule.swift @@ -0,0 +1,21 @@ +// +// TabBarPortalModule.swift +// FollowNative +// +// Created by Innei on 2025/3/17. +// + +import ExpoModulesCore +import UIKit + +public class TabBarPortalModule: Module { + public func definition() -> ModuleDefinition { + Name("TabBarPortal") + + View(TabBarPortalView.self) {} + } +} + +class TabBarPortalView: ExpoView { + +} diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift new file mode 100644 index 000000000..555294eb1 --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift @@ -0,0 +1,126 @@ +// +// TabBarRootView.swift +// FollowNative +// +// Created by Innei on 2025/3/16. +// + +import ExpoModulesCore +import Foundation +import SnapKit + +class TabBarRootView: ExpoView { + private let tabBarController = UITabBarController() + private let vc = UIViewController() + private var tabViewControllers: [UIViewController] = [] + + private let onTabIndexChange = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + tabBarController.tabBar.isHidden = true + tabBarController.delegate = self + addSubview(vc.view) + vc.addChild(tabBarController) + vc.view!.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + vc.view.addSubview(tabBarController.view) + tabBarController.didMove(toParent: vc) + + } + + #if RCT_NEW_ARCH_ENABLED + + override func mountChildComponentView(_ childComponentView: UIView, index: Int) { + self.insertSubview(childComponentView, at: index) + } + + override func unmountChildComponentView(_ childComponentView: UIView, index: Int) { + self.willRemoveSubview(childComponentView) + // gestureRecognizers?.removeAll() + + } + #endif + + public func switchToTab(index: Int) { + let beforeIndex = tabBarController.selectedIndex + if beforeIndex == index { + return + } + + if tabViewControllers.count < beforeIndex || tabViewControllers.count < index { + return + } + if let fromView = tabViewControllers[beforeIndex].view, + let toView = tabViewControllers[index].view + { + if fromView != toView { + UIView.transition( + from: fromView, + to: toView, + duration: 0.1, + options: [.transitionCrossDissolve, .preferredFramesPerSecond60], + completion: nil) + } + } + + tabBarController.selectedIndex = index + if let selectedViewController = tabBarController.selectedViewController { + tabBarController(tabBarController, didSelect: selectedViewController) + } + + } + + override func insertSubview(_ subview: UIView, at atIndex: Int) { + + if let tabScreenView = subview as? TabScreenView { + + let screenVC = UIViewController() + screenVC.view = tabScreenView + tabViewControllers.append(screenVC) + tabBarController.viewControllers = tabViewControllers + tabBarController.didMove(toParent: vc) + } + + if let tabBarPortalView = subview as? TabBarPortalView { + let tabBarView = self.tabBarController.view! + tabBarView.addSubview(tabBarPortalView) + + } + } + + override func willRemoveSubview(_ subview: UIView) { + if let tabScreenView = subview as? TabScreenView { + tabViewControllers.removeAll { viewController in + return viewController.view == tabScreenView + } + + tabBarController.viewControllers = tabViewControllers + tabBarController.didMove(toParent: vc) + } + } + +} + +// MARK: - UITabBarControllerDelegate +extension TabBarRootView: UITabBarControllerDelegate { + + func tabBarController( + _ tabBarController: UITabBarController, didSelect viewController: UIViewController + ) { + onTabIndexChange([ + "index": tabBarController.selectedIndex + ]) + } + + // func tabBarController( + // _ tabBarController: UITabBarController, + // animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController + // ) -> (any UIViewControllerAnimatedTransitioning)? { + + // return nil + // } + +} diff --git a/apps/mobile/native/ios/Modules/TabBar/TabScreenModule.swift b/apps/mobile/native/ios/Modules/TabBar/TabScreenModule.swift new file mode 100644 index 000000000..d78eae0b6 --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabScreenModule.swift @@ -0,0 +1,16 @@ +// +// TabScreenModule.swift +// FollowNative +// +// Created by Innei on 2025/3/16. +// + +import ExpoModulesCore + +public class TabScreenModule: Module { + + public func definition() -> ModuleDefinition { + Name("TabScreen") + View(TabScreenView.self) {} + } +} diff --git a/apps/mobile/native/ios/Modules/TabBar/TabScreenView.swift b/apps/mobile/native/ios/Modules/TabBar/TabScreenView.swift new file mode 100644 index 000000000..0ea70ab79 --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabScreenView.swift @@ -0,0 +1,16 @@ +// +// TabScreenView.swift +// FollowNative +// +// Created by Innei on 2025/3/16. +// + +import ExpoModulesCore +import UIKit + +class TabScreenView: ExpoView { + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + } + +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c5659d3cd..ae225db75 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,7 @@ "ios:device": "expo run:ios --device", "lint": "expo lint", "start": "expo start --dev-client", + "start:clean": "expo start --dev-client --clear", "typecheck": "tsc --noEmit", "web": "expo start --web" }, @@ -24,6 +25,7 @@ "@follow/models": "workspace:*", "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", + "@gorhom/portal": "1.0.14", "@hookform/resolvers": "4.1.3", "@react-native-community/image-editor": "4.3.0", "@react-native-firebase/analytics": "21.12.0", @@ -31,9 +33,6 @@ "@react-native-firebase/app-check": "21.12.0", "@react-native-firebase/crashlytics": "21.12.0", "@react-native-picker/picker": "2.11.0", - "@react-navigation/bottom-tabs": "7.2.1", - "@react-navigation/drawer": "7.1.2", - "@react-navigation/native": "7.0.15", "@shopify/flash-list": "1.7.3", "@tanstack/query-sync-storage-persister": "5.68.0", "@tanstack/react-query": "5.68.0", @@ -62,7 +61,6 @@ "expo-linking": "7.0.5", "expo-localization": "16.0.1", "expo-media-library": "17.0.6", - "expo-router": "4.0.18", "expo-secure-store": "14.0.1", "expo-sharing": "13.0.1", "expo-splash-screen": "0.29.22", @@ -82,6 +80,7 @@ "qrcode": "1.5.4", "react": "18.3.1", "react-dom": "18.3.1", + "react-error-boundary": "5.0.0", "react-hook-form": "7.54.2", "react-native": "0.77.1", "react-native-awesome-slider": "2.9.0", @@ -101,7 +100,7 @@ "react-native-sheet-transitions": "0.1.2", "react-native-svg": "15.11.2", "react-native-track-player": "4.1.1", - "react-native-uikit-colors": "0.3.8", + "react-native-uikit-colors": "0.3.9", "react-native-video": "6.10.2", "react-native-volume-manager": "2.0.8", "react-native-web": "0.19.13", diff --git a/apps/mobile/src/Analytics.tsx b/apps/mobile/src/Analytics.tsx new file mode 100644 index 000000000..34960d490 --- /dev/null +++ b/apps/mobile/src/Analytics.tsx @@ -0,0 +1,23 @@ +import analytics from "@react-native-firebase/analytics" +import { useEffect } from "react" + +import { Navigation } from "./lib/navigation/Navigation" + +export const Analytics = () => { + useEffect(() => { + return Navigation.rootNavigation.on("didAppear", ({ screenId }) => { + const logScreenView = async () => { + try { + await analytics().logScreenView({ + screen_name: screenId, + screen_class: screenId, + }) + } catch (err: any) { + console.warn(`[Error] logScreenView: ${err}`) + } + } + logScreenView() + }) + }, []) + return null +} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx new file mode 100644 index 000000000..602691b80 --- /dev/null +++ b/apps/mobile/src/App.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native" +import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated" +import { useSheet } from "react-native-sheet-transitions" + +import { FullWindowOverlay } from "./components/common/FullWindowOverlay" +import { useIntentHandler } from "./hooks/useIntentHandler" +import { DebugButton, EnvProfileIndicator } from "./modules/debug" +import { useOnboarding, usePrefetchSessionUser } from "./store/user/hooks" + +export function App({ children }: { children: React.ReactNode }) { + useIntentHandler() + useOnboarding() + const { scale } = useSheet() + + const style = useAnimatedStyle(() => ({ + borderRadius: interpolate(scale.value, [0.8, 0.99, 1], [0, 50, 0]), + transform: [ + { + scale: scale.value, + }, + ], + })) + return ( + + + + + {children} + + {__DEV__ && } + + + + + ) +} + +const Session = () => { + usePrefetchSessionUser() + return null +} diff --git a/apps/mobile/src/components/common/ErrorBoundary.tsx b/apps/mobile/src/components/common/ErrorBoundary.tsx new file mode 100644 index 000000000..7e0626b79 --- /dev/null +++ b/apps/mobile/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,18 @@ +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary" +import { Text, View } from "react-native" + +const ErrorBoundaryFallback = ({ error }: { error: Error }) => { + return ( + + {error.message} + + ) +} + +export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => { + return ( + }> + {children} + + ) +} diff --git a/apps/mobile/src/components/common/HeaderTitleExtra.tsx b/apps/mobile/src/components/common/HeaderTitleExtra.tsx deleted file mode 100644 index be22ff0de..000000000 --- a/apps/mobile/src/components/common/HeaderTitleExtra.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { cn } from "@follow/utils" -import { useTheme } from "@react-navigation/native" -import type { StyleProp, TextProps, TextStyle } from "react-native" -import { Animated, StyleSheet, Text, View } from "react-native" - -type Props = Omit & { - tintColor?: string - children?: string - style?: Animated.WithAnimatedValue> - subText?: string - subTextStyle?: StyleProp - subTextClassName?: string -} - -export function HeaderTitleExtra({ - tintColor, - style, - subText, - subTextStyle, - subTextClassName, - ...rest -}: Props) { - const { fonts } = useTheme() - - return ( - - - {!!subText && ( - - {subText} - - )} - - ) -} - -const styles = StyleSheet.create({ - title: { fontSize: 17 }, -}) diff --git a/apps/mobile/src/components/common/Link.tsx b/apps/mobile/src/components/common/Link.tsx new file mode 100644 index 000000000..93c516a0c --- /dev/null +++ b/apps/mobile/src/components/common/Link.tsx @@ -0,0 +1,24 @@ +import type { PropsWithChildren } from "react" +import type { TextProps } from "react-native" +import { Linking, Text } from "react-native" + +export const Link = ({ + href, + children, + ...props +}: PropsWithChildren<{ href: string } & TextProps>) => { + return ( + { + const canOpen = await Linking.canOpenURL(href) + if (canOpen) { + Linking.openURL(href) + } + }} + {...props} + > + {children} + + ) +} diff --git a/apps/mobile/src/components/layouts/header/FakeNativeHeaderTitle.tsx b/apps/mobile/src/components/layouts/header/FakeNativeHeaderTitle.tsx new file mode 100644 index 000000000..2fd94b5da --- /dev/null +++ b/apps/mobile/src/components/layouts/header/FakeNativeHeaderTitle.tsx @@ -0,0 +1,27 @@ +import type { StyleProp, TextProps, TextStyle } from "react-native" +import { Animated, StyleSheet } from "react-native" + +type Props = Omit & { + tintColor?: string + children?: string + style?: Animated.WithAnimatedValue> +} + +export function FakeNativeHeaderTitle({ style, ...rest }: Props) { + return ( + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 17, + }, +}) diff --git a/apps/mobile/src/components/common/ModalSharedComponents.tsx b/apps/mobile/src/components/layouts/header/HeaderElements.tsx similarity index 54% rename from apps/mobile/src/components/common/ModalSharedComponents.tsx rename to apps/mobile/src/components/layouts/header/HeaderElements.tsx index 675d805ab..7e59b2cb2 100644 --- a/apps/mobile/src/components/common/ModalSharedComponents.tsx +++ b/apps/mobile/src/components/layouts/header/HeaderElements.tsx @@ -1,38 +1,39 @@ import { withOpacity } from "@follow/utils" -import { router } from "expo-router" -import { useCallback, useMemo } from "react" -import { TouchableOpacity } from "react-native" +import { useCallback } from "react" +import { TouchableOpacity, View } from "react-native" -import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne" import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" +import { + useCanDismiss, + useIsSingleRouteInGroup, + useNavigation, + useScreenIsInSheetModal, +} from "@/src/lib/navigation/hooks" +import { StackScreenHeaderPortal } from "@/src/lib/navigation/StackScreenHeaderPortal" import { useColor } from "@/src/theme/colors" -import { RotateableLoading } from "./RotateableLoading" +import { RotateableLoading } from "../../common/RotateableLoading" -export const ModalHeaderCloseButton = () => { - return -} - -const ModalHeaderCloseButtonImpl = () => { +export const HeaderCloseButton = () => { const label = useColor("label") - const routeOnlyOne = useIsRouteOnlyOne() - const memoedRouteOnlyOne = useMemo(() => routeOnlyOne, []) - + const navigation = useNavigation() + const canDismiss = useCanDismiss() + const isInModal = useScreenIsInSheetModal() + const isSingleRouteInGroup = useIsSingleRouteInGroup() const handlePress = useCallback(() => { - if (router.canDismiss()) { - router.dismiss() + if (canDismiss) { + navigation.dismiss() } else { - // If we can't dismiss, redirect to the root route - router.replace("/") + navigation.back() } - }, []) + }, [canDismiss, navigation]) return ( - {memoedRouteOnlyOne ? ( + {isInModal && isSingleRouteInGroup ? ( ) : ( @@ -46,15 +47,8 @@ export interface ModalHeaderSubmitButtonProps { onPress: () => void isLoading?: boolean } -export const ModalHeaderSubmitButton = ({ - isValid, - onPress, - isLoading, -}: ModalHeaderSubmitButtonProps) => { - return -} -const ModalHeaderShubmitButtonImpl = ({ +export const HeaderSubmitButton = ({ isValid, onPress, isLoading, @@ -71,3 +65,13 @@ const ModalHeaderShubmitButtonImpl = ({ ) } + +export const HeaderCloseOnly = () => { + return ( + + + + + + ) +} diff --git a/apps/mobile/src/components/layouts/header/ModalHeader.tsx b/apps/mobile/src/components/layouts/header/ModalHeader.tsx deleted file mode 100644 index 1faa9bf37..000000000 --- a/apps/mobile/src/components/layouts/header/ModalHeader.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useTypeScriptHappyCallback } from "@follow/hooks/exports" -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack" -import { Stack } from "expo-router" -import type { FC } from "react" -import { memo } from "react" -import { StyleSheet, View } from "react-native" -import type { SharedValue } from "react-native-reanimated" -import Animated, { useAnimatedStyle } from "react-native-reanimated" -import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context" -import { useColor } from "react-native-uikit-colors" - -import { HeaderTitleExtra } from "../../common/HeaderTitleExtra" -import { ModalHeaderCloseButton } from "../../common/ModalSharedComponents" -import { ThemedBlurView } from "../../common/ThemedBlurView" -import { useModalScrollViewContext } from "../contexts/ModalScrollViewContext" -import { getDefaultHeaderHeight } from "../utils" - -interface ModalHeaderProps - extends Omit { - headerLeft?: React.ReactNode - headerRight?: React.ReactNode - headerTitle?: string - headerSubtitle?: string -} - -export const ModalHeader: FC = (props) => { - const { animatedY } = useModalScrollViewContext() - - return ( - props.headerLeft ?? , - headerRight: () => props.headerRight, - - header: useTypeScriptHappyCallback( - ({ options }) => ( -
- ), - [animatedY, props.headerSubtitle, props.headerTitle], - ), - }} - /> - ) -} - -interface HeaderProps { - headerTitle?: string - headerLeft?: React.ReactNode - headerRight?: React.ReactNode - animatedY: SharedValue - headerSubtitle?: string -} - -const InternalBlurEffectWithBottomBorder = (props: { animatedY: SharedValue }) => { - const border = useColor("opaqueSeparator") - const animatedStyle = useAnimatedStyle(() => { - return { - opacity: Math.max(0, Math.min(1, props.animatedY.value / 10)), - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: border, - } - }) - return ( - - - - ) -} - -const Header: FC = memo( - ({ headerTitle, headerLeft, headerRight, headerSubtitle, animatedY }) => { - const frame = useSafeAreaFrame() - const insets = useSafeAreaInsets() - const height = getDefaultHeaderHeight(frame, true, insets.top) - - return ( - - - {/* Grid */} - - {/* Left actions */} - {headerLeft} - - {/* Title */} - - {/* Title */} - {headerTitle} - - - {/* Right actions */} - {headerRight} - - - ) - }, -) diff --git a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx index 0ba2780ad..c438749f1 100644 --- a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx +++ b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx @@ -1,12 +1,11 @@ import { cn } from "@follow/utils" -import { HeaderTitle } from "@react-navigation/elements" -import { router, Stack, useNavigation } from "expo-router" import type { FC, PropsWithChildren, ReactNode } from "react" import { createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" import type { LayoutChangeEvent } from "react-native" import { StyleSheet, TouchableOpacity, View } from "react-native" import type { AnimatedProps } from "react-native-reanimated" import Animated, { + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useSharedValue, @@ -14,26 +13,34 @@ import Animated, { } from "react-native-reanimated" import type { DefaultStyle } from "react-native-reanimated/lib/typescript/hook/commonTypes" import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context" -import type { NativeStackNavigationOptions } from "react-native-screens/lib/typescript/native-stack/types" import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils" import { useColor } from "react-native-uikit-colors" +import { CloseCuteReIcon } from "@/src/icons/close_cute_re" import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" +import { + useCanBack, + useCanDismiss, + useNavigation, + useScreenIsInSheetModal, +} from "@/src/lib/navigation/hooks" +import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext" import { ThemedBlurView } from "../../common/ThemedBlurView" import { getDefaultHeaderHeight } from "../utils" -import { NavigationContext } from "../views/NavigationContext" import { SetNavigationHeaderHeightContext } from "../views/NavigationHeaderContext" +import { FakeNativeHeaderTitle } from "./FakeNativeHeaderTitle" +interface NavigationHeaderButtonProps { + canGoBack: boolean + canDismiss: boolean + modal?: boolean +} export interface NavigationHeaderRawProps { - headerLeft?: FC<{ - canGoBack: boolean - }> - headerTitle?: FC> | ReactNode + headerLeft?: FC + headerRight?: FC + headerTitle?: FC> | ReactNode headerTitleAbsolute?: boolean - headerRight?: FC<{ - canGoBack: boolean - }> title?: string @@ -63,9 +70,10 @@ const useHideableBottom = ( }) }, [hideableBottomRef, largeHeaderHeight, originalDefaultHeaderHeight]) - const { scrollY } = useContext(NavigationContext)! - useEffect(() => { - const id = scrollY.addListener(({ value }) => { + const { reAnimatedScrollY } = useContext(ScreenItemContext)! + useAnimatedReaction( + () => reAnimatedScrollY.value, + (value) => { if (!enable) { return } @@ -80,12 +88,8 @@ const useHideableBottom = ( largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight) } lastScrollY.current = value - }) - - return () => { - scrollY.removeListener(id) - } - }, [enable, largeHeaderHeight, originalDefaultHeaderHeight, scrollY]) + }, + ) const layoutHeightOnceRef = useRef(false) const onLayout = useCallback( @@ -115,10 +119,26 @@ const useHideableBottom = ( onLayout, } } -interface NavigationHeaderProps +export interface InternalNavigationHeaderProps extends Omit, "children">, - NavigationHeaderRawProps, - PropsWithChildren {} + PropsWithChildren { + headerLeft?: + | FC<{ + canGoBack: boolean + }> + | ReactNode + headerRight?: + | FC<{ + canGoBack: boolean + }> + | ReactNode + title?: string + + hideableBottom?: ReactNode + hideableBottomHeight?: number + headerTitleAbsolute?: boolean + headerTitle?: FC> | ReactNode +} const blurThreshold = 0 const titlebarPaddingHorizontal = 8 @@ -130,36 +150,41 @@ export const InternalNavigationHeader = ({ headerRight, title, headerTitle: customHeaderTitle, - modal = false, + hideableBottom, hideableBottomHeight, headerTitleAbsolute, ...rest -}: NavigationHeaderProps) => { +}: InternalNavigationHeaderProps) => { const insets = useSafeAreaInsets() const frame = useSafeAreaFrame() + + const sheetModal = useScreenIsInSheetModal() const defaultHeight = useMemo( - () => getDefaultHeaderHeight(frame, modal, modal ? 0 : insets.top), - [frame, insets.top, modal], + () => getDefaultHeaderHeight(frame, sheetModal, sheetModal ? 0 : insets.top), + [frame, insets.top, sheetModal], ) const border = useColor("opaqueSeparator") const opacityAnimated = useSharedValue(0) - const { scrollY } = useContext(NavigationContext)! + const { reAnimatedScrollY } = useContext(ScreenItemContext)! const setHeaderHeight = useContext(SetNavigationHeaderHeightContext) - useEffect(() => { - const handler = ({ value }: { value: number }) => { + useAnimatedReaction( + () => reAnimatedScrollY.value, + (value) => { opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10)) - } - const id = scrollY.addListener(handler) + }, + ) - handler({ value: (scrollY as any)._value }) - return () => { - scrollY.removeListener(id) - } - }, [opacityAnimated, scrollY]) + const canBack = useCanBack() + const canDismiss = useCanDismiss() + + useEffect(() => { + const { value } = reAnimatedScrollY + opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10)) + }, [opacityAnimated, reAnimatedScrollY]) const blurStyle = useAnimatedStyle(() => ({ opacity: opacityAnimated.value, @@ -175,7 +200,7 @@ export const InternalNavigationHeader = ({ ) const rootTitleBarStyle = useAnimatedStyle(() => { const styles = { - paddingTop: modal ? 0 : insets.top, + paddingTop: sheetModal ? 0 : insets.top, position: "relative", overflow: "hidden", } satisfies DefaultStyle @@ -185,24 +210,16 @@ export const InternalNavigationHeader = ({ return styles }) - const navigation = useNavigation() - const canBack = navigation.canGoBack() - useEffect(() => { - if (title) { - navigation.setOptions({ title }) - } - }, [navigation, title]) - const HeaderLeft = headerLeft ?? DefaultHeaderBackButton - const renderTitle = customHeaderTitle ?? HeaderTitle + const renderTitle = customHeaderTitle ?? FakeNativeHeaderTitle const headerTitle = typeof renderTitle !== "function" ? renderTitle : createElement(renderTitle, { children: title, }) - const RightButton = headerRight ?? Noop + const RightButton = headerRight ?? (Noop as FC) const animatedRef = useAnimatedRef() useEffect(() => { @@ -235,22 +252,23 @@ export const InternalNavigationHeader = ({ style={{ marginLeft: insets.left, marginRight: insets.right, - height: !modal ? defaultHeight - insets.top : defaultHeight, + height: !sheetModal ? defaultHeight - insets.top : defaultHeight, paddingHorizontal: titlebarPaddingHorizontal, }} pointerEvents={"box-none"} > {/* Left */} - - + + {typeof HeaderLeft === "function" ? ( + + ) : ( + HeaderLeft + )} {/* Center */} - + {typeof RightButton === "function" ? ( + + ) : ( + RightButton + )} { +export const DefaultHeaderBackButton = ({ canGoBack, canDismiss }: NavigationHeaderButtonProps) => { const label = useColor("label") - if (!canGoBack) return null + const navigation = useNavigation() + if (!canGoBack && !canDismiss) return null return ( - router.back()}> - + { + if (canGoBack) { + navigation.back() + } else if (canDismiss) { + navigation.dismiss() + } + }} + > + {canGoBack ? ( + + ) : ( + + )} ) } @@ -316,24 +351,3 @@ export const UINavigationHeaderActionButton = ({ ) } const Noop = () => null - -/** - * NativeNavigationHeader wrapped react navigation native-stack is universal in modal and stack, but there are significant limitations in UI customization. - */ -export interface NativeNavigationHeaderProps - extends Pick {} -export const NativeNavigationHeader: FC = (props) => { - const navigation = useNavigation() - return ( - , - headerTransparent: true, - headerBlurEffect: "systemChromeMaterial", - ...props, - }} - /> - ) -} diff --git a/apps/mobile/src/components/layouts/tabbar/BottomTabProvider.tsx b/apps/mobile/src/components/layouts/tabbar/BottomTabProvider.tsx new file mode 100644 index 000000000..484c710cb --- /dev/null +++ b/apps/mobile/src/components/layouts/tabbar/BottomTabProvider.tsx @@ -0,0 +1,24 @@ +import { useMemo, useState } from "react" +import { useSharedValue } from "react-native-reanimated" + +import { BottomTabHeightProvider } from "./BottomTabHeightProvider" +import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext" +import { + BottomTabBarVisibleContext, + SetBottomTabBarVisibleContext, +} from "./contexts/BottomTabBarVisibleContext" + +export const BottomTabProvider = ({ children }: { children: React.ReactNode }) => { + const opacity = useSharedValue(1) + const [tabBarVisible, setTabBarVisible] = useState(true) + + return ( + ({ opacity }), [opacity])}> + + + {children} + + + + ) +} diff --git a/apps/mobile/src/components/layouts/tabbar/BottomTabs.tsx b/apps/mobile/src/components/layouts/tabbar/BottomTabs.tsx index df224d867..167072c65 100644 --- a/apps/mobile/src/components/layouts/tabbar/BottomTabs.tsx +++ b/apps/mobile/src/components/layouts/tabbar/BottomTabs.tsx @@ -1,77 +1,25 @@ -import type { BottomTabBarProps } from "@react-navigation/bottom-tabs" -import type { ForwardRefExoticComponent } from "react" -import { forwardRef, useMemo, useRef, useState } from "react" -import type { ScrollView } from "react-native" -import { useSharedValue } from "react-native-reanimated" +import { useContext, useRef } from "react" -import { BottomTabHeightProvider } from "./BottomTabHeightProvider" -import { - AttachNavigationScrollViewContext, - SetAttachNavigationScrollViewContext, -} from "./contexts/AttachNavigationScrollViewContext" import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext" -import { - BottomTabBarVisibleContext, - SetBottomTabBarVisibleContext, -} from "./contexts/BottomTabBarVisibleContext" import { useNavigationScrollToTop } from "./hooks" import { Tabbar } from "./Tabbar" -import { Tabs } from "./TabClient" -type ExtractReactForwardRefExoticComponent = - T extends React.ForwardRefExoticComponent ? P : never - -export const BottomTabs: ForwardRefExoticComponent< - Omit, "tabBar"> -> = forwardRef((props, ref) => { - const opacity = useSharedValue(1) - const [tabBarVisible, setTabBarVisible] = useState(true) - const [attachNavigationScrollViewRef, setAttachNavigationScrollViewRef] = - useState | null>(null) - - const currentTarget = useRef(undefined) - const scrollToTop = useNavigationScrollToTop(attachNavigationScrollViewRef) +export const BottomTabs = () => { + const currentIndex = useRef(undefined) + const scrollToTop = useNavigationScrollToTop() + const { opacity } = useContext(BottomTabBarBackgroundContext) return ( - - - ({ opacity }), [opacity])}> - - - - { - if (props.screenListeners && "tabPress" in props.screenListeners) { - props.screenListeners.tabPress!(e) - } + { + opacity.value = 1 - if (currentTarget.current === e.target) { - scrollToTop() - return - } + if (currentIndex.current === index) { + scrollToTop() + return + } - opacity.value = 1 - currentTarget.current = e.target - }, - transitionStart: (e) => { - if (props.screenListeners && "transitionStart" in props.screenListeners) { - props.screenListeners.transitionStart!(e) - } - currentTarget.current = e.target - opacity.value = 1 - }, - }} - ref={ref} - /> - - - - - - + currentIndex.current = index + }} + /> ) -}) -const TabBar = (props: BottomTabBarProps) => +} diff --git a/apps/mobile/src/components/layouts/tabbar/TabClient.tsx b/apps/mobile/src/components/layouts/tabbar/TabClient.tsx deleted file mode 100644 index 6fbceb702..000000000 --- a/apps/mobile/src/components/layouts/tabbar/TabClient.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// @see https://github.com/expo/expo/blob/main/packages/expo-router/src/layouts/TabsClient.tsx -import type { - BottomTabNavigationEventMap, - BottomTabNavigationOptions, -} from "@react-navigation/bottom-tabs" -import { createBottomTabNavigator } from "@react-navigation/bottom-tabs" -import type { ParamListBase, TabNavigationState } from "@react-navigation/native" -import type { Href } from "expo-router" -import { Link, withLayoutContext } from "expo-router" -import * as React from "react" - -// This is the only way to access the navigator. -const BottomTabNavigator = createBottomTabNavigator().Navigator - -type TabsProps = BottomTabNavigationOptions & { href?: Href | null } - -export const Tabs = withLayoutContext< - TabsProps, - typeof BottomTabNavigator, - TabNavigationState, - BottomTabNavigationEventMap ->(BottomTabNavigator, (screens) => { - // Support the `href` shortcut prop. - return screens.map((screen) => { - if (typeof screen.options !== "function" && screen.options?.href !== undefined) { - const { href, ...options } = screen.options - - return { - ...screen, - options: { - ...options, - tabBarItemStyle: options.tabBarItemStyle, - tabBarButton: (props) => { - if (href == null) { - return null - } - - // TODO: React Navigation types these props as Animated.WithAnimatedValue> - // While Link expects a TextStyle. We need to reconcile these types. - return ( - - ) - }, - }, - } - } - return screen - }) -}) diff --git a/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx b/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx index 1ff0598ee..b58772c55 100644 --- a/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx +++ b/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx @@ -1,8 +1,6 @@ -import type { BottomTabBarProps } from "@react-navigation/bottom-tabs" -import { getLabel } from "@react-navigation/elements" -import { CommonActions, NavigationContext, NavigationRouteContext } from "@react-navigation/native" +import { useAtom, useAtomValue } from "jotai" import type { FC } from "react" -import { Fragment, memo, useContext, useEffect } from "react" +import { memo, useContext, useEffect } from "react" import type { StyleProp, TextStyle } from "react-native" import { Platform, Pressable, StyleSheet, Text, View } from "react-native" import Animated, { @@ -15,6 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { SetBottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext" import { gentleSpringPreset, quickSpringPreset, softSpringPreset } from "@/src/constants/spring" +import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext" +import type { TabbarIconProps } from "@/src/lib/navigation/bottom-tab/types" import { PlayerTabBar } from "@/src/modules/player/PlayerTabBar" import { accentColor } from "@/src/theme/colors" @@ -23,15 +23,19 @@ import { Grid } from "../../ui/grid" import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext" import { BottomTabBarVisibleContext } from "./contexts/BottomTabBarVisibleContext" -export const Tabbar: FC = (props) => { - const { state, navigation, descriptors } = props - const { routes } = state +export const Tabbar: FC<{ + onPress?: (index: number) => void +}> = ({ onPress: onPressProp }) => { const setTabBarHeight = useContext(SetBottomTabBarHeightContext) const insets = useSafeAreaInsets() const tabBarVisible = useContext(BottomTabBarVisibleContext) + const tabContext = useContext(BottomTabContext) + const tabScreens = useAtomValue(tabContext.tabScreensAtom) + const [selectedIndex, setSelectedIndex] = useAtom(tabContext.currentIndexAtom) const translateY = useSharedValue(0) + useEffect(() => { cancelAnimation(translateY) translateY.value = withSpring( @@ -39,6 +43,7 @@ export const Tabbar: FC = (props) => { !tabBarVisible ? quickSpringPreset : softSpringPreset, ) }, [tabBarVisible, translateY]) + if (tabScreens.length === 0) return null return ( = (props) => { > - - {routes.map((route, index) => { - const focused = index === state.index - const { options } = descriptors[route.key]! + + {tabScreens.map((route, index) => { + const focused = index === selectedIndex const inactiveTintColor = "#999" const onPress = () => { - const event = navigation.emit({ - type: "tabPress", - target: route.key, - canPreventDefault: true, - }) - - if (!focused && !event.defaultPrevented) { - navigation.dispatch({ - ...CommonActions.navigate(route), - target: state.key, - }) - } + setSelectedIndex(index) + onPressProp?.(index) } - const tabButton = options?.tabBarButton - const label = - typeof options?.tabBarLabel === "function" - ? options?.tabBarLabel - : getLabel({ label: options?.tabBarLabel, title: options?.title }, route.name) + + const label = route.title const accessibilityLabel = - options?.tabBarAccessibilityLabel !== undefined - ? options?.tabBarAccessibilityLabel - : typeof label === "string" && Platform.OS === "ios" - ? `${label}, tab, ${index + 1} of ${routes.length}` - : undefined + typeof label === "string" && Platform.OS === "ios" + ? `${label}, tab, ${index + 1} of ${tabScreens.length}` + : undefined const renderIcon = ({ focused }: { focused: boolean }) => { const iconSize = ICON_SIZE_ROUND @@ -93,31 +82,12 @@ export const Tabbar: FC = (props) => { focused={focused} iconSize={iconSize} inactiveTintColor={inactiveTintColor} - renderIcon={options.tabBarIcon!} + renderIcon={route.renderIcon! || noop} /> ) } const renderLabel = ({ focused }: { focused: boolean }) => { - const color = focused ? accentColor : accentColor - - if (typeof label !== "string") { - const labelString = getLabel( - { - label: typeof options?.tabBarLabel === "string" ? options.tabBarLabel : undefined, - title: options?.title, - }, - route.name, - ) - - return label({ - focused, - color, - position: "beside-icon", - children: labelString, - }) - } - return ( = (props) => { ) } - const TabButton = tabButton ?? Fragment return ( - - - + ) })} @@ -178,7 +145,7 @@ const TabIcon = ({ focused: boolean iconSize: number inactiveTintColor: string - renderIcon: (options: { focused: boolean; size: number; color: string }) => React.ReactNode + renderIcon: (options: TabbarIconProps) => React.ReactNode }) => { const activeOpacity = focused ? 1 : 0 const inactiveOpacity = focused ? 0 : 1 @@ -260,7 +227,6 @@ const styles = StyleSheet.create({ }, }) -const AnimatedThemedBlurView = Animated.createAnimatedComponent(ThemedBlurView) const TabBarBackground = () => { const { opacity } = useContext(BottomTabBarBackgroundContext) @@ -273,7 +239,9 @@ const TabBarBackground = () => { })) return ( - + + + void originalRenderIcon: (scene: { route: any; focused: boolean }) => React.ReactNode originalRenderLabel: (scene: { route: any; focused: boolean }) => React.ReactNode @@ -316,26 +284,24 @@ const TabItem = memo( const scene = { route, focused } return ( - - - { - pressed.value = withSpring(1, gentleSpringPreset) - }} - onPressOut={() => { - pressed.value = withSpring(0, gentleSpringPreset) - }} - className="flex-1 flex-col items-center justify-center" - accessibilityLabel={accessibilityLabel} - accessibilityRole="button" - accessibilityState={{ selected: focused }} - > - {originalRenderIcon(scene)} - {originalRenderLabel(scene)} - - - + { + pressed.value = withSpring(1, gentleSpringPreset) + }} + onPressOut={() => { + pressed.value = withSpring(0, gentleSpringPreset) + }} + className="flex-1 flex-col items-center justify-center" + accessibilityLabel={accessibilityLabel} + accessibilityRole="button" + accessibilityState={{ selected: focused }} + > + {originalRenderIcon(scene)} + {originalRenderLabel(scene)} + ) }, ) + +const noop = () => null diff --git a/apps/mobile/src/components/layouts/tabbar/hooks.ts b/apps/mobile/src/components/layouts/tabbar/hooks.ts index 046826358..2eafaa968 100644 --- a/apps/mobile/src/components/layouts/tabbar/hooks.ts +++ b/apps/mobile/src/components/layouts/tabbar/hooks.ts @@ -1,15 +1,15 @@ -import { useNavigation } from "expo-router" import type { RefObject } from "react" import { useCallback, useContext, useEffect, useRef } from "react" import type { FlatList, ScrollView } from "react-native" import { findNodeHandle, Platform } from "react-native" import { performNativeScrollToTop } from "@/src/lib/native" - import { SetAttachNavigationScrollViewContext, useAttachNavigationScrollView, -} from "./contexts/AttachNavigationScrollViewContext" +} from "@/src/lib/navigation/AttachNavigationScrollViewContext" +import { useScreenIsAppeared } from "@/src/lib/navigation/bottom-tab/hooks" + import { BottomTabBarHeightContext } from "./contexts/BottomTabBarHeightContext" export const useBottomTabBarHeight = () => { @@ -55,17 +55,15 @@ export const useNavigationScrollToTop = ( export const useRegisterNavigationScrollView = (active = true) => { const scrollViewRef = useRef(null) - const navigation = useNavigation() + const tabScreenIsFocused = useScreenIsAppeared() const setAttachNavigationScrollViewRef = useContext(SetAttachNavigationScrollViewContext) + useEffect(() => { if (!active) return if (!setAttachNavigationScrollViewRef) return + if (!tabScreenIsFocused) return setAttachNavigationScrollViewRef(scrollViewRef as unknown as RefObject) - const unsubscribe = navigation.addListener("focus", () => { - setAttachNavigationScrollViewRef(scrollViewRef as unknown as RefObject) - }) - return unsubscribe - }, [setAttachNavigationScrollViewRef, scrollViewRef, active, navigation]) + }, [setAttachNavigationScrollViewRef, scrollViewRef, active, tabScreenIsFocused]) return scrollViewRef } diff --git a/apps/mobile/src/components/layouts/views/NavigationContext.tsx b/apps/mobile/src/components/layouts/views/NavigationContext.tsx deleted file mode 100644 index a4f29ead9..000000000 --- a/apps/mobile/src/components/layouts/views/NavigationContext.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from "react" -import type { Animated } from "react-native" - -export const NavigationContext = createContext<{ - scrollY: Animated.Value -} | null>(null) diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index ce4a03f4d..7770079ef 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -1,36 +1,33 @@ import { useTypeScriptHappyCallback } from "@follow/hooks" -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack" -import { Stack } from "expo-router" +import { useSetAtom, useStore } from "jotai" import type { FC, PropsWithChildren } from "react" -import { useContext, useEffect, useMemo, useRef, useState } from "react" -import type { - NativeScrollEvent, - NativeSyntheticEvent, - ScrollView, - ScrollViewProps, -} from "react-native" -import { Animated as RNAnimated, useAnimatedValue, View } from "react-native" +import { useContext, useEffect, useRef, useState } from "react" +import type { ScrollView, ScrollViewProps, StyleProp, ViewStyle } from "react-native" +import { View } from "react-native" import type { SharedValue } from "react-native-reanimated" +import { useAnimatedScrollHandler } from "react-native-reanimated" import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context" +import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" import { AttachNavigationScrollViewContext, SetAttachNavigationScrollViewContext, -} from "@/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext" -import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" +} from "@/src/lib/navigation/AttachNavigationScrollViewContext" +import { useScreenIsAppeared } from "@/src/lib/navigation/bottom-tab/hooks" +import { useScreenIsInSheetModal } from "@/src/lib/navigation/hooks" +import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext" -import { AnimatedScrollView } from "../../common/AnimatedComponents" +import { ReAnimatedScrollView } from "../../common/AnimatedComponents" +import type { InternalNavigationHeaderProps } from "../header/NavigationHeader" import { InternalNavigationHeader } from "../header/NavigationHeader" import { getDefaultHeaderHeight } from "../utils" -import { NavigationContext } from "./NavigationContext" import { NavigationHeaderHeightContext, SetNavigationHeaderHeightContext, } from "./NavigationHeaderContext" type SafeNavigationScrollViewProps = Omit & { - withHeaderBlur?: boolean onScroll?: (e: ReanimatedScrollEvent) => void // For scroll view content adjustment behavior @@ -39,70 +36,91 @@ type SafeNavigationScrollViewProps = Omit & { // to sharedValue reanimatedScrollY?: SharedValue + + contentViewStyle?: StyleProp + contentViewClassName?: string } & PropsWithChildren export const SafeNavigationScrollView: FC = ({ children, - withHeaderBlur = true, onScroll, withBottomInset = false, withTopInset = false, reanimatedScrollY, + contentViewClassName, + contentViewStyle, + ...props }) => { const insets = useSafeAreaInsets() const tabBarHeight = useBottomTabBarHeight() - const scrollY = useAnimatedValue(0) const scrollViewRef = useRef(null) const setAttachNavigationScrollViewRef = useContext(SetAttachNavigationScrollViewContext) + const isFocused = useScreenIsAppeared() useEffect(() => { - if (setAttachNavigationScrollViewRef) { + if (setAttachNavigationScrollViewRef && isFocused) { setAttachNavigationScrollViewRef(scrollViewRef) } - }, [setAttachNavigationScrollViewRef, scrollViewRef]) + }, [setAttachNavigationScrollViewRef, scrollViewRef, isFocused]) const frame = useSafeAreaFrame() + const sheetModal = useScreenIsInSheetModal() const [headerHeight, setHeaderHeight] = useState(() => - getDefaultHeaderHeight(frame, false, insets.top), + getDefaultHeaderHeight(frame, sheetModal, insets.top), ) + const screenCtxValue = useContext(ScreenItemContext) + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + if (reanimatedScrollY) { + reanimatedScrollY.value = event.contentOffset.y + } + + screenCtxValue.reAnimatedScrollY.value = event.contentOffset.y + }, + }) return ( - ({ scrollY }), [scrollY])}> - - - {withHeaderBlur && } - ) { - if (reanimatedScrollY) { - reanimatedScrollY.value = event.nativeEvent.contentOffset.y - } - }, - })} - automaticallyAdjustContentInsets={false} - automaticallyAdjustsScrollIndicatorInsets={false} - scrollIndicatorInsets={{ - top: headerHeight, - bottom: tabBarHeight, - }} - {...props} - > - - - {children} - - - - - - + + + { + screenCtxValue.scrollViewContentHeight.value = h + }, + [screenCtxValue.scrollViewContentHeight], + )} + onLayout={useTypeScriptHappyCallback( + (e) => { + screenCtxValue.scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight + }, + [screenCtxValue.scrollViewHeight, headerHeight], + )} + automaticallyAdjustContentInsets={false} + automaticallyAdjustsScrollIndicatorInsets={false} + scrollIndicatorInsets={{ + top: headerHeight, + bottom: tabBarHeight, + }} + {...props} + > + + + + {children} + + + + + + ) } @@ -111,51 +129,49 @@ export const NavigationBlurEffectHeader = ({ headerHideableBottomHeight, headerTitleAbsolute, ...props -}: NativeStackNavigationOptions & { +}: InternalNavigationHeaderProps & { blurThreshold?: number headerHideableBottomHeight?: number headerHideableBottom?: () => React.ReactNode headerTitleAbsolute?: boolean }) => { - const navigationContext = useContext(NavigationContext)! - const setHeaderHeight = useContext(SetNavigationHeaderHeightContext) const hideableBottom = headerHideableBottom?.() - const { headerLeft, ...rest } = props + const screenCtxValue = useContext(ScreenItemContext) - return ( - { + setSlot({ + ...store.get(screenCtxValue.Slot), + header: ( + + + + ), + }) + }, [ + screenCtxValue.Slot, + headerHideableBottomHeight, + headerTitleAbsolute, + hideableBottom, + props.headerLeft, + props.headerRight, + props.title, + setHeaderHeight, + setSlot, + store, + props.headerTitle, + ]) - headerShown: true, - headerLeft, - - header: useTypeScriptHappyCallback( - ({ options }) => ( - - - - - - ), - [hideableBottom, navigationContext], - ), - - ...rest, - }} - /> - ) + return null } diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index 442f4369e..626199613 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -1,4 +1,5 @@ import { clsx } from "@follow/utils" +import { Portal } from "@gorhom/portal" import { requireNativeView } from "expo" import { useAtom } from "jotai" import * as React from "react" @@ -10,7 +11,6 @@ import { useUISettingKey } from "@/src/atoms/settings/ui" import { BugCuteReIcon } from "@/src/icons/bug_cute_re" import type { EntryModel } from "@/src/store/entry/types" -import { Portal } from "../../ui/portal" import { sharedWebViewHeightAtom } from "./atom" import { htmlUrl } from "./constants" import { prepareEntryRenderWebView, SharedWebViewModule } from "./index" diff --git a/apps/mobile/src/components/ui/form/PickerIos.tsx b/apps/mobile/src/components/ui/form/PickerIos.tsx index 8f7ff4da1..bf8cbe5ea 100644 --- a/apps/mobile/src/components/ui/form/PickerIos.tsx +++ b/apps/mobile/src/components/ui/form/PickerIos.tsx @@ -1,5 +1,6 @@ /* eslint-disable @eslint-react/no-array-index-key */ import { cn } from "@follow/utils" +import { Portal } from "@gorhom/portal" import { Picker } from "@react-native-picker/picker" import { useMemo, useState } from "react" import type { StyleProp, ViewStyle } from "react-native" @@ -11,7 +12,6 @@ import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" import { useColor } from "@/src/theme/colors" import { BlurEffect } from "../../common/BlurEffect" -import { Portal } from "../portal" interface PickerIosProps { options: { label: string; value: T }[] diff --git a/apps/mobile/src/components/ui/portal/Portal.tsx b/apps/mobile/src/components/ui/portal/Portal.tsx deleted file mode 100644 index e82525081..000000000 --- a/apps/mobile/src/components/ui/portal/Portal.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from "react" -import { useEffect, useMemo, useRef } from "react" - -import PortalContext from "./PortalContext" -import type { IPortalMethods, PortalConsumerProps } from "./type" - -const PortalConsumer: React.FC = (props) => { - const id = React.useId() - const key = useRef(id) - const once = useRef(false) - - useEffect( - () => () => { - const { manager } = props - manager?.unmount?.(key.current) - }, - - [], - ) - - useEffect(() => { - const { manager, children } = props - if (!once.current) { - once.current = true - key.current = manager?.mount?.(children) ?? "" - } else { - manager?.update?.(key.current, children) - } - }, [props]) - - return null -} - -export function Portal({ children }: React.PropsWithChildren) { - return useMemo( - () => ( - - {(manager: IPortalMethods | null): React.ReactNode => ( - {children} - )} - - ), - [children], - ) -} - -export { PortalConsumerProps, PortalProps } from "./type" diff --git a/apps/mobile/src/components/ui/portal/PortalContext.tsx b/apps/mobile/src/components/ui/portal/PortalContext.tsx deleted file mode 100644 index 1a7b607e6..000000000 --- a/apps/mobile/src/components/ui/portal/PortalContext.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from "react" - -import type { IPortalMethods } from "./type" - -export default React.createContext(null) diff --git a/apps/mobile/src/components/ui/portal/PortalHost.tsx b/apps/mobile/src/components/ui/portal/PortalHost.tsx deleted file mode 100644 index 40a38b87c..000000000 --- a/apps/mobile/src/components/ui/portal/PortalHost.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from "react" -import { useCallback, useMemo, useRef } from "react" - -import PortalContext from "./PortalContext" -import PortalManager from "./PortalManager" -import type { IPortalManager, Operation, PortalKey } from "./type" - -interface PortalHostProps { - children: React.ReactNode -} - -export function PortalHost(props: PortalHostProps) { - const id = React.useId() - const nextKey = useRef(0) - const queue = useRef([]) - const manager = useRef() - - const mount = useCallback((children: React.ReactNode, _key?: PortalKey) => { - let key = _key - if (!key) { - nextKey.current++ - key = `${id}-${nextKey.current}` - } - - if (manager.current) { - manager.current.mount(key, children) - } else { - queue.current.push({ type: "mount", key, children }) - } - - return key - }, []) - - const update = useCallback((key: PortalKey, children: React.ReactNode) => { - if (manager.current) { - manager.current.update(key, children) - } else { - const op: Operation = { type: "mount", key, children } - const index = queue.current.findIndex( - (o) => o.type === "mount" || (o.type === "update" && o.key === key), - ) - - if (index !== -1) { - queue.current[index] = op - } else { - queue.current.push(op) - } - } - }, []) - - const unmount = useCallback((key: PortalKey) => { - if (manager.current) { - manager.current.unmount(key) - } else { - queue.current.push({ type: "unmount", key }) - } - }, []) - - const { children } = props - - const ctxValue = useMemo( - () => ({ - mount, - update, - unmount, - }), - [mount, update, unmount], - ) - - return useMemo( - () => ( - <> - - <> - {/* placeholder */} - {children} - {/* portal container */} - - - - - ), - [ctxValue, children], - ) -} - -PortalHost.displayName = "Portal.host" diff --git a/apps/mobile/src/components/ui/portal/PortalManager.tsx b/apps/mobile/src/components/ui/portal/PortalManager.tsx deleted file mode 100644 index bfd6cc40b..000000000 --- a/apps/mobile/src/components/ui/portal/PortalManager.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react" -import { useCallback, useImperativeHandle, useMemo, useState } from "react" -import { StyleSheet, View } from "react-native" - -export default React.forwardRef((_, ref: any) => { - const [portals, setPortals] = useState<{ key: number; children: React.ReactNode }[]>([]) - - const mount = useCallback((key: number, children: React.ReactNode) => { - setPortals((oldPortals) => [...oldPortals, { key, children }]) - }, []) - - const update = useCallback((key: number, children: React.ReactNode) => { - setPortals((oldPortals) => - oldPortals.map((item) => { - if (item.key === key) { - return { ...item, children } - } - return { ...item } - }), - ) - }, []) - - const unmount = useCallback( - (key: number) => setPortals((oldPortals) => oldPortals.filter((item) => item.key !== key)), - [], - ) - - useImperativeHandle(ref, () => ({ - mount, - update, - unmount, - })) - - return useMemo( - () => ( - <> - {portals.map(({ key, children }) => ( - - {children} - - ))} - - ), - [portals], - ) -}) diff --git a/apps/mobile/src/components/ui/portal/index.tsx b/apps/mobile/src/components/ui/portal/index.tsx deleted file mode 100644 index d2c1dd9de..000000000 --- a/apps/mobile/src/components/ui/portal/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Portal } from "./Portal" -export { PortalHost } from "./PortalHost" diff --git a/apps/mobile/src/components/ui/portal/type.ts b/apps/mobile/src/components/ui/portal/type.ts deleted file mode 100644 index 1ebc2963a..000000000 --- a/apps/mobile/src/components/ui/portal/type.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type * as React from "react" - -export type PortalKey = string -export interface IPortalManager { - mount: (key: PortalKey, children: React.ReactNode) => void - update: (key: PortalKey, children: React.ReactNode) => void - unmount: (key: PortalKey) => void -} - -export interface IPortalMethods { - mount: (children: React.ReactNode) => PortalKey - update: (key: PortalKey, children: React.ReactNode) => void - unmount: (key: PortalKey) => void -} - -export type Operation = - | { - type: "mount" - key: PortalKey - children: React.ReactNode - } - | { - type: "update" - key: PortalKey - children: React.ReactNode - } - | { - type: "unmount" - key: PortalKey - } - -export interface IStaticContainerProps { - shouldUpdate: boolean - children: React.ReactNode -} - -export interface PortalProps { - children?: React.ReactNode -} - -export interface PortalConsumerProps { - manager: IPortalMethods | null - children: React.ReactNode -} diff --git a/apps/mobile/src/hooks/useIntentHandler.ts b/apps/mobile/src/hooks/useIntentHandler.ts index 6eea5df33..b4d482c1c 100644 --- a/apps/mobile/src/hooks/useIntentHandler.ts +++ b/apps/mobile/src/hooks/useIntentHandler.ts @@ -1,7 +1,9 @@ import * as Linking from "expo-linking" -import { router } from "expo-router" import { useEffect } from "react" +import { useNavigation } from "../lib/navigation/hooks" +import { FollowScreen } from "../screens/(modal)/follow" + // This needs to stay outside of react to persist between account switches let previousIntentUrl = "" export const resetIntentUrl = () => { @@ -11,6 +13,8 @@ export const resetIntentUrl = () => { export function useIntentHandler() { const incomingUrl = Linking.useURL() + const navigation = useNavigation() + useEffect(() => { if (incomingUrl && incomingUrl !== previousIntentUrl) { previousIntentUrl = incomingUrl @@ -21,9 +25,13 @@ export function useIntentHandler() { return } - router.push(`/follow?${searchParams.toString()}`) + navigation.presentControllerView(FollowScreen, { + id: searchParams.get("id") ?? undefined, + type: (searchParams.get("type") as "url" | "feed" | "list") ?? undefined, + url: searchParams.get("url") ?? undefined, + }) } - }, [incomingUrl]) + }, [incomingUrl, navigation]) } // follow://add?id=41147805276726272 diff --git a/apps/mobile/src/hooks/useIsRouteOnlyOne.ts b/apps/mobile/src/hooks/useIsRouteOnlyOne.ts deleted file mode 100644 index a6d496e6e..000000000 --- a/apps/mobile/src/hooks/useIsRouteOnlyOne.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useNavigation } from "expo-router" - -export const useIsRouteOnlyOne = () => { - const navigation = useNavigation() - const state = navigation.getState() - - const routeOnlyOne = state?.routes.length === 1 - - return routeOnlyOne -} diff --git a/apps/mobile/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext.tsx b/apps/mobile/src/lib/navigation/AttachNavigationScrollViewContext.tsx similarity index 100% rename from apps/mobile/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext.tsx rename to apps/mobile/src/lib/navigation/AttachNavigationScrollViewContext.tsx diff --git a/apps/mobile/src/lib/navigation/ChainNavigationContext.tsx b/apps/mobile/src/lib/navigation/ChainNavigationContext.tsx new file mode 100644 index 000000000..7fba00327 --- /dev/null +++ b/apps/mobile/src/lib/navigation/ChainNavigationContext.tsx @@ -0,0 +1,23 @@ +import type { PrimitiveAtom } from "jotai" +import { createContext } from "react" + +import type { + NavigationControllerView, + NavigationControllerViewExtraProps, + NavigationControllerViewType, +} from "./types" + +export interface Route { + id: string + + Component?: NavigationControllerView + element?: React.ReactElement + + type: NavigationControllerViewType + props?: unknown + screenOptions?: NavigationControllerViewExtraProps +} +export type ChainNavigationContextType = { + routesAtom: PrimitiveAtom +} +export const ChainNavigationContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/GroupedNavigationRouteContext.ts b/apps/mobile/src/lib/navigation/GroupedNavigationRouteContext.ts new file mode 100644 index 000000000..02853d8b4 --- /dev/null +++ b/apps/mobile/src/lib/navigation/GroupedNavigationRouteContext.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { Route } from "./ChainNavigationContext" + +export const GroupedNavigationRouteContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/Navigation.ts b/apps/mobile/src/lib/navigation/Navigation.ts new file mode 100644 index 000000000..2d61789b9 --- /dev/null +++ b/apps/mobile/src/lib/navigation/Navigation.ts @@ -0,0 +1,180 @@ +import { jotaiStore } from "@follow/utils" +import { EventEmitter } from "expo" +import { atom } from "jotai" + +import type { ChainNavigationContextType, Route } from "./ChainNavigationContext" +import type { + NavigationControllerView, + NavigationControllerViewExtraProps, + NavigationControllerViewType, +} from "./types" + +export class Navigation { + private ctxValue: ChainNavigationContextType + constructor(ctxValue: ChainNavigationContextType) { + this.ctxValue = ctxValue + + this.pushControllerView = this.pushControllerView.bind(this) + this.presentControllerView = this.presentControllerView.bind(this) + this.dismiss = this.dismiss.bind(this) + this.back = this.back.bind(this) + this.popTo = this.popTo.bind(this) + this.popToRoot = this.popToRoot.bind(this) + } + + __internal_getCtxValue() { + return this.ctxValue + } + + private viewIdCounter = 0 + + static readonly rootNavigation: Navigation = new Navigation({ + routesAtom: atom([]), + }) + + private __push(route: Route) { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + const hasRoute = routes.some((r) => r.id === route.id) + if (hasRoute && routes.at(-1)?.id === route.id) { + console.warn(`Top of stack is already ${route.id}`) + return + } else if (hasRoute) { + route.id = `${route.id}-${this.viewIdCounter++}` + } + jotaiStore.set(this.ctxValue.routesAtom, [...routes, route]) + } + + private resolveScreenOptions( + view: NavigationControllerView, + ): Required { + return { + transparent: view.transparent ?? false, + id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`, + title: view.title ?? "", + // Form Sheet + sheetAllowedDetents: view.sheetAllowedDetents ?? "fitToContents", + sheetCornerRadius: view.sheetCornerRadius ?? 16, + sheetExpandsWhenScrolledToEdge: view.sheetExpandsWhenScrolledToEdge ?? true, + sheetElevation: view.sheetElevation ?? 24, + sheetGrabberVisible: view.sheetGrabberVisible ?? true, + sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0, + sheetLargestUndimmedDetentIndex: view.sheetLargestUndimmedDetentIndex ?? "medium", + } + } + + pushControllerView(view: NavigationControllerView, props?: T) { + const screenOptions = this.resolveScreenOptions(view) + this.__push({ + id: screenOptions.id, + type: "push", + Component: view, + props, + screenOptions, + }) + } + + presentControllerView( + view: NavigationControllerView, + props?: T, + type: Exclude = "modal", + ) { + const screenOptions = this.resolveScreenOptions(view) + this.__push({ + id: screenOptions.id, + type, + Component: view, + props, + screenOptions, + }) + } + private __pop() { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + const lastRoute = routes.at(-1) + if (!lastRoute) { + return + } + jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1)) + } + + /** + * Dismiss the current modal. + */ + dismiss() { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + const lastModalIndex = routes.findLastIndex((r) => r.type !== "push") + if (lastModalIndex === -1) { + return + } + jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex)) + } + + back() { + return this.__pop() + } + + __internal_dismiss(routeId: string) { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + const lastModalIndex = routes.findLastIndex((r) => r.id === routeId) + if (lastModalIndex === -1) { + return + } + jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex)) + } + + // eslint-disable-next-line unicorn/prefer-event-target + private bus = new EventEmitter<{ + willAppear: (payload: LifecycleEventPayload) => void + didAppear: (payload: LifecycleEventPayload) => void + willDisappear: (payload: LifecycleEventPayload) => void + didDisappear: (payload: LifecycleEventPayload) => void + }>() + on(event: "willAppear", callback: (payload: LifecycleEventPayload) => void): Disposer + on(event: "didAppear", callback: (payload: LifecycleEventPayload) => void): Disposer + on(event: "willDisappear", callback: (payload: LifecycleEventPayload) => void): Disposer + on(event: "didDisappear", callback: (payload: LifecycleEventPayload) => void): Disposer + on(event: "screenChange", callback: (payload: ScreenChangeEventPayload) => void): Disposer + on(event: string, callback: (payload: any) => void): Disposer { + const subscription = this.bus.addListener(event as any, callback) + return () => { + subscription.remove() + } + } + + emit(event: "willAppear", payload: LifecycleEventPayload): void + emit(event: "didAppear", payload: LifecycleEventPayload): void + emit(event: "willDisappear", payload: LifecycleEventPayload): void + emit(event: "didDisappear", payload: LifecycleEventPayload): void + emit(event: "screenChange", payload: ScreenChangeEventPayload): void + emit(event: string, payload: LifecycleEventPayload): void { + this.bus.emit(event as any, payload) + } + + canGoBack() { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + return routes.length > 0 + } + + popTo(routeId: string) { + const routes = jotaiStore.get(this.ctxValue.routesAtom) + const index = routes.findIndex((r) => r.id === routeId) + if (index === -1) { + return + } + jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, index + 1)) + } + + popToRoot() { + jotaiStore.set(this.ctxValue.routesAtom, []) + } +} + +type Disposer = () => void + +type LifecycleEventPayload = { + screenId: string +} + +type ScreenChangeEventPayload = { + screenId: string + type: "appear" | "disappear" +} diff --git a/apps/mobile/src/lib/navigation/NavigationInstanceContext.ts b/apps/mobile/src/lib/navigation/NavigationInstanceContext.ts new file mode 100644 index 000000000..37be8e161 --- /dev/null +++ b/apps/mobile/src/lib/navigation/NavigationInstanceContext.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { Navigation } from "./Navigation" + +export const NavigationInstanceContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/NavigationLink.tsx b/apps/mobile/src/lib/navigation/NavigationLink.tsx new file mode 100644 index 000000000..b77f7ef01 --- /dev/null +++ b/apps/mobile/src/lib/navigation/NavigationLink.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react" +import type { TextProps } from "react-native" +import { Text } from "react-native" +import type { StackPresentationTypes } from "react-native-screens" + +import { useNavigation } from "./hooks" +import type { NavigationControllerView } from "./types" + +interface NavigationLinkProps extends TextProps { + destination: NavigationControllerView + stackPresentation?: StackPresentationTypes + props?: T +} +function NavigationLinkImpl( + { destination, children, stackPresentation = "push", props, ...rest }: NavigationLinkProps, + ref: React.Ref, +) { + const navigation = useNavigation() + + return ( + { + if (stackPresentation === "push") { + navigation.pushControllerView(destination, props) + } else { + navigation.presentControllerView(destination, props, stackPresentation) + } + }} + {...rest} + ref={ref} + > + {children} + + ) +} + +export const NavigationLink = forwardRef(NavigationLinkImpl) as ( + props: NavigationLinkProps & { ref?: React.Ref }, +) => ReturnType diff --git a/apps/mobile/src/lib/navigation/ScreenItemContext.ts b/apps/mobile/src/lib/navigation/ScreenItemContext.ts new file mode 100644 index 000000000..c535d97cc --- /dev/null +++ b/apps/mobile/src/lib/navigation/ScreenItemContext.ts @@ -0,0 +1,22 @@ +import type { PrimitiveAtom } from "jotai" +import type { ReactNode } from "react" +import { createContext } from "react" +import type { SharedValue } from "react-native-reanimated" + +export interface ScreenItemContextType { + screenId: string + + isFocusedAtom: PrimitiveAtom + isAppearedAtom: PrimitiveAtom + isDisappearedAtom: PrimitiveAtom + + // For Layout ScrollView + reAnimatedScrollY: SharedValue + scrollViewHeight: SharedValue + scrollViewContentHeight: SharedValue + + Slot: PrimitiveAtom<{ + header: ReactNode + }> +} +export const ScreenItemContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/ScreenNameContext.tsx b/apps/mobile/src/lib/navigation/ScreenNameContext.tsx new file mode 100644 index 000000000..52d8c3fb1 --- /dev/null +++ b/apps/mobile/src/lib/navigation/ScreenNameContext.tsx @@ -0,0 +1,13 @@ +import type { PrimitiveAtom } from "jotai" +import { useAtomValue } from "jotai" +import { createContext, useContext } from "react" + +export const ScreenNameContext = createContext>(null!) + +export const useScreenName = () => { + const name = useContext(ScreenNameContext) + if (!name) { + throw new Error("ScreenNameContext not mounted") + } + return useAtomValue(name) +} diff --git a/apps/mobile/src/lib/navigation/ScreenOptionsContext.ts b/apps/mobile/src/lib/navigation/ScreenOptionsContext.ts new file mode 100644 index 000000000..83cc5d5d2 --- /dev/null +++ b/apps/mobile/src/lib/navigation/ScreenOptionsContext.ts @@ -0,0 +1,46 @@ +import type { PrimitiveAtom } from "jotai" +import { useStore } from "jotai" +import { createContext, useCallback, useContext } from "react" + +export interface ScreenOptionsContextType { + gestureEnabled?: boolean + preventNativeDismiss?: boolean +} +export const ScreenOptionsContext = createContext>(null!) + +export const useSetScreenOptions = () => { + const ctx = useContext(ScreenOptionsContext) + if (!ctx) { + throw new Error("ScreenOptionsContext not found") + } + + const store = useStore() + return useCallback( + (options: ScreenOptionsContextType) => { + const prev = store.get(ctx) + store.set(ctx, { ...prev, ...options }) + }, + [store, ctx], + ) +} + +export const ModalScreenItemOptionsContext = createContext>( + null!, +) + +export const useSetModalScreenOptions = () => { + const ctx = useContext(ModalScreenItemOptionsContext) + + const store = useStore() + return useCallback( + (options: ScreenOptionsContextType) => { + if (!ctx) { + return + } + + const prev = store.get(ctx) + store.set(ctx, { ...prev, ...options }) + }, + [store, ctx], + ) +} diff --git a/apps/mobile/src/lib/navigation/StackNavigation.tsx b/apps/mobile/src/lib/navigation/StackNavigation.tsx new file mode 100644 index 000000000..fa9472f36 --- /dev/null +++ b/apps/mobile/src/lib/navigation/StackNavigation.tsx @@ -0,0 +1,201 @@ +import type { PrimitiveAtom } from "jotai" +import { atom, useAtomValue } from "jotai" +import type { FC } from "react" +import { memo, useContext, useMemo, useState } from "react" +import type { ScrollView } from "react-native" +import { StyleSheet } from "react-native" +import { SafeAreaProvider } from "react-native-safe-area-context" +import type { ScreenStackHeaderConfigProps } from "react-native-screens" +import { ScreenStack } from "react-native-screens" + +import { + AttachNavigationScrollViewContext, + SetAttachNavigationScrollViewContext, +} from "./AttachNavigationScrollViewContext" +import type { Route } from "./ChainNavigationContext" +import { ChainNavigationContext } from "./ChainNavigationContext" +import { GroupedNavigationRouteContext } from "./GroupedNavigationRouteContext" +import { Navigation } from "./Navigation" +import { NavigationInstanceContext } from "./NavigationInstanceContext" +import { ScreenNameContext } from "./ScreenNameContext" +import type { ScreenOptionsContextType } from "./ScreenOptionsContext" +import { ModalScreenItemOptionsContext } from "./ScreenOptionsContext" +import type { NavigationControllerView } from "./types" +import { WrappedScreenItem } from "./WrappedScreenItem" + +interface RootStackNavigationProps { + children: React.ReactNode + + headerConfig?: ScreenStackHeaderConfigProps +} +export const RootStackNavigation = ({ children, headerConfig }: RootStackNavigationProps) => { + const [attachNavigationScrollViewRef, setAttachNavigationScrollViewRef] = + useState | null>(null) + + return ( + + + + atom(""), [])}> + + + + + {children} + + + + + + + + + + + ) +} + +const ScreenItemsMapper = () => { + const chainCtxValue = useContext(ChainNavigationContext) + const routes = useAtomValue(chainCtxValue.routesAtom) + + const routeGroups = useMemo(() => { + const groups: Route[][] = [] + let currentGroup: Route[] = [] + + routes.forEach((route, index) => { + // Start a new group if this is the first route or if it's a modal (non-push) + if (index === 0 || route.type !== "push") { + // Save the previous group if it's not empty + if (currentGroup.length > 0) { + groups.push(currentGroup) + } + // Start a new group with this route + currentGroup = [route] + } else { + // Add to the current group if it's a push route + currentGroup.push(route) + } + }) + + // Add the last group if it's not empty + if (currentGroup.length > 0) { + groups.push(currentGroup) + } + + return groups + }, [routes]) + + return ( + + {routeGroups.map((group) => { + const isPushGroup = group.at(0)?.type === "push" + if (!isPushGroup) { + return + } + return + })} + + ) +} + +const MapScreenStackItems: FC<{ + routes: Route[] +}> = memo(({ routes }) => { + return routes.map((route) => { + return ( + + + + ) + }) +}) + +const ModalScreenStackItems: FC<{ + routes: Route[] +}> = memo(({ routes }) => { + const rootModalRoute = routes.at(0) + const modalScreenOptionsCtxValue = useMemo>( + () => atom({}), + [], + ) + + const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue) + + if (!rootModalRoute) { + return null + } + const isStackModal = rootModalRoute.type !== "formSheet" + + if (isStackModal) { + return ( + + + + + + + {routes.slice(1).map((route) => { + return ( + + + + ) + })} + + + + ) + } + return routes.map((route) => { + return ( + + + + ) + }) +}) + +const ResolveView: FC<{ + comp?: NavigationControllerView + element?: React.ReactElement + props?: unknown +}> = ({ comp: Component, element, props }) => { + if (Component && typeof Component === "function") { + return + } + if (element) { + return element + } + throw new Error("No component or element provided") +} diff --git a/apps/mobile/src/lib/navigation/StackScreenHeaderPortal.tsx b/apps/mobile/src/lib/navigation/StackScreenHeaderPortal.tsx new file mode 100644 index 000000000..76850e4a7 --- /dev/null +++ b/apps/mobile/src/lib/navigation/StackScreenHeaderPortal.tsx @@ -0,0 +1,19 @@ +import { jotaiStore } from "@follow/utils" +import { useStore } from "jotai" +import type { FC, PropsWithChildren } from "react" +import { useContext, useEffect } from "react" + +import { ScreenItemContext } from "./ScreenItemContext" + +export const StackScreenHeaderPortal: FC = ({ children }) => { + const ctxValue = useContext(ScreenItemContext) + + const store = useStore() + useEffect(() => { + jotaiStore.set(ctxValue.Slot, { + ...store.get(ctxValue.Slot), + header: children, + }) + }, [ctxValue, children, store]) + return null +} diff --git a/apps/mobile/src/lib/navigation/WrappedScreenItem.tsx b/apps/mobile/src/lib/navigation/WrappedScreenItem.tsx new file mode 100644 index 000000000..ef458ad61 --- /dev/null +++ b/apps/mobile/src/lib/navigation/WrappedScreenItem.tsx @@ -0,0 +1,143 @@ +import type { PrimitiveAtom } from "jotai" +import { atom, useAtomValue, useSetAtom } from "jotai" +import type { FC, ReactNode } from "react" +import { memo, useContext, useMemo } from "react" +import { StyleSheet, View } from "react-native" +import { useSharedValue } from "react-native-reanimated" +import type { ScreenStackHeaderConfigProps, StackPresentationTypes } from "react-native-screens" +import { ScreenStackItem } from "react-native-screens" + +import { useNavigation } from "@/src/lib/navigation/hooks" +import { useColor } from "@/src/theme/colors" + +import { useCombinedLifecycleEvents } from "./__internal/hooks" +import { defaultHeaderConfig } from "./config" +import type { ScreenItemContextType } from "./ScreenItemContext" +import { ScreenItemContext } from "./ScreenItemContext" +import type { ScreenOptionsContextType } from "./ScreenOptionsContext" +import { ScreenOptionsContext } from "./ScreenOptionsContext" +import type { NavigationControllerViewExtraProps } from "./types" + +export const WrappedScreenItem: FC< + { + screenId: string + children: React.ReactNode + stackPresentation?: StackPresentationTypes + + headerConfig?: ScreenStackHeaderConfigProps + screenOptions?: NavigationControllerViewExtraProps + } & ScreenOptionsContextType +> = memo( + ({ + screenId, + children, + stackPresentation, + headerConfig, + screenOptions: screenOptionsProp, + ...rest + }) => { + const navigation = useNavigation() + const reAnimatedScrollY = useSharedValue(0) + const scrollViewHeight = useSharedValue(0) + const scrollViewContentHeight = useSharedValue(0) + const ctxValue = useMemo( + () => ({ + screenId, + isFocusedAtom: atom(false), + isAppearedAtom: atom(false), + isDisappearedAtom: atom(false), + reAnimatedScrollY, + Slot: atom<{ + header: ReactNode + }>({ + header: null, + }), + scrollViewHeight, + scrollViewContentHeight, + }), + [screenId, reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight], + ) + const setIsFocused = useSetAtom(ctxValue.isFocusedAtom) + const setIsAppeared = useSetAtom(ctxValue.isAppearedAtom) + const setIsDisappeared = useSetAtom(ctxValue.isDisappearedAtom) + + const combinedLifecycleEvents = useCombinedLifecycleEvents(ctxValue.screenId, { + onAppear: () => { + setIsFocused(true) + setIsAppeared(true) + setIsDisappeared(false) + }, + onDisappear: () => { + setIsFocused(false) + setIsAppeared(false) + setIsDisappeared(true) + }, + onWillAppear: () => { + setIsFocused(false) + setIsAppeared(true) + setIsDisappeared(false) + }, + onWillDisappear: () => { + setIsFocused(false) + setIsAppeared(false) + setIsDisappeared(true) + }, + }) + + const screenOptionsCtxValue = useMemo>( + () => atom({}), + [], + ) + + const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue) + + const backgroundColor = useColor("systemBackground") + + // Priority: Ctx > Define on Component + + const mergedScreenOptions = { + ...screenOptionsProp, + ...screenOptionsFromCtx, + } + return ( + + + { + navigation.__internal_dismiss(screenId) + }} + {...rest} + {...mergedScreenOptions} + > +
+ {children} + + + + ) + }, +) +const Header = () => { + const ctxValue = useContext(ScreenItemContext) + + const Slot = useAtomValue(ctxValue.Slot) + + if (!Slot.header) { + return null + } + return {Slot.header} +} + +WrappedScreenItem.displayName = "WrappedScreenItem" diff --git a/apps/mobile/src/lib/navigation/__internal/hooks.ts b/apps/mobile/src/lib/navigation/__internal/hooks.ts new file mode 100644 index 000000000..b3f68cc46 --- /dev/null +++ b/apps/mobile/src/lib/navigation/__internal/hooks.ts @@ -0,0 +1,55 @@ +import { useEffect, useMemo, useRef } from "react" + +import { useNavigation } from "../hooks" + +export const useCombinedLifecycleEvents = ( + screenId: string, + { + onAppear, + onDisappear, + onWillAppear, + onWillDisappear, + }: { + onAppear?: () => void + onDisappear?: () => void + onWillAppear?: () => void + onWillDisappear?: () => void + } = {}, +) => { + const navigation = useNavigation() + const stableRef = useRef({ + onAppear, + onDisappear, + onWillAppear, + onWillDisappear, + }) + + useEffect(() => { + stableRef.current = { + onAppear, + onDisappear, + onWillAppear, + onWillDisappear, + } + }, [onAppear, onDisappear, onWillAppear, onWillDisappear]) + return useMemo(() => { + return { + onAppear: () => { + navigation.emit("didAppear", { screenId }) + stableRef.current.onAppear?.() + }, + onDisappear: () => { + navigation.emit("didDisappear", { screenId }) + stableRef.current.onDisappear?.() + }, + onWillAppear: () => { + navigation.emit("willAppear", { screenId }) + stableRef.current.onWillAppear?.() + }, + onWillDisappear: () => { + navigation.emit("willDisappear", { screenId }) + stableRef.current.onWillDisappear?.() + }, + } + }, [navigation, screenId]) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/BottomTabContext.tsx b/apps/mobile/src/lib/navigation/bottom-tab/BottomTabContext.tsx new file mode 100644 index 000000000..0c0c74d20 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/BottomTabContext.tsx @@ -0,0 +1,14 @@ +import type { PrimitiveAtom } from "jotai" +import { createContext } from "react" + +import type { TabScreenProps } from "./TabScreen" + +export interface BottomTabContextType { + currentIndexAtom: PrimitiveAtom + + loadedableIndexAtom: PrimitiveAtom> + + tabScreensAtom: PrimitiveAtom + tabHeightAtom: PrimitiveAtom +} +export const BottomTabContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx b/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx new file mode 100644 index 000000000..7411b68cc --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx @@ -0,0 +1,41 @@ +import { useContext } from "react" +import { useAnimatedReaction } from "react-native-reanimated" + +import { BottomTabBarBackgroundContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarBackgroundContext" +import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" + +import { ScreenItemContext } from "../ScreenItemContext" + +// FIXME +export const CalculateTabBarOpacity = () => { + const { scrollViewContentHeight, scrollViewHeight, reAnimatedScrollY } = + useContext(ScreenItemContext) + const { opacity } = useContext(BottomTabBarBackgroundContext) + const tabbarHeight = useBottomTabBarHeight() + useAnimatedReaction( + () => { + // Calculate how close we are to the bottom of the content + const distanceFromBottom = + scrollViewContentHeight.value - + scrollViewHeight.value - + reAnimatedScrollY.value - + tabbarHeight + + // Define a threshold for when to start fading (in pixels) + const fadeThreshold = 50 + + // If we're within the threshold distance from the bottom, calculate opacity + if (distanceFromBottom <= fadeThreshold) { + // Linear interpolation: 0 at bottom, 1 at threshold + return Math.max(0, distanceFromBottom / fadeThreshold) + } + + // Otherwise, keep the tab bar fully visible + return 1 + }, + (opacityValue) => { + opacity.value = opacityValue + }, + ) + return null +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.ios.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.ios.tsx new file mode 100644 index 000000000..4fd6b2b3a --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.ios.tsx @@ -0,0 +1,34 @@ +import { requireNativeView } from "expo" +import { useSetAtom } from "jotai" +import { useContext } from "react" +import type { ViewProps } from "react-native" +import { StyleSheet, View } from "react-native" + +import { BottomTabContext } from "./BottomTabContext" + +const TabBarPortalNative = requireNativeView("TabBarPortal") + +export const TabBarPortal = ({ children }: { children: React.ReactNode }) => { + const { tabHeightAtom } = useContext(BottomTabContext) + const setTabHeight = useSetAtom(tabHeightAtom) + return ( + + { + setTabHeight(e.nativeEvent.layout.height) + }} + > + {children} + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + }, +}) diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.tsx new file mode 100644 index 000000000..8e48f4911 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.tsx @@ -0,0 +1,30 @@ +import { useSetAtom } from "jotai" +import { useContext } from "react" +import { StyleSheet, View } from "react-native" + +import { BottomTabContext } from "./BottomTabContext" + +export const TabBarPortal = ({ children }: { children: React.ReactNode }) => { + const { tabHeightAtom } = useContext(BottomTabContext) + const setTabHeight = useSetAtom(tabHeightAtom) + return ( + + { + setTabHeight(e.nativeEvent.layout.height) + }} + > + {children} + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + }, +}) diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.ios.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.ios.tsx new file mode 100644 index 000000000..83bd59287 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.ios.tsx @@ -0,0 +1,69 @@ +import { requireNativeView } from "expo" +import { atom, useAtom } from "jotai" +import type { FC, PropsWithChildren } from "react" +import * as React from "react" +import { useCallback, useMemo, useState } from "react" +import type { NativeSyntheticEvent, ViewProps } from "react-native" +import { StyleSheet } from "react-native" + +import type { BottomTabContextType } from "./BottomTabContext" +import { BottomTabContext } from "./BottomTabContext" +import type { TabScreenProps } from "./TabScreen.ios" +import { TabScreen } from "./TabScreen.ios" + +interface TabRootProps { + initialTabIndex?: number +} + +const TabBarRoot = requireNativeView< + { + onTabIndexChange: (e: NativeSyntheticEvent<{ index: number }>) => void + selectedIndex: number + } & ViewProps +>("TabBarRoot") + +export const TabRoot: FC = ({ + children, + initialTabIndex = 0, +}) => { + const [tabIndexAtom] = useState(() => atom(initialTabIndex)) + const [tabIndex, setTabIndex] = useAtom(tabIndexAtom) + + const ctxValue = useMemo( + () => ({ + currentIndexAtom: tabIndexAtom, + loadedableIndexAtom: atom(new Set()), + tabScreensAtom: atom([]), + tabHeightAtom: atom(0), + }), + [tabIndexAtom], + ) + + const MapChildren = useMemo(() => { + let cnt = 0 + return React.Children.map(children, (child) => { + if (typeof child === "object" && child && "type" in child && child.type === TabScreen) { + return React.cloneElement(child, { + tabScreenIndex: cnt++, + }) + } + return child + }) + }, [children]) + return ( + + { + setTabIndex(e.nativeEvent.index) + }, + [setTabIndex], + )} + selectedIndex={tabIndex} + > + {MapChildren} + + + ) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.tsx new file mode 100644 index 000000000..96bc89b5c --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.tsx @@ -0,0 +1,48 @@ +import { atom } from "jotai" +import type { FC, PropsWithChildren } from "react" +import * as React from "react" +import { useMemo, useState } from "react" +import { StyleSheet, View } from "react-native" + +import type { BottomTabContextType } from "./BottomTabContext" +import { BottomTabContext } from "./BottomTabContext" +import type { TabScreenProps } from "./TabScreen" +import { TabScreen } from "./TabScreen" + +interface TabRootProps { + initialTabIndex?: number +} + +export const TabRoot: FC = ({ + children, + initialTabIndex = 0, +}) => { + const [tabIndexAtom] = useState(() => atom(initialTabIndex)) + + const ctxValue = useMemo( + () => ({ + currentIndexAtom: tabIndexAtom, + loadedableIndexAtom: atom(new Set()), + tabScreensAtom: atom([]), + tabHeightAtom: atom(0), + }), + [tabIndexAtom], + ) + + const MapChildren = useMemo(() => { + let cnt = 0 + return React.Children.map(children, (child) => { + if (typeof child === "object" && child && "type" in child && child.type === TabScreen) { + return React.cloneElement(child, { + tabScreenIndex: cnt++, + }) + } + return child + }) + }, [children]) + return ( + + {MapChildren} + + ) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.ios.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.ios.tsx new file mode 100644 index 000000000..e9a7bc8b0 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.ios.tsx @@ -0,0 +1,106 @@ +import { requireNativeView } from "expo" +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import type { FC, PropsWithChildren } from "react" +import { useContext, useEffect, useMemo } from "react" +import type { ViewProps } from "react-native" +import { StyleSheet } from "react-native" + +import { WrappedScreenItem } from "../WrappedScreenItem" +import { BottomTabContext } from "./BottomTabContext" +import { LifecycleEvents, ScreenNameRegister } from "./shared" +import type { TabScreenContextType } from "./TabScreenContext" +import { TabScreenContext } from "./TabScreenContext" +import type { TabbarIconProps, TabScreenComponent } from "./types" + +const TabScreenNative = requireNativeView("TabScreen") + +export interface TabScreenProps { + title: string + tabScreenIndex: number + renderIcon?: (props: TabbarIconProps) => React.ReactNode +} +export const TabScreen: FC>> = ({ + children, + ...props +}) => { + const { tabScreenIndex } = props as any as TabScreenProps + + const { + loadedableIndexAtom, + currentIndexAtom, + tabScreensAtom: tabScreens, + } = useContext(BottomTabContext) + + const setTabScreens = useSetAtom(tabScreens) + useEffect(() => { + const propsFromChildren: Partial = {} + if (children && typeof children === "object") { + const childType = (children as any).type as TabScreenComponent + if ("tabBarIcon" in childType) { + propsFromChildren.renderIcon = childType.tabBarIcon + } + if ("title" in childType) { + propsFromChildren.title = childType.title + } + } + + setTabScreens((prev) => [ + ...prev, + { + ...propsFromChildren, + ...props, + tabScreenIndex, + }, + ]) + + return () => { + setTabScreens((prev) => + prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex), + ) + } + }, [setTabScreens, props, tabScreenIndex, children]) + + const currentSelectedIndex = useAtomValue(currentIndexAtom) + + const isSelected = useMemo( + () => currentSelectedIndex === tabScreenIndex, + [currentSelectedIndex, tabScreenIndex], + ) + + const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom) + + const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex) + useEffect(() => { + if (isSelected) { + setLoadedableIndex((prev) => { + prev.add(tabScreenIndex) + return new Set(prev) + }) + } + }, [setLoadedableIndex, tabScreenIndex, isSelected]) + + const ctxValue = useMemo( + () => ({ + tabScreenIndex, + + titleAtom: atom(props.title), + }), + [tabScreenIndex, props.title], + ) + const shouldLoadReact = isSelected || isLoadedBefore + + return ( + + + {shouldLoadReact && ( + + {children} + + + {/* */} + + )} + + + ) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.tsx new file mode 100644 index 000000000..c7e83b1b1 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.tsx @@ -0,0 +1,102 @@ +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import type { FC, PropsWithChildren } from "react" +import { useContext, useEffect, useMemo } from "react" +import { StyleSheet, View } from "react-native" + +import { WrappedScreenItem } from "../WrappedScreenItem" +import { BottomTabContext } from "./BottomTabContext" +import { LifecycleEvents, ScreenNameRegister } from "./shared" +import type { TabScreenContextType } from "./TabScreenContext" +import { TabScreenContext } from "./TabScreenContext" +import type { TabbarIconProps, TabScreenComponent } from "./types" + +export interface TabScreenProps { + title: string + tabScreenIndex: number + renderIcon?: (props: TabbarIconProps) => React.ReactNode +} +export const TabScreen: FC>> = ({ + children, + ...props +}) => { + const { tabScreenIndex } = props as any as TabScreenProps + + const { + loadedableIndexAtom, + currentIndexAtom, + tabScreensAtom: tabScreens, + } = useContext(BottomTabContext) + + const setTabScreens = useSetAtom(tabScreens) + useEffect(() => { + const propsFromChildren: Partial = {} + if (children && typeof children === "object") { + const childType = (children as any).type as TabScreenComponent + if ("tabBarIcon" in childType) { + propsFromChildren.renderIcon = childType.tabBarIcon + } + if ("title" in childType) { + propsFromChildren.title = childType.title + } + } + + setTabScreens((prev) => [ + ...prev, + { + ...propsFromChildren, + ...props, + tabScreenIndex, + }, + ]) + + return () => { + setTabScreens((prev) => + prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex), + ) + } + }, [setTabScreens, props, tabScreenIndex, children]) + + const currentSelectedIndex = useAtomValue(currentIndexAtom) + + const isSelected = useMemo( + () => currentSelectedIndex === tabScreenIndex, + [currentSelectedIndex, tabScreenIndex], + ) + + const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom) + + const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex) + useEffect(() => { + if (isSelected) { + setLoadedableIndex((prev) => { + prev.add(tabScreenIndex) + return new Set(prev) + }) + } + }, [setLoadedableIndex, tabScreenIndex, isSelected]) + + const ctxValue = useMemo( + () => ({ + tabScreenIndex, + + titleAtom: atom(props.title), + }), + [tabScreenIndex, props.title], + ) + const shouldLoadReact = isSelected || isLoadedBefore + + return ( + + + {shouldLoadReact && ( + + {children} + + + {/* */} + + )} + + + ) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabScreenContext.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabScreenContext.tsx new file mode 100644 index 000000000..6f0f9089a --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabScreenContext.tsx @@ -0,0 +1,9 @@ +import type { PrimitiveAtom } from "jotai" +import { createContext } from "react" + +export interface TabScreenContextType { + tabScreenIndex: number + + titleAtom: PrimitiveAtom +} +export const TabScreenContext = createContext(null!) diff --git a/apps/mobile/src/lib/navigation/bottom-tab/hooks.ts b/apps/mobile/src/lib/navigation/bottom-tab/hooks.ts new file mode 100644 index 000000000..cbe0e1686 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/hooks.ts @@ -0,0 +1,38 @@ +import { useAtomValue, useSetAtom } from "jotai" +import { useCallback, useContext } from "react" + +import { ScreenItemContext } from "../ScreenItemContext" +import { BottomTabContext } from "./BottomTabContext" +import { TabScreenContext } from "./TabScreenContext" + +export const useScreenIsAppeared = () => { + const { isAppearedAtom } = useContext(ScreenItemContext) + + return useAtomValue(isAppearedAtom) +} + +export const useTabScreenIsFocused = () => { + const { currentIndexAtom } = useContext(BottomTabContext) + const currentIndex = useAtomValue(currentIndexAtom) + const { isFocusedAtom } = useContext(ScreenItemContext) + const isFocused = useAtomValue(isFocusedAtom) + const { tabScreenIndex } = useContext(TabScreenContext) + + return currentIndex === tabScreenIndex && isFocused +} + +export const useSwitchTab = () => { + const { currentIndexAtom } = useContext(BottomTabContext) + const setCurrentIndex = useSetAtom(currentIndexAtom) + return useCallback( + (index: number) => { + setCurrentIndex(index) + }, + [setCurrentIndex], + ) +} + +export const useBottomTabHeight = () => { + const { tabHeightAtom } = useContext(BottomTabContext) + return useAtomValue(tabHeightAtom) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/shared.tsx b/apps/mobile/src/lib/navigation/bottom-tab/shared.tsx new file mode 100644 index 000000000..16522cd2d --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/shared.tsx @@ -0,0 +1,40 @@ +import { useAtomValue, useSetAtom, useStore } from "jotai" +import { useContext, useEffect } from "react" + +import { ScreenItemContext } from "../ScreenItemContext" +import { ScreenNameContext } from "../ScreenNameContext" +import { useTabScreenIsFocused } from "./hooks" +import { TabScreenContext } from "./TabScreenContext" + +export const ScreenNameRegister = () => { + const nameAtom = useContext(ScreenNameContext) + const { titleAtom } = useContext(TabScreenContext) + const isFocused = useTabScreenIsFocused() + const title = useAtomValue(titleAtom) + const store = useStore() + useEffect(() => { + if (isFocused) { + store.set(nameAtom, title) + } + }, [isFocused, title, nameAtom, store]) + return null +} + +export const LifecycleEvents = ({ isSelected }: { isSelected: boolean }) => { + const { isFocusedAtom, isAppearedAtom, isDisappearedAtom } = useContext(ScreenItemContext) + const setIsFocused = useSetAtom(isFocusedAtom) + const setIsAppeared = useSetAtom(isAppearedAtom) + const setIsDisappeared = useSetAtom(isDisappearedAtom) + useEffect(() => { + if (isSelected) { + setIsFocused(true) + setIsAppeared(true) + setIsDisappeared(false) + } else { + setIsFocused(false) + setIsAppeared(false) + setIsDisappeared(true) + } + }, [isSelected, setIsAppeared, setIsDisappeared, setIsFocused]) + return null +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/types.ts b/apps/mobile/src/lib/navigation/bottom-tab/types.ts new file mode 100644 index 000000000..c236e3f06 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/types.ts @@ -0,0 +1,11 @@ +import type { FC } from "react" + +export type TabbarIconProps = { + focused: boolean + size: number + color: string +} +export type TabScreenComponent = FC & { + tabBarIcon?: (props: TabbarIconProps) => React.ReactNode + title?: string +} diff --git a/apps/mobile/src/lib/navigation/config.ts b/apps/mobile/src/lib/navigation/config.ts new file mode 100644 index 000000000..866a04d6a --- /dev/null +++ b/apps/mobile/src/lib/navigation/config.ts @@ -0,0 +1,5 @@ +import type { ScreenStackHeaderConfigProps } from "react-native-screens" + +export const defaultHeaderConfig: ScreenStackHeaderConfigProps = { + hidden: true, +} diff --git a/apps/mobile/src/lib/navigation/debug/DebugButtonGroup.tsx b/apps/mobile/src/lib/navigation/debug/DebugButtonGroup.tsx new file mode 100644 index 000000000..bb31a6072 --- /dev/null +++ b/apps/mobile/src/lib/navigation/debug/DebugButtonGroup.tsx @@ -0,0 +1,159 @@ +/* eslint-disable no-console */ +import { useEffect, useRef } from "react" +import { Button, SafeAreaView, ScrollView, Text, TouchableOpacity, View } from "react-native" + +import { FullWindowOverlay } from "@/src/components/common/FullWindowOverlay" +import { CloseCuteReIcon } from "@/src/icons/close_cute_re" + +import { useNavigation } from "../hooks" +import { Navigation } from "../Navigation" +import type { NavigationControllerView } from "../types" + +export const DebugButtonGroup = () => { + useEffect(() => { + const disposers: (() => void)[] = [] + disposers.push( + Navigation.rootNavigation.on("willAppear", (payload) => { + console.log("willAppear", payload) + }), + Navigation.rootNavigation.on("didAppear", (payload) => { + console.log("didAppear", payload) + }), + Navigation.rootNavigation.on("willDisappear", (payload) => { + console.log("willDisappear", payload) + }), + Navigation.rootNavigation.on("didDisappear", (payload) => { + console.log("didDisappear", payload) + }), + ) + return () => { + disposers.forEach((disposer) => disposer()) + } + }, []) + + const cntRef = useRef(0) + return ( + + + + Ad eveniet laboriosam hic voluptas non facilis sint. Laborum rem et provident blanditiis + iure rem. Porro voluptate ipsa explicabo voluptatem cumque est architecto. Sit + voluptatibus exercitationem recusandae cupiditate tenetur inventore amet repellendus + ratione. Nobis nulla harum soluta aliquam iure unde saepe. Modi ipsam harum aspernatur + aperiam quod pariatur nisi corporis. Doloribus molestiae a dolore. Veniam commodi nesciunt + beatae itaque aliquid nemo. Ut labore rem voluptates. Reprehenderit recusandae voluptate + earum consectetur tempora corrupti. Nulla ducimus enim sit ipsam eum esse debitis saepe. + Nobis ut voluptas. Id sapiente voluptate soluta ipsa esse corrupti facere nemo recusandae. + Dignissimos eum mollitia hic corrupti. Reiciendis voluptates et provident sed laborum + consequuntur. Quod nemo nesciunt dignissimos doloribus veniam odio. + + + + +