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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-25 23:06:51 +08:00
parent d1b790036c
commit 87ea8b7a20
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 224 additions and 119 deletions

View File

@ -53,7 +53,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>137</string>
<string>138</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>

View File

@ -11,7 +11,8 @@
"TabBarPortalModule",
"EnhancePagerViewModule",
"EnhancePageViewModule",
"ItemPressableModule"
"ItemPressableModule",
"TabBarBottomAccessoryModule"
]
},
"android": {

View File

@ -9,105 +9,102 @@ import UIKit
extension UIWindow {
static func findViewController<T: UIViewController>(ofType type: T.Type) -> T? {
static func findViewController<T: UIViewController>(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<T: UIViewController>(_ 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<T: UIViewController>(
_ 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
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -16,9 +16,18 @@ import { isIos26 } from "@/src/lib/platform"
export const ThemedBlurView = ({
ref,
tint,
tintColor,
useGlass,
...rest
}: BlurViewProps & { ref?: React.Ref<BlurView | null>; useGlass?: boolean }) => {
}: BlurViewProps & {
ref?: React.Ref<BlurView | null>
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 <GlassView style={rest.style} glassEffectStyle="regular" />
return <GlassView style={rest.style} glassEffectStyle="regular" tintColor={tintColor} />
}
return useBlurView ? (
<BlurView
ref={ref}
intensity={100}
tint={colorScheme === "light" ? "systemChromeMaterialLight" : "systemChromeMaterialDark"}
{...rest}
/>
<>
<BlurView
ref={ref}
intensity={100}
tint={colorScheme === "light" ? "systemChromeMaterialLight" : "systemChromeMaterialDark"}
{...rest}
/>
{tintColor && (
<View style={[StyleSheet.absoluteFillObject, { backgroundColor: tintColor }]} />
)}
</>
) : (
<View
ref={ref as any}
@ -42,7 +56,7 @@ export const ThemedBlurView = ({
style={StyleSheet.flatten([
rest.style,
{
backgroundColor: background,
backgroundColor: tintColor ?? background,
opacity: 1,
},
])}

View File

@ -43,7 +43,7 @@ const NativeTabBarHolder = () => {
<Portal>
<BottomTabContext value={bottomTabContext}>
<NavigationInstanceContext value={navigation}>
<View className="bg-red absolute inset-x-0 bottom-[68px] mb-4">
<View className="absolute inset-x-0 bottom-[68px] mb-4">
<GlassPlayerTabBar className="absolute inset-x-0 bottom-0" />
</View>
</NavigationInstanceContext>

View File

@ -40,5 +40,5 @@ export function getDefaultHeaderHeight({
headerHeight = 64
}
return headerHeight + statusBarHeight
return headerHeight + (modalPresentation ? 0 : statusBarHeight)
}

View File

@ -4,6 +4,7 @@ import type { ViewProps } from "react-native"
import type { TabBarRootWrapperProps } from "./types"
export const TabBarPortalWrapper = requireNativeView<ViewProps>("TabBarPortal")
export const TabBarBottomAccessoryWrapper = requireNativeView<ViewProps>("TabBarBottomAccessory")
export type TabScreenNativeProps = ViewProps & { title?: string }

View File

@ -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<typeof View> & {
title?: string
icon?: FC<TabbarIconProps> | IconNativeValues

View File

@ -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 <View className={cn("flex flex-row items-center gap-2", className)}>{children}</View>
return <View className={cn("flex flex-row items-center gap-1", className)}>{children}</View>
}
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 })

View File

@ -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<{
<FeedScreenEntryList />
{!isSubscribed && isBizId(feedIdentifier) && (
<Pressable
className="bg-accent absolute bottom-10 left-1/2 z-10 m-2 mx-auto -translate-x-1/2 rounded-full px-4 py-2"
className="absolute bottom-10 left-1/2 z-10 m-2 mx-auto -translate-x-1/2 overflow-hidden rounded-full px-8 py-2"
onPress={() => {
navigation.presentControllerView(FollowScreen, {
id: feedIdentifier,
@ -42,6 +44,11 @@ export const FeedScreen: NavigationControllerView<{
})
}}
>
<ThemedBlurView
useGlass
style={StyleSheet.absoluteFillObject}
tintColor={withOpacity(accentColor, 0.6)}
/>
<Text className="font-bold text-white">{t("words.follow")}</Text>
</Pressable>
)}