Guide to Cross-Platform Development Using React Native
14 minutes
Mobile
Sep 16, 2024
12 minutes
npx react-native init react-native-web-example
<!DOCTYPE html>
<html>
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Test React Native Web</title> <style> html, body { height: 100%; } /* These styles disable body scrolling if you are using <ScrollView> */ body { overflow: hidden; } /* These styles make the root element full-height */ #app-root { display: flex; flex: 1 1 100%; height: 100vh; } input { outline: none; } </style>
</head>
<body>
<div id="app-root">
</div>
<script src="./index.web.js" type="module"></script>
</body>
</html>
import { AppRegistry } from "react-native";
import name from "./app.json";
import App from "./App";
import { enableExperimentalWebImplementation } from "react-native-gesture-handler";
enableExperimentalWebImplementation(true);
AppRegistry.registerComponent(name, () => App);
AppRegistry.runApplication(name, { initialProps: {}, rootTag: document.getElementById("app-root"),
});
// vite.config.js
import reactNativeWeb from "vite-plugin-react-native-web";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import commonjs from "vite-plugin-commonjs";
export default defineConfig({ commonjsOptions: { transformMixedEsModules: true }, plugins: [ reactNativeWeb(), react({ babel: { plugins: [ "react-native-reanimated/plugin", "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-export-namespace-from", ], }, }), commonjs(), ],
});
module.exports = { presets: ["module:@react-native/babel-preset"], plugins: [ "@babel/plugin-proposal-export-namespace-from", "react-native-reanimated/plugin", ],
};
{
...
"scripts": { ...
"web:dev": "vite dev", "web:build": "vite build", "web:preview": "vite build && vite preview"
...
}
...
}
<head>
<!-- paste here --> <script src="https://telegram.org/js/telegram-web-app.js"></script> ...
</head>
...
type TelegramTheme = { bg_color: string; text_color: string; hint_color: string; link_color: string; button_color: string; button_text_color: string; secondary_bg_color: string; header_bg_color: string; accent_text_color: string; section_bg_color: string; section_header_text_color: string; section_separator_color: string; subtitle_text_color: string; destructive_text_color: string;
};
type WebAppUser = { id: number; is_bot: boolean; first_name: string; last_name: string; username: string; is_premium: boolean; photo_url: string;
};
type WebappData = { user: WebAppUser;
};
type TelegramHapticFeedback = { impactOccurred: ( style: "light" | "medium" | "rigid" | "heavy" | "soft", ) => void; notificationOccurred: (type: "error" | "success" | "warning") => void;
};
type TelegramWebapp = { initData: string; initDataUnsafe: WebappData; version: string; platform: string; themeParams: TelegramTheme; headerColor: string; backgroundColor: string; expand: () => void; close: () => void; HapticFeedback: TelegramHapticFeedback;
};
type Window = { Telegram?: { WebApp: TelegramWebapp; };
};
declare var window: Window;
import { Platform } from "react-native";
export const MockConfig = { themeParams: { bg_color: "#000", secondary_bg_color: "#1f1f1f", section_bg_color: "#000", section_separator_color: "#8b8b8b", header_bg_color: "#2c2c2c", text_color: "#fff", hint_color: "#949494", link_color: "", button_color: "#358ffe", button_text_color: "", accent_text_color: "#0f75f1", section_header_text_color: "", subtitle_text_color: "", destructive_text_color: "", }, initDataUnsafe: { user: { username: "MockUser", is_premium: false, photo_url: "", first_name: "", last_name: "", id: 0, }, },
} as TelegramWebapp;
export const config = () => { if (Platform.OS !== "web") { return MockConfig; } if (window.Telegram?.WebApp.initData) { return window.Telegram?.WebApp; } else { return MockConfig; }
};
pnpm add @react-navigation/native-stack @react-navigation/native react-native-screens
pnpm add react-native-reanimated react-native-gesture-handler
module.exports = { presets: ["module:@react-native/babel-preset"],
// add plugins here plugins: [ "@babel/plugin-proposal-export-namespace-from", "react-native-reanimated/plugin", ],
};
cd ios && pod install && cd ..
import { Image, StyleSheet, Text, View } from "react-native";
import { config } from "../../config";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React from "react";
type HeaderProps = { amount: number;
};
export const Header: React.FC<HeaderProps> = ({ amount }) => { const insets = useSafeAreaInsets(); const paddingTop = Math.max(20, insets.top); const { username, photo_url } = config().initDataUnsafe.user; return ( <View style={[styles.header, { paddingTop }]}> <View style={styles.amountRow}> <Image source={require("../../assets/icons/coin.png")} style={{ height: 40, width: 40 }} /> <Text style={styles.text}>{amount}</Text> </View> <View style={styles.userInfo}> <Text style={styles.username}>@{username}</Text> {photo_url ? ( <Image style={[styles.image, { backgroundColor: "transparent" }]} source={{ uri: photo_url, }}></Image> ) : ( <View style={styles.image}> <Image style={styles.icon} source={require("../../assets/icons/profile-placeholder.png")} /> </View> )} </View> </View> );
};
const styles = StyleSheet.create({ header: { backgroundColor: config().themeParams?.header_bg_color, paddingHorizontal: 20, flexDirection: "row", alignItems: "center", paddingBottom: 20, justifyContent: "space-between", }, amountRow: { flexDirection: "row", alignItems: "center", }, text: { fontSize: 24, fontWeight: "600", color: config().themeParams?.text_color, }, userInfo: { flexDirection: "row", alignItems: "center", gap: 20, }, username: { color: config().themeParams.accent_text_color, fontSize: 18, }, image: { backgroundColor: config().themeParams.button_color, height: 50, width: 50, justifyContent: "center", alignItems: "center", borderRadius: 50, }, icon: { height: 30, width: 30, tintColor: config().themeParams.text_color, },
});
import React, { useState } from "react";
import { Dimensions, GestureResponderEvent, Image, Platform, Pressable, StyleSheet, Text, View,
} from "react-native";
import Animated, { SlideOutUp, useAnimatedStyle, useSharedValue, withTiming,
} from "react-native-reanimated";
import { generateUuid } from "../utils";
import { config } from "../../config";
import { useHaptics } from "../useHaptics";
import { ImpactFeedbackStyle } from "expo-haptics";
//animated component to have ability use animated style from Reanimated package
const AnimatedButton = Animated.createAnimatedComponent(Pressable);
const sensitivity = Platform.OS == "web" ? 0.1 : 0.2;
const animationConfig = { duration: 100,
};
/**
* @prop onClick - what happened on click the coin
* @prop disabled - when coin can be clicked or not
*/
type CoinProps = { onClick: () => void; disabled?: boolean;
};
export const Coin: React.FC<CoinProps> = ({ disabled, onClick }) => { const [number, setNumber] = useState< { id: string; x: number; y: number } | undefined >(undefined); const [showNumber, setShowNumber] = useState(false); const width = Dimensions.get("window").width - 50; //setting coin size based on window and check web compatibility const size = width > 1000 ? 1000 : width; const center = size / 2; //shared values to use in coin animation const rotateX = useSharedValue(0); const rotateY = useSharedValue(0); const { impactOccurred } = useHaptics(); const handlePressIn = async (e: GestureResponderEvent) => { await impactOccurred(ImpactFeedbackStyle.Light); const { locationX, locationY } = e.nativeEvent; //getting rotate amount by x axis const deltaX = locationX - center; //getting rotate amount by y axis const deltaY = locationY - center; if (Platform.OS === "web") { rotateY.value = deltaX * sensitivity; rotateX.value = -deltaY * sensitivity; } else { rotateY.value = withTiming(deltaX * sensitivity, animationConfig); rotateX.value = withTiming(-deltaY * sensitivity, animationConfig); } //set number position && unique id to have no problems with keys setNumber({ id: generateUuid(), x: locationX, y: locationY }); }; const handlePressOut = (e: GestureResponderEvent) => { setShowNumber(true); if (Platform.OS === "web") { rotateX.value = 0; rotateY.value = 0; } else { rotateX.value = withTiming(0, animationConfig); rotateY.value = withTiming(0, animationConfig); } onClick(); // use timeout to not remove element on render start setTimeout(() => { //set values undefined to launch exiting animation setNumber(undefined); setShowNumber(false); }, 10); }; //style to define coin rotation const rotateStyle = useAnimatedStyle( () => ({ position: "relative", transform: [ { rotateY: `${rotateY.value}deg`, }, { rotateX: `${rotateX.value}deg`, }, ], }), [rotateX, rotateY], ); return ( <View style={styles.container}> <AnimatedButton style={[rotateStyle]} disabled={disabled} onPressIn={handlePressIn} onPressOut={handlePressOut}> <Image source={require("../../assets/icons/coin.png")} style={{ height: size, width: size }}></Image> </AnimatedButton> {!!number && showNumber && ( <Animated.View exiting={SlideOutUp.duration(500)} key={number.id} style={{ position: "absolute", top: number.y, left: number.x, zIndex: 1000, }}> <Text style={[styles.text]}>+1</Text> </Animated.View> )} </View> );
};
const styles = StyleSheet.create({ container: { position: "relative", }, text: { fontSize: 26, fontWeight: "600", //getting text color from Telegram client color: config().themeParams?.hint_color, },
});
import React, { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { config } from "../../config";
type ProgressProps = { max?: number; amount: number;
};
export const Progress: React.FC<ProgressProps> = ({ max = 3500, amount }) => { const [width, setWidth] = useState(0); return ( <View style={styles.container} onLayout={e => setWidth(e.nativeEvent.layout.width)}> <Text style={[styles.text, { width }]}> {amount} / {max} </Text> <View style={[styles.progress, { width: (amount / max) * width }]}> <Text style={[styles.text, styles.progressText, { width }]}> {amount} / {max} </Text> </View> </View> );
};
const styles = StyleSheet.create({ container: { height: 70, borderColor: config().themeParams.accent_text_color, backgroundColor: config().themeParams.section_bg_color, borderWidth: 2, borderRadius: 70, overflow: "hidden", position: "relative", justifyContent: "center", }, progress: { height: "100%", backgroundColor: config().themeParams.accent_text_color, width: 200, borderRadius: 20, position: "absolute", justifyContent: "center", overflow: "hidden", }, text: { fontWeight: "700", fontSize: 24, color: config().themeParams.accent_text_color, textAlign: "center", }, progressText: { textAlign: "center", color: config().themeParams.text_color, },
});
import React from "react";
import { StyleSheet, View, ViewProps } from "react-native";
import { config } from "../../config";
const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: config().themeParams?.secondary_bg_color, },
});
export const Screen: React.FC<ViewProps> = ({ children, style }) => { return <View style={[styles.screen, style]}>{children}</View>;
};
import { StatusBar, StyleSheet, View } from "react-native";
import { Coin, Header, Progress, Screen } from "../components";
import { config } from "../../config";
import { useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MAX_CLICK_AMOUNT = 3500;
export const HomeScreen = () => { //total amount of coins const [amount, setAmount] = useState(0); //amount of clicks const [clickedAmount, setClickedAmount] = useState(0); const insets = useSafeAreaInsets(); const paddingBottom = Math.max(20, insets.bottom); //what happened when we press coin const handleClick = () => { setAmount(prev => prev + 1); setClickedAmount(prev => prev + 1); }; return ( <> <StatusBar backgroundColor={config().themeParams.header_bg_color} /> <Screen style={{ paddingBottom }}> <Header amount={amount} /> <View style={styles.screen}> <View style={styles.coin}> <Coin disabled={clickedAmount >= MAX_CLICK_AMOUNT} onClick={handleClick}></Coin> </View> <View style={styles.footer}> <Progress amount={clickedAmount} /> </View> </View> </Screen> </> );
};
const styles = StyleSheet.create({ screen: { flex: 1, gap: 20, }, coin: { flex: 1, backgroundColor: config().themeParams.bg_color, alignItems: "center", justifyContent: "center", }, footer: { padding: 20, },
});
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { HomeScreen } from "./screens/HomeScreen";
const RootStack = createNativeStackNavigator();
export const RootNavigator = () => { return ( <RootStack.Navigator screenOptions={{ headerShown: false }}> <RootStack.Screen name="Home" component={HomeScreen}></RootStack.Screen> </RootStack.Navigator> );
};
import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { NavigationContainer } from "@react-navigation/native";
import { RootNavigator } from "./RootNavigator";
import { config } from "../config";
export default function App() { useEffect(() => { config().expand(); }, []); return ( <SafeAreaProvider> <NavigationContainer> <RootNavigator /> </NavigationContainer> </SafeAreaProvider> );
}
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import { impactAsync, notificationAsync, NotificationFeedbackType, ImpactFeedbackStyle,
} from "expo-haptics";
type Haptics = { impactOccurred: (style: ImpactFeedbackStyle) => Promise<void>; notificationOccurred: (type: NotificationFeedbackType) => Promise<void>;
};
export const useHaptics = () => { const [haptics, setHaptics] = useState<Haptics>({ impactOccurred: async _ => {}, notificationOccurred: async _ => {}, }); useEffect(() => { if (Platform.OS == "web") { if (window.Telegram?.WebApp.HapticFeedback) { setHaptics(window.Telegram.WebApp.HapticFeedback); return; } } const impact = async (style: ImpactFeedbackStyle) => await impactAsync(style); const notification = async (type: NotificationFeedbackType) => await notificationAsync(type); setHaptics({ impactOccurred: impact, notificationOccurred: notification }); }, []); return haptics;
};