From 87ea8b7a20723410aabb453453f5e5f9f20331ca Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 25 Sep 2025 23:06:51 +0800 Subject: [PATCH] feat(tabbar): add TabBarBottomAccessoryModule and enhance TabBarRootView - Introduced `TabBarBottomAccessoryModule` to provide a new bottom accessory view for the tab bar. - Updated `TabBarRootView` to utilize a custom tab bar controller, improving the management of the tab bar's visibility and behavior. - Refactored `UIWindow` extension methods for better readability and structure. - Enhanced `expo-module.config.json` to include the new module and improved formatting for better clarity. These changes aim to enhance the tab bar functionality and improve the overall user interface experience in the mobile application. Signed-off-by: Innei --- apps/mobile/ios/Folo/Info.plist | 2 +- apps/mobile/native/expo-module.config.json | 3 +- .../native/ios/Extensions/UIWindow.swift | 189 +++++++++--------- .../TabBar/TabBarBottomAccessoryModule.swift | 73 +++++++ .../ios/Modules/TabBar/TabBarRootView.swift | 15 +- .../src/components/common/ThemedBlurView.tsx | 32 ++- .../layouts/tabbar/ReactNativeTab.ios.tsx | 2 +- .../src/components/layouts/utils/index.tsx | 2 +- .../lib/navigation/bottom-tab/native.ios.tsx | 1 + .../src/lib/navigation/bottom-tab/native.tsx | 1 + apps/mobile/src/modules/screen/action.tsx | 10 +- .../(stack)/feeds/[feedId]/FeedScreen.tsx | 13 +- 12 files changed, 224 insertions(+), 119 deletions(-) create mode 100644 apps/mobile/native/ios/Modules/TabBar/TabBarBottomAccessoryModule.swift diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index fd31bb5ad..085ae9b9f 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -53,7 +53,7 @@ CFBundleVersion - 137 + 138 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/native/expo-module.config.json b/apps/mobile/native/expo-module.config.json index 05e23ab8d..b69c7f315 100644 --- a/apps/mobile/native/expo-module.config.json +++ b/apps/mobile/native/expo-module.config.json @@ -11,7 +11,8 @@ "TabBarPortalModule", "EnhancePagerViewModule", "EnhancePageViewModule", - "ItemPressableModule" + "ItemPressableModule", + "TabBarBottomAccessoryModule" ] }, "android": { diff --git a/apps/mobile/native/ios/Extensions/UIWindow.swift b/apps/mobile/native/ios/Extensions/UIWindow.swift index 754552da2..4a0519969 100644 --- a/apps/mobile/native/ios/Extensions/UIWindow.swift +++ b/apps/mobile/native/ios/Extensions/UIWindow.swift @@ -9,105 +9,102 @@ import UIKit extension UIWindow { - - static func findViewController(ofType type: T.Type) -> T? { + static func findViewController(ofType type: T.Type) -> T? { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil - } - - - if let rootVC = window.rootViewController { - return findViewControllerInHierarchy(rootVC, ofType: type) - } - return nil - } - - - static private func findViewControllerInHierarchy(_ viewController: UIViewController, ofType type: T.Type) -> T? { - - if let targetVC = viewController as? T { - return targetVC - } - - - if let navController = viewController as? UINavigationController { - if let visibleVC = navController.visibleViewController { - return findViewControllerInHierarchy(visibleVC, ofType: type) - } - } - - - if let tabController = viewController as? UITabBarController { - if let selectedVC = tabController.selectedViewController { - return findViewControllerInHierarchy(selectedVC, ofType: type) - } - } - - - for childVC in viewController.children { - if let foundVC = findViewControllerInHierarchy(childVC, ofType: type) { - return foundVC - } - } - - - if let presentedVC = viewController.presentedViewController { - return findViewControllerInHierarchy(presentedVC, ofType: type) - } - - return nil - } - - public static func findRNSNavigationController() -> UINavigationController? { - - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil - } - - let rootViewController = window.rootViewController - - if let navController = rootViewController as? UINavigationController, - NSStringFromClass(type(of: navController)).contains("RNSNavigationController") - { - return navController - } - - return findRNSNavigationControllerInChildren(of: rootViewController) + let window = windowScene.windows.first + else { + return nil } - private static func findRNSNavigationControllerInChildren(of viewController: UIViewController?) - -> UINavigationController? + if let rootVC = window.rootViewController { + return findViewControllerInHierarchy(rootVC, ofType: type) + } + return nil + } + + static private func findViewControllerInHierarchy( + _ viewController: UIViewController, ofType type: T.Type + ) -> T? { + + if let targetVC = viewController as? T { + return targetVC + } + + if let navController = viewController as? UINavigationController { + if let visibleVC = navController.visibleViewController { + return findViewControllerInHierarchy(visibleVC, ofType: type) + } + } + + if let tabController = viewController as? UITabBarController { + if let selectedVC = tabController.selectedViewController { + return findViewControllerInHierarchy(selectedVC, ofType: type) + } + } + + for childVC in viewController.children { + if let foundVC = findViewControllerInHierarchy(childVC, ofType: type) { + return foundVC + } + } + + if let presentedVC = viewController.presentedViewController { + return findViewControllerInHierarchy(presentedVC, ofType: type) + } + + return nil + } + + public static func findRNSNavigationController() -> UINavigationController? { + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first + else { + return nil + } + + let rootViewController = window.rootViewController + + if let navController = rootViewController as? UINavigationController, + NSStringFromClass(type(of: navController)).contains("RNSNavigationController") { - guard let viewController = viewController else { - return nil - } - - if let presentedVC = viewController.presentedViewController { - if let navController = presentedVC as? UINavigationController, - NSStringFromClass(type(of: navController)).contains("RNSNavigationController") - { - return navController - } - - if let result = findRNSNavigationControllerInChildren(of: presentedVC) { - return result - } - } - - for childVC in viewController.children { - if let navController = childVC as? UINavigationController, - NSStringFromClass(type(of: navController)).contains("RNSNavigationController") - { - return navController - } - - if let result = findRNSNavigationControllerInChildren(of: childVC) { - return result - } - } - - return nil + return navController } + + return findRNSNavigationControllerInChildren(of: rootViewController) + } + + private static func findRNSNavigationControllerInChildren(of viewController: UIViewController?) + -> UINavigationController? + { + guard let viewController = viewController else { + return nil + } + + if let presentedVC = viewController.presentedViewController { + if let navController = presentedVC as? UINavigationController, + NSStringFromClass(type(of: navController)).contains("RNSNavigationController") + { + return navController + } + + if let result = findRNSNavigationControllerInChildren(of: presentedVC) { + return result + } + } + + for childVC in viewController.children { + if let navController = childVC as? UINavigationController, + NSStringFromClass(type(of: navController)).contains("RNSNavigationController") + { + return navController + } + + if let result = findRNSNavigationControllerInChildren(of: childVC) { + return result + } + } + + return nil + } } diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarBottomAccessoryModule.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarBottomAccessoryModule.swift new file mode 100644 index 000000000..1c6b249d8 --- /dev/null +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarBottomAccessoryModule.swift @@ -0,0 +1,73 @@ +// +// TabBarBottomAccessoryModule.swift +// FollowNative +// +// Created by Innei on 2025-09-25 +// + +import ExpoModulesCore +import UIKit + +public class TabBarBottomAccessoryModule: Module { + public func definition() -> ModuleDefinition { + Name("TabBarBottomAccessory") + + View(TabBarBottomAccessoryView.self) { + + } + } +} + +class TabBarBottomAccessoryView: ExpoView { + private weak var attachedRoot: TabBarRootView? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + } + + deinit { + detachFromRoot() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + attachToNearestTabBarRoot() + } + + override func willMove(toWindow newWindow: UIWindow?) { + if newWindow == nil { + detachFromRoot() + } + super.willMove(toWindow: newWindow) + } + + #if RCT_NEW_ARCH_ENABLED + + override func mountChildComponentView(_ childComponentView: UIView, index: Int) { + attachToNearestTabBarRoot() + } + + override func unmountChildComponentView(_ childComponentView: UIView, index: Int) { + detachFromRoot() + + } + #endif + + func attachToNearestTabBarRoot() { + if #available(iOS 26, *) { + guard window != nil else { return } + + CustomTabbarController.tabBarController.bottomAccessory = .init(contentView: self) + } + + } + + func detachFromRoot() { + if #available(iOS 26, *) { + guard window != nil else { return } + CustomTabbarController.tabBarController.bottomAccessory = nil + + } + } + +} diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift index ac626f1fd..d732f2d31 100644 --- a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift @@ -10,8 +10,9 @@ import Foundation import SnapKit import UIKit -class TabBarRootView: ExpoView { - private lazy var tabBarController = { +@MainActor +enum CustomTabbarController { + static var tabBarController = { let tabBarController = UITabBarController() if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad { tabBarController.tabBar.isTranslucent = false @@ -27,19 +28,27 @@ class TabBarRootView: ExpoView { if #available(iOS 26.0, *) { tabBarController.isTabBarHidden = false tabBarController.tabBarMinimizeBehavior = .onScrollDown + } tabBarController.tabBar.tintColor = Utils.accentColor return tabBarController }() +} + +class TabBarRootView: ExpoView { + private var tabBarController = CustomTabbarController.tabBarController private let vc = UIViewController() private var tabViewControllers: [UIViewController] = [] + private var bottomAccessoryView: UIView? private let onTabIndexChange = EventDispatcher() private let onTabItemPress = EventDispatcher() + + required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -118,6 +127,7 @@ class TabBarRootView: ExpoView { let tabBarView = tabBarController.view! tabBarView.addSubview(tabBarPortalView) } + } override func willRemoveSubview(_ subview: UIView) { @@ -129,6 +139,7 @@ class TabBarRootView: ExpoView { tabBarController.viewControllers = tabViewControllers tabBarController.didMove(toParent: vc) } + } } diff --git a/apps/mobile/src/components/common/ThemedBlurView.tsx b/apps/mobile/src/components/common/ThemedBlurView.tsx index b857a689a..add3e39f4 100644 --- a/apps/mobile/src/components/common/ThemedBlurView.tsx +++ b/apps/mobile/src/components/common/ThemedBlurView.tsx @@ -16,9 +16,18 @@ import { isIos26 } from "@/src/lib/platform" export const ThemedBlurView = ({ ref, tint, + + tintColor, useGlass, ...rest -}: BlurViewProps & { ref?: React.Ref; useGlass?: boolean }) => { +}: BlurViewProps & { + ref?: React.Ref + useGlass?: boolean + /** + * The tint color of the glass view, only works when `useGlass` is true + */ + tintColor?: string +}) => { const { colorScheme } = useColorScheme() const background = useColor("systemBackground") @@ -26,15 +35,20 @@ export const ThemedBlurView = ({ const useBlurView = Platform.OS === "ios" || "experimentalBlurMethod" in rest if (isIos26 && useGlass) { - return + return } return useBlurView ? ( - + <> + + {tintColor && ( + + )} + ) : ( { - + diff --git a/apps/mobile/src/components/layouts/utils/index.tsx b/apps/mobile/src/components/layouts/utils/index.tsx index 5a09d432d..1fd29439b 100644 --- a/apps/mobile/src/components/layouts/utils/index.tsx +++ b/apps/mobile/src/components/layouts/utils/index.tsx @@ -40,5 +40,5 @@ export function getDefaultHeaderHeight({ headerHeight = 64 } - return headerHeight + statusBarHeight + return headerHeight + (modalPresentation ? 0 : statusBarHeight) } diff --git a/apps/mobile/src/lib/navigation/bottom-tab/native.ios.tsx b/apps/mobile/src/lib/navigation/bottom-tab/native.ios.tsx index 2b206086a..3e55add44 100644 --- a/apps/mobile/src/lib/navigation/bottom-tab/native.ios.tsx +++ b/apps/mobile/src/lib/navigation/bottom-tab/native.ios.tsx @@ -4,6 +4,7 @@ import type { ViewProps } from "react-native" import type { TabBarRootWrapperProps } from "./types" export const TabBarPortalWrapper = requireNativeView("TabBarPortal") +export const TabBarBottomAccessoryWrapper = requireNativeView("TabBarBottomAccessory") export type TabScreenNativeProps = ViewProps & { title?: string } diff --git a/apps/mobile/src/lib/navigation/bottom-tab/native.tsx b/apps/mobile/src/lib/navigation/bottom-tab/native.tsx index 9cd632869..86d72d065 100644 --- a/apps/mobile/src/lib/navigation/bottom-tab/native.tsx +++ b/apps/mobile/src/lib/navigation/bottom-tab/native.tsx @@ -6,6 +6,7 @@ import type { IconNativeValues } from "@/src/constants/native-images" import type { TabbarIconProps, TabBarRootWrapperProps } from "./types" export { View as TabBarPortalWrapper } from "react-native" +export { View as TabBarBottomAccessoryWrapper } from "react-native" export type TabScreenNativeProps = React.ComponentProps & { title?: string icon?: FC | IconNativeValues diff --git a/apps/mobile/src/modules/screen/action.tsx b/apps/mobile/src/modules/screen/action.tsx index 70be6d067..8057b7d1d 100644 --- a/apps/mobile/src/modules/screen/action.tsx +++ b/apps/mobile/src/modules/screen/action.tsx @@ -25,7 +25,7 @@ import { accentColor, useColor } from "@/src/theme/colors" import { MarkAllAsReadDialog } from "../dialogs/MarkAllAsReadDialog" export const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => { - return {children} + return {children} } export function HomeLeftAction() { @@ -59,7 +59,7 @@ interface HeaderActionButtonProps { variant?: "primary" | "secondary" } -export const MarkAllAsReadActionButton = ({ variant = "primary" }: HeaderActionButtonProps) => { +export const MarkAllAsReadActionButton = ({ variant = "secondary" }: HeaderActionButtonProps) => { const { t } = useTranslation() const { size, color } = useButtonVariant({ variant }) @@ -76,11 +76,11 @@ export const MarkAllAsReadActionButton = ({ variant = "primary" }: HeaderActionB const useButtonVariant = ({ variant = "primary" }: HeaderActionButtonProps) => { const label = useColor("label") - const size = 24 + const size = 20 const color = variant === "primary" ? accentColor : label return { size, color } } -export const UnreadOnlyActionButton = ({ variant = "primary" }: HeaderActionButtonProps) => { +export const UnreadOnlyActionButton = ({ variant = "secondary" }: HeaderActionButtonProps) => { const { t } = useTranslation() const unreadOnly = useGeneralSettingKey("unreadOnly") const { size, color } = useButtonVariant({ variant }) @@ -110,7 +110,7 @@ export const UnreadOnlyActionButton = ({ variant = "primary" }: HeaderActionButt export const FeedShareActionButton = ({ feedId, - variant = "primary", + variant = "secondary", }: { feedId?: string } & HeaderActionButtonProps) => { const { t } = useTranslation() const { size, color } = useButtonVariant({ variant }) diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx index c2d90dcfd..867c95eea 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx @@ -1,13 +1,14 @@ import { FeedViewType } from "@follow/constants" import { useFeedById } from "@follow/store/feed/hooks" import { useIsSubscribed } from "@follow/store/subscription/hooks" -import { isBizId } from "@follow/utils" +import { isBizId, withOpacity } from "@follow/utils" import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { Pressable } from "react-native" +import { Pressable, StyleSheet } from "react-native" import { RootSiblingParent } from "react-native-root-siblings" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { ThemedBlurView } from "@/src/components/common/ThemedBlurView" import { BottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext" import { Text } from "@/src/components/ui/typography/Text" import { useNavigation } from "@/src/lib/navigation/hooks" @@ -16,6 +17,7 @@ import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector" import { EntryListContext, useEntries, useSelectedView } from "@/src/modules/screen/atoms" import { TimelineHeader } from "@/src/modules/screen/TimelineSelectorProvider" import { FollowScreen } from "@/src/screens/(modal)/FollowScreen" +import { accentColor } from "@/src/theme/colors" export const FeedScreen: NavigationControllerView<{ feedId: string @@ -34,7 +36,7 @@ export const FeedScreen: NavigationControllerView<{ {!isSubscribed && isBizId(feedIdentifier) && ( { navigation.presentControllerView(FollowScreen, { id: feedIdentifier, @@ -42,6 +44,11 @@ export const FeedScreen: NavigationControllerView<{ }) }} > + {t("words.follow")} )}