Mục lục
Khởi tạo dự án Expo
Bước đầu tiên để xây dựng ứng dụng là khởi tạo một dự án Expo mới. Chúng ta sẽ sử dụng Yarn kết hợp với template TypeScript trắng (blank) để có một khởi đầu sạch sẽ nhất.
# Tạo project với yarn và sử dụng template typescript
yarn create expo-app@latest expo-navigation-bottom-tabs -- --template blank-typescript
# cd vào thư mục dự án
cd expo-navigation-bottom-tabs
# Cài đặt dependencies
yarn
# Chạy dự án
yarn start
Sau khi khởi chạy thành công, bạn có thể tải ứng dụng Expo Go trên điện thoại Android hoặc iOS, quét mã QR hiển thị trên Terminal để xem kết quả trực tiếp.
Cấu trúc thư mục dự án ban đầu của bạn sẽ trông như sau:
navigation-bottom-tags/
├── assets/
│ ├── adaptive-icon.png
│ ├── favicon.png
│ └── icon.png
│ └── splash-icon.png
├── .gitignore
├── app.json
├── App.tsx
├── index.tsx
├── package.json
├── tsconfig.json
└── yarn.lock
Cấu hình Alias Imports
Để code được gọn gàng và tránh tình trạng ../../../ chằng chịt, chúng ta cần thiết lập Absolute Imports (Alias). Đầu tiên, hãy mở file tsconfig.json và khai báo đường dẫn paths bên trong compilerOptions:
{
...,
"compilerOptions": {
...,
"paths": {
"@/*": [
"./src/*"
]
},
...
},
...
}
baseUrl khi định nghĩa paths nữa. Tuy nhiên, đường dẫn alias bắt buộc phải bắt đầu bằng ./.
Tiếp theo, cài đặt plugin hỗ trợ Babel phân giải (resolve) các module này:
yarn add --dev babel-plugin-module-resolver
Tạo file cấu hình babel.config.js ở thư mục gốc và cập nhật nội dung như sau:
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
'@': './src',
},
},
],
],
};
};
Để kiểm tra, hãy tạo một thư mục src và thêm file main.tsx đầu tiên của chúng ta:
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export function Main() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
Cuối cùng, cập nhật lại file App.tsx ngoài cùng để trỏ vào component Main vừa tạo thông qua Alias @/main:
import { Main } from '@/main';
export default function App() {
return (
<Main />
);
}
Đừng quên khởi động lại server Expo để Babel nhận cấu hình mới. Nhấn Ctrl + C để tắt process và chạy lại lệnh:
yarn start
Cài đặt React Navigation và Bottom Tabs
Hệ thống điều hướng (Routing) là xương sống của mọi ứng dụng. Chúng ta sẽ sử dụng thư viện phổ biến nhất hiện nay là @react-navigation.
yarn add @react-navigation/native @react-navigation/bottom-tabs react-native-safe-area-context
Do @react-navigation/bottom-tabs yêu cầu thư viện lõi react-native-screens, và để đảm bảo tương thích hoàn hảo với phiên bản Expo hiện tại, bạn nên dùng lệnh npx expo install để cài đặt đích danh phiên bản phù hợp:
npx expo install react-native-screens -- --exact
Chuẩn hoá kiểu dữ liệu Route
Để khai thác sức mạnh của TypeScript, hãy tạo file src/types/route.ts và định nghĩa giao diện chuẩn cho mọi Route trong ứng dụng:
import { type ComponentType } from "react";
export interface IRoute {
// Đây là component sẽ làm màn chình cho route
component: ComponentType;
// Tên, key, title của route
name: string;
}
Tạo các Màn hình (Screens)
Tạo thư mục src/screens. Giả sử ứng dụng của chúng ta có 4 tab chính: Home, Chat, Shop và Profile. Hãy lần lượt tạo 4 file index tương ứng:
// src/screens/home/index.tsx
import { Text, View } from "react-native";
export function HomeScreen() {
return (
<View>
<Text>Home screen</Text>
</View>
);
}
// src/screens/chat/index.tsx
import { Text, View } from "react-native";
export function ChatScreen() {
return (
<View>
<Text>Chat screen</Text>
</View>
);
}
// src/screens/shop/index.tsx
import { Text, View } from "react-native";
export function ShopScreen() {
return (
<View>
<Text>Shop screen</Text>
</View>
);
}
// src/screens/profile/index.tsx
import { Text, View } from "react-native";
export function ProfileScreen() {
return (
<View>
<Text>Profile screen</Text>
</View>
);
}
Cấu hình hệ thống Routes
Tạo file cấu hình src/routes.ts để tập hợp tất cả các màn hình lại thành một danh sách quản lý tập trung:
// src/routes.ts
import { type IRoute } from "@/types/route";
import { HomeScreen } from "@/screens/home";
import { ChatScreen } from "@/screens/chat";
import { ShopScreen } from "@/screens/shop";
import { ProfileScreen } from "@/screens/profile";
export const routes: IRoute[] = [
{
component: HomeScreen,
name: "Home",
},
{
component: ChatScreen,
name: "Chat",
},
{
component: ShopScreen,
name: "Shop",
},
{
component: ProfileScreen,
name: "Profile",
},
];
Tích hợp Navigation vào App
Quay lại file src/main.tsx, chúng ta cần bọc toàn bộ ứng dụng bằng <NavigationContainer>:
import { NavigationContainer } from '@react-navigation/native';
Khởi tạo đối tượng Tab thông qua hàm createBottomTabNavigator:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
const Tab = createBottomTabNavigator();
Import cấu hình routes vừa tạo:
import { routes } from "@/routes";
Bây giờ, chúng ta dùng vòng lặp để render các màn hình tự động thay vì phải khai báo thủ công từng tab một:
return (
<NavigationContainer>
<Tab.Navigator>
{routes.map((route) => (
<Tab.Screen key={route.name} name={route.name} component={route.component} />
))}
</Tab.Navigator>
</NavigationContainer>
);
Tóm lại, file src/main.tsx hoàn chỉnh của bạn lúc này sẽ trông như sau:
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';
const Tab = createBottomTabNavigator();
export function Main() {
return (
<NavigationContainer>
<StatusBar style="auto" />
<Tab.Navigator>
{routes.map((route) => (
<Tab.Screen key={route.name} name={route.name} component={route.component} />
))}
</Tab.Navigator>
</NavigationContainer>
);
}
Sau khi lưu lại, bạn sẽ thấy thanh Bottom Tabs xuất hiện ở dưới cùng màn hình với 4 thẻ tương ứng.
Tuỳ biến Tab Icons nâng cao
Mặc định, cách nhanh nhất để thêm icon là khai báo chuỗi (string) đại diện cho icon từ bộ @expo/vector-icons vào thuộc tính options:
options={{tabBarIcon: ({color, size}) => <MaterialCommunityIcons name={route.icon} color={color} size={size} />}}
Tuy nhiên, cách tiếp cận này khá hạn chế nếu bạn muốn dùng icon Custom dạng SVG của riêng mình. Để giải quyết triệt để, chúng ta sẽ định nghĩa lại hàm render Icon linh hoạt hơn. Hãy cập nhật lại src/types/route.ts:
interface RouteIconProps {
color: string;
size: number;
focused: boolean;
}
import type { ComponentType } from "react";
export interface RouteIconProps {
color: string;
size: number;
focused: boolean;
}
export interface IRoute {
// Đây là component sẽ làm màn chình cho route
component: ComponentType;
// Tên, key, title của route
name: string;
// Đây là icon của route, nó sẽ được render trên bottom tabs
icon: (props: RouteIconProps) => ReactNode;
}
Bây giờ, thay vì khai báo icon chung chung, chúng ta sẽ tạo riêng một file tab-icon.tsx trong từng thư mục screen. Việc này giúp đóng gói logic hiển thị icon (active/inactive) đi kèm ngay sát với màn hình đó.
// src/screens/home/tab-icon.tsx
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";
export function HomeTabIcon(props: RouteIconProps) {
const { color, size, focused } = props;
return <MaterialCommunityIcons name={focused ? "home" : "home-outline"} color={color} size={size} />
}
// src/screens/chat/tab-icon.tsx
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";
export function ChatTabIcon(props: RouteIconProps) {
const { color, size, focused } = props;
return <MaterialCommunityIcons name={focused ? "message" : "message-outline"} color={color} size={size} />
}
// src/screens/shop/tab-icon.tsx
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";
export function ShopTabIcon(props: RouteIconProps) {
const { color, size, focused } = props;
return <MaterialCommunityIcons name={focused ? "cart" : "cart-outline"} color={color} size={size} />
}
// src/screens/profile/tab-icon.tsx
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";
export function ProfileTabIcon(props: RouteIconProps) {
const { color, size, focused } = props;
return <MaterialCommunityIcons name={focused ? "account" : "account-outline"} color={color} size={size} />
}
Để giữ file cấu hình routes.ts được sạch sẽ, hãy re-export các icon này ngay trong file index.tsx của từng screen:
// src/screens/home/index.tsx
import { Text, View } from "react-native";
export { HomeTabIcon } from "./tab-icon";
export function HomeScreen() {
return (
<View>
<Text>Home screen</Text>
</View>
);
}
// src/screens/chat/index.tsx
import { Text, View } from "react-native";
export { ChatTabIcon } from "./tab-icon";
export function ChatScreen() {
return (
<View>
<Text>Chat screen</Text>
</View>
);
}
// src/screens/shop/index.tsx
import { Text, View } from "react-native";
export { ShopTabIcon } from "./tab-icon";
export function ShopScreen() {
return (
<View>
<Text>Shop screen</Text>
</View>
);
}
// src/screens/profile/index.tsx
import { Text, View } from "react-native";
export { ProfileTabIcon } from "./tab-icon";
export function ProfileScreen() {
return (
<View>
<Text>Profile screen</Text>
</View>
);
}
Nhờ cấu trúc trên, file src/routes.ts giờ đây cực kỳ gọn gàng và dễ bảo trì:
// src/routes.ts
import { type IRoute } from "@/types/route";
import { HomeScreen, HomeTabIcon } from "@/screens/home";
import { ChatScreen, ChatTabIcon } from "@/screens/chat";
import { ShopScreen, ShopTabIcon } from "@/screens/shop";
import { ProfileScreen, ProfileTabIcon } from "@/screens/profile";
export const routes: IRoute[] = [
{
component: HomeScreen,
name: "Home",
icon: HomeTabIcon
},
{
component: ChatScreen,
name: "Chat",
icon: ChatTabIcon
},
{
component: ShopScreen,
name: "Shop",
icon: ShopTabIcon,
},
{
component: ProfileScreen,
name: "Profile",
icon: ProfileTabIcon,
},
];
Và ở file src/main.tsx, bạn chỉ cần gán thẳng hàm route.icon vào tabBarIcon:
// src/main.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';
const Tab = createBottomTabNavigator();
export function Main() {
return (
<NavigationContainer>
<StatusBar />
<Tab.Navigator>
{routes.map((route) => (
<Tab.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
tabBarIcon: route.icon,
}}
/>
))}
</Tab.Navigator>
</NavigationContainer>
);
}
index.tsx và các file phụ trợ như tab-icon.tsx giúp cô lập logic cực tốt. Thư mục src/screens chỉ chứa các thành phần "độc quyền" của trang đó, còn những gì dùng chung sẽ được đưa vào src/components.
Quản lý Theme với Ecosy State Management
Tiếp theo, chúng ta sẽ tích hợp hệ sinh thái Ecosy để quản lý State và Theme cho toàn bộ ứng dụng. Cài đặt các package cần thiết:
yarn add @ecosy/core @ecosy/store @ecosy/react @ecosy/styled
Bộ tứ quyền lực của Ecosy bao gồm:
- @ecosy/core: Cung cấp các tiện ích nền tảng (Utilities) như Http, Subscriber (Pub/Sub pattern), Serialize, Slugify,...
- @ecosy/store: Core State Management độc lập, không phụ thuộc React. Kế thừa cơ chế Pub/Sub giúp state mang tính phản ứng (reactive) tức thì.
- @ecosy/react: Cầu nối giữa Store và React thông qua hook
useSelector(Tương tự Redux Toolkit) để tự động re-render component khi dữ liệu thay đổi. - @ecosy/styled: Hệ thống Styled Components kèm theo công cụ quản lý Theme tích hợp sẵn.
Khác biệt lớn nhất của Ecosy là bạn không cần bọc ứng dụng bằng một Provider khổng lồ. Các Store hoạt động như những Singleton độc lập, gọi là có, dùng là ăn ngay.
Tích hợp Theme vào Navigator
Bộ @ecosy/styled đã tích hợp sẵn theme mặc định. Chúng ta sẽ tách Tab.Navigator ra một component riêng để tránh re-render không cần thiết và dễ dàng styling phần Header theo theme:
// src/main.tsx
import { ReactElement } from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useThemeFactory } from "@ecosy/styled/react-native";
const Tab = createBottomTabNavigator();
interface NavigatorProps {
children: ReactElement[];
}
function Navigator(props: NavigatorProps) {
const { children } = props;
const screenOptions = useThemeFactory(({ palette }) => ({
headerStyle: {
backgroundColor: palette.primary,
},
headerTintColor: palette.onPrimary,
}));
return (
<Tab.Navigator screenOptions={screenOptions}>
{children}
</Tab.Navigator>
);
}
Đưa component Navigator vừa tạo vào cấu trúc Main:
// src/main.tsx
export function Main() {
return (
<NavigationContainer>
<StatusBar style="auto" />
<Navigator>
{routes.map((route) => (
<Tab.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
tabBarIcon: route.icon,
}}
/>
))}
</Navigator>
</NavigationContainer>
);
}
Thành quả file src/main.tsx tổng hợp:
// src/main.tsx
import { ReactElement } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { useThemeFactory } from '@ecosy/styled/react-native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';
const Tab = createBottomTabNavigator();
interface NavigatorProps {
children: ReactElement[];
}
function Navigator(props: NavigatorProps) {
const { children } = props;
const screenOptions = useThemeFactory(({ palette }) => ({
headerStyle: {
backgroundColor: palette.primary,
},
headerTintColor: palette.onPrimary,
}));
return (
<Tab.Navigator screenOptions={screenOptions}>
{children}
</Tab.Navigator>
);
}
export function Main() {
return (
<NavigationContainer>
<StatusBar style="auto" />
<Navigator>
{routes.map((route) => (
<Tab.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
tabBarIcon: route.icon,
}}
/>
))}
</Navigator>
</NavigationContainer>
);
}
Xây dựng Base Screen Component
Để đảm bảo mọi màn hình đều có padding và background màu sắc đồng nhất theo theme hiện hành, hãy tạo một component Screen bọc ngoài (Wrapper Component):
import { PropsWithChildren } from "react";
import { makeStyles, View, ViewProps } from "@ecosy/styled/react-native";
import { StyleSheet } from "react-native";
const useStyles = makeStyles(({ palette }) => ({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: palette.background,
},
}));
export interface ScreenProps extends ViewProps {}
export function Screen(props: PropsWithChildren<ScreenProps>) {
const { children, style, ...rest } = props;
const { styles } = useStyles();
return (
<View {...rest} style={StyleSheet.flatten([styles.container, style])}>
{children}
</View>
);
}
Sau này, khi cần bổ sung KeyboardAvoidingView hay SafeAreaView, bạn chỉ cần sửa duy nhất tại component Screen này.
Phát triển Component Button đa biến (Variants)
Ecosy cung cấp hàm variants cho phép bạn xây dựng các UI component phức tạp với nhiều trạng thái khác nhau một cách dễ dàng, tương tự như Tailwind hay Stitches:
// src/components/button/index.tsx
import { forwardRef } from "react";
import { StyleSheet, View as RNView } from "react-native";
import {
Text,
TouchableOpacity,
type TextProps,
type TouchableOpacityProps,
variants,
useTheme
} from "@ecosy/styled/react-native";
const buttonContainerVariants = variants({
alignItems: "center",
justifyContent: "center",
}, {
variants: {
type: {
primary: ({ palette }) => ({ backgroundColor: palette.primary }),
secondary: ({ palette }) => ({ backgroundColor: palette.secondary }),
outline: ({ palette, components }) => ({
borderWidth: components.button.md.borderWidth || 1,
borderColor: palette.primary,
backgroundColor: "transparent",
}),
},
size: {
"2xs": ({ components }) => ({
height: components.button["2xs"].height,
borderRadius: components.button["2xs"].radius,
paddingHorizontal: components.button["2xs"].paddingHorizontal,
}),
xs: ({ components }) => ({
height: components.button.xs.height,
borderRadius: components.button.xs.radius,
paddingHorizontal: components.button.xs.paddingHorizontal,
}),
sm: ({ components }) => ({
height: components.button.sm.height,
borderRadius: components.button.sm.radius,
paddingHorizontal: components.button.sm.paddingHorizontal,
}),
md: ({ components }) => ({
height: components.button.md.height,
borderRadius: components.button.md.radius,
paddingHorizontal: components.button.md.paddingHorizontal,
}),
lg: ({ components }) => ({
height: components.button.lg.height,
borderRadius: components.button.lg.radius,
paddingHorizontal: components.button.lg.paddingHorizontal,
}),
xl: ({ components }) => ({
height: components.button.xl.height,
borderRadius: components.button.xl.radius,
paddingHorizontal: components.button.xl.paddingHorizontal,
}),
"2xl": ({ components }) => ({
height: components.button["2xl"].height,
borderRadius: components.button["2xl"].radius,
paddingHorizontal: components.button["2xl"].paddingHorizontal,
}),
},
fullWidth: {
true: { width: "100%" },
false: { alignSelf: "flex-start" },
}
},
defaultVariants: {
type: "primary",
size: "md",
fullWidth: "true",
}
});
const buttonTextVariants = variants({
fontWeight: "700",
}, {
variants: {
type: {
primary: ({ palette }) => ({ color: palette.onPrimary }),
secondary: ({ palette }) => ({ color: palette.onSecondary }),
outline: ({ palette }) => ({ color: palette.primary }),
},
size: {
"2xs": ({ components }) => ({ fontSize: components.button["2xs"].fontSize }),
xs: ({ components }) => ({ fontSize: components.button.xs.fontSize }),
sm: ({ components }) => ({ fontSize: components.button.sm.fontSize }),
md: ({ components }) => ({ fontSize: components.button.md.fontSize }),
lg: ({ components }) => ({ fontSize: components.button.lg.fontSize }),
xl: ({ components }) => ({ fontSize: components.button.xl.fontSize }),
"2xl": ({ components }) => ({ fontSize: components.button["2xl"].fontSize }),
}
},
defaultVariants: {
type: "primary",
size: "md",
}
});
export interface ButtonProps extends TouchableOpacityProps {
components?: {
text?: TextProps;
};
type?: "primary" | "secondary" | "outline";
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
fullWidth?: boolean;
}
export const Button = forwardRef<RNView, ButtonProps>(
function Button(props, ref) {
const { style, children, components = {}, type = "primary", size = "md", fullWidth = true, ...rest } = props;
const theme = useTheme();
const containerStyle = buttonContainerVariants(theme, { type, size, fullWidth: fullWidth ? "true" : "false" });
const textStyle = buttonTextVariants(theme, { type, size });
return (
<TouchableOpacity
{...rest}
ref={ref}
style={StyleSheet.flatten([containerStyle, style])}
>
<Text {...components.text} style={StyleSheet.flatten([textStyle, components.text?.style])}>
{children}
</Text>
</TouchableOpacity>
);
}
);
Giờ đây, chúng ta có thể áp dụng ngay Button đa năng này vào màn hình Profile để tạo tính năng chuyển đổi giao diện (Toggle Theme) giữa chế độ Sáng và Tối:
// src/screens/profile/switch-theme.tsx
import { View, actions } from "@ecosy/styled/react-native";
import { Button } from "@/components/button";
export function SwitchTheme() {
return (
<View py={8}>
<Button onPress={actions.toggleTheme}>
Toggle theme
</Button>
</View>
);
}
Tiếp theo, hãy nhúng component SwitchTheme vừa tạo vào cấu trúc của màn hình ProfileScreen:
// src/screens/profile/index.tsx
import { SwitchTheme } from "./switch-theme";
import { Screen } from "@/components/screen";
import { Text } from "@ecosy/styled/react-native";
export function ProfileScreen() {
return (
<Screen>
<Text>Profile screen</Text>
<SwitchTheme />
</Screen>
);
}
Chỉ với vài dòng code đơn giản, khi truy cập vào tab Profile và nhấn nút Toggle Theme, toàn bộ màu nền và chữ của ứng dụng sẽ lập tức biến đổi mượt mà theo cấu hình theme tương ứng nhờ sức mạnh của Ecosy Store.
Tuy nhiên, thanh điều hướng dưới cùng (Bottom Tabs) mặc định vẫn chưa đồng bộ màu sắc. Để giải quyết vấn đề này, giải pháp tối ưu nhất là tạo một Custom TabBar hoàn toàn mới, kế thừa màu sắc linh hoạt từ @ecosy/styled:
// src/components/tabbar/index.tsx
import { makeStyles, Text, TouchableOpacity, View } from "@ecosy/styled/react-native";
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
const useStyles = makeStyles(({ palette }) => ({
container: {
flexDirection: "row",
height: 90,
borderTopWidth: 1,
borderTopColor: palette.border,
backgroundColor: palette.surface,
paddingBottom: 22,
},
tab: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 8,
},
label: {
fontSize: 12,
fontWeight: "600",
marginTop: 4,
},
icon: {
marginBottom: 2,
},
}));
export function TabBar(props: BottomTabBarProps) {
const { state, descriptors, navigation } = props;
const { styles, theme } = useStyles();
const colors = theme.palette;
return (
<View style={styles.container}>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const focused = state.index === index;
const label = options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const onPress = () => {
const event = navigation.emit({
type: "tabPress",
target: route.key,
canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const renderedLabel = typeof label === "function" ? label({
focused,
color: focused ? colors.primary : colors.text,
position: "below-icon",
children: options.title || "",
}) : label;
return (
<TouchableOpacity key={route.key} onPress={onPress} style={styles.tab}>
{options.tabBarIcon?.({
focused,
color: focused ? colors.primary : colors.textSecondary,
size: 24
}) || null}
<Text c={focused ? colors.primary : colors.text} style={styles.label}>
{renderedLabel}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}
Cuối cùng, tích hợp TabBar tùy chỉnh này vào cấu trúc Navigator trong main.tsx:
// src/main.tsx
import { TabBar } from "@/components/tabbar";
function Navigator(props: NavigatorProps) {
const { children } = props;
const screenOptions = useThemeFactory(({ palette }) => ({
headerStyle: {
backgroundColor: palette.primary,
},
headerTintColor: palette.onPrimary,
}));
return (
<Tab.Navigator
tabBar={(props) => <TabBar {...props} />}
screenOptions={screenOptions}
>
{children}
</Tab.Navigator>
);
}
Mặc định, @ecosy/styled khởi tạo ứng dụng bằng bộ màu slate tinh tế. Tuy nhiên, bạn hoàn toàn có thể thổi luồng gió mới cho ứng dụng bằng cách import các bảng màu khác được thiết kế sẵn (ví dụ: red, emerald, amber...):
// src/main.tsx
import { useThemeFactory, actions } from "@ecosy/styled/react-native";
import red from "@ecosy/styled/theme/red";
actions.setThemes(red);
Tuyệt vời hơn nữa, sự linh hoạt của @ecosy/styled không chỉ dừng lại ở các theme có sẵn. Bạn hoàn toàn có quyền tự do định nghĩa các bảng màu mang đậm bản sắc thương hiệu (Custom Theme) của dự án và nạp vào hệ thống:
// src/theme/brand.ts
import { type ThemeDefinition } from "@ecosy/styled";
import { defaultComponents } from "@ecosy/styled/theme/default";
export const brandTheme: ThemeDefinition = {
light: {
palette: {
primary: "#E11D48",
onPrimary: "#FFFFFF",
background: "#FFF1F2",
surface: "#FFFFFF",
text: "#1F2937",
// ... định nghĩa các token màu khác
},
components: defaultComponents
},
dark: {
palette: {
primary: "#F43F5E",
onPrimary: "#FFFFFF",
background: "#0F172A",
surface: "#1E293B",
text: "#F8FAFC",
// ... định nghĩa các token màu khác
},
components: defaultComponents
}
};
actions.setThemes(brandTheme), Ecosy sẽ ghi đè (override) hoàn toàn cấu hình theme cũ. Tuy nhiên, nhờ cơ chế Deep Merge State thông minh, bạn không nhất thiết phải khai báo lại toàn bộ token. Chỉ cần định nghĩa những thuộc tính muốn thay đổi (ví dụ: primary) và cập nhật thông qua store.setState(), Ecosy sẽ tự động trộn (merge) và kế thừa các thuộc tính còn thiếu từ cấu hình mặc định.
Sau đó kích hoạt bản vá theme vừa tạo thông qua `store.setState`:
import { store } from "@ecosy/styled/react-native";
import { brandTheme } from "./theme/brand";
store.setState({ themes: brandTheme });
Điểm sáng giá nhất của kiến trúc này nằm ở cơ chế Pub/Sub ngầm. Bất kỳ sự thay đổi nào từ Store (như khi gọi hàm setThemes) đều ngay lập tức được truyền tải đến hệ thống State nội bộ. Ecosy sẽ tự động kích hoạt tiến trình render lại nhưng chỉ tập trung chính xác vào các component React đang đăng ký theo dõi (subscribe) màu sắc. Nhờ thiết kế tách bạch này, trải nghiệm chuyển đổi giao diện ứng dụng diễn ra chớp nhoáng mà không cần phụ thuộc vào một Context Provider khổng lồ bọc bên ngoài.
Mã nguồn tham khảo (Template)
Nếu bạn muốn bắt tay vào thực hành ngay mà không cần mất thời gian thiết lập từ đầu, bạn có thể clone trực tiếp các template repository hoàn chỉnh mà chúng tôi đã chuẩn bị sẵn:
- Môi trường Expo: material-atomic/expo-navigation-bottom-tabs
- Môi trường React Native CLI: material-atomic/rncli-navigation-bottom-tabs