├── .gitignore ├── README.md ├── app.json ├── app ├── _layout.tsx └── index.tsx ├── assets ├── fonts │ ├── Geist-Bold.ttf │ ├── Geist-Medium.ttf │ ├── Geist-Regular.ttf │ ├── Geist-SemiBold.ttf │ └── SpaceMono-Regular.ttf └── images │ ├── adaptive-icon.png │ ├── dp.png │ ├── favicon.png │ ├── icon.png │ ├── partial-react-logo.png │ ├── react-logo.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ ├── splash-icon.png │ └── twitter-cover.png ├── components ├── ExternalLink.tsx ├── ResponsiveText.tsx ├── ThemedText.tsx ├── ThemedView.tsx └── ui │ ├── Article.tsx │ ├── FlowBar.tsx │ ├── Header.tsx │ └── TimeFlow.tsx ├── constants ├── Colors.ts └── index.ts ├── hooks ├── useColorScheme.ts ├── useColorScheme.web.ts ├── useFontScale.ts └── useThemeColor.ts ├── media ├── demo.gif └── demo.mp4 ├── package-lock.json ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quick Demo 2 | 3 | Demo animation 4 | 5 | --- 6 | 7 | # Welcome to your Expo app 👋 8 | 9 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). 10 | 11 | ## Get started 12 | 13 | 1. Install dependencies 14 | 15 | ```bash 16 | npm install 17 | ``` 18 | 19 | 2. Start the app 20 | 21 | ```bash 22 | npx expo start 23 | ``` 24 | 25 | In the output, you'll find options to open the app in a 26 | 27 | - [development build](https://docs.expo.dev/develop/development-builds/introduction/) 28 | - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) 29 | - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) 30 | - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo 31 | 32 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). 33 | 34 | ## Get a fresh project 35 | 36 | When you're ready, run: 37 | 38 | ```bash 39 | npm run reset-project 40 | ``` 41 | 42 | This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. 43 | 44 | ## Learn more 45 | 46 | To learn more about developing your project with Expo, look at the following resources: 47 | 48 | - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). 49 | - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. 50 | 51 | ## Join the community 52 | 53 | Join our community of developers creating universal apps. 54 | 55 | - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. 56 | - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. 57 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-blog", 4 | "slug": "expo-blog", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "foregroundImage": "./assets/images/adaptive-icon.png", 17 | "backgroundColor": "#ffffff" 18 | } 19 | }, 20 | "web": { 21 | "bundler": "metro", 22 | "output": "static", 23 | "favicon": "./assets/images/dp.png" 24 | }, 25 | "plugins": [ 26 | "expo-router", 27 | [ 28 | "expo-splash-screen", 29 | { 30 | "image": "./assets/images/splash-icon.png", 31 | "imageWidth": 200, 32 | "resizeMode": "contain", 33 | "backgroundColor": "#ffffff" 34 | } 35 | ] 36 | ], 37 | "experiments": { 38 | "typedRoutes": true 39 | }, 40 | "extra": { 41 | "router": { 42 | "origin": false 43 | }, 44 | "eas": { 45 | "projectId": "02515bfa-b9a1-4295-b136-8bc869cf4753" 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | ThemeProvider, 5 | } from "@react-navigation/native"; 6 | import { useFonts } from "expo-font"; 7 | import { Stack } from "expo-router"; 8 | import * as SplashScreen from "expo-splash-screen"; 9 | import { StatusBar } from "expo-status-bar"; 10 | import { useEffect } from "react"; 11 | import "react-native-reanimated"; 12 | import * as NavigationBar from "expo-navigation-bar"; 13 | 14 | import { useColorScheme } from "@/hooks/useColorScheme"; 15 | import { SafeAreaProvider } from "react-native-safe-area-context"; 16 | import { Platform } from "react-native"; 17 | import Head from "expo-router/head"; 18 | 19 | // Prevent the splash screen from auto-hiding before asset loading is complete. 20 | SplashScreen.preventAutoHideAsync(); 21 | 22 | export default function RootLayout() { 23 | const colorScheme = useColorScheme(); 24 | const [loaded] = useFonts({ 25 | GeistBold: require("../assets/fonts/Geist-Bold.ttf"), 26 | GeistSemiBold: require("../assets/fonts/Geist-SemiBold.ttf"), 27 | GeistMedium: require("../assets/fonts/Geist-Medium.ttf"), 28 | GeistRegular: require("../assets/fonts/Geist-Regular.ttf"), 29 | }); 30 | 31 | useEffect(() => { 32 | const setNavBar = async () => { 33 | try { 34 | if (Platform.OS === "android") { 35 | await SplashScreen.preventAutoHideAsync(); 36 | await NavigationBar.setPositionAsync("absolute"); 37 | await NavigationBar.setBackgroundColorAsync("#00000000"); 38 | await NavigationBar.setVisibilityAsync("hidden"); 39 | await NavigationBar.setBehaviorAsync("overlay-swipe"); 40 | } 41 | } catch (e) { 42 | console.warn("Error setting navigation bar:", e); 43 | } finally { 44 | if (loaded) { 45 | await SplashScreen.hideAsync(); 46 | } 47 | } 48 | }; 49 | 50 | setNavBar(); 51 | }, [loaded]); 52 | 53 | if (!loaded) { 54 | return null; 55 | } 56 | 57 | return ( 58 | <> 59 | 60 | 61 | 64 | 65 | 66 | 67 | 69 | 70 | 71 | ); 72 | } 73 | 74 | function HeadComponent() { 75 | return ( 76 | 77 | Expo Blog 78 | 82 | 83 | 84 | {/* Open Graph / Facebook */} 85 | 89 | 90 | 91 | 92 | 96 | 97 | 98 | 99 | {/* Twitter */} 100 | 104 | 105 | 106 | 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, View, ViewToken } from "react-native"; 2 | import React, { useRef, useState } from "react"; 3 | import Animated, { 4 | useAnimatedScrollHandler, 5 | useDerivedValue, 6 | useSharedValue, 7 | } from "react-native-reanimated"; 8 | import Header from "@/components/ui/Header"; 9 | import { BLOG_DATA } from "@/constants"; 10 | import { ThemedView } from "@/components/ThemedView"; 11 | import Article from "@/components/ui/Article"; 12 | import FlowBar from "@/components/ui/FlowBar"; 13 | 14 | const { content, title, sections } = BLOG_DATA; 15 | 16 | export default function Index() { 17 | const lastLoggedIndex = useSharedValue(0); 18 | 19 | const onViewableItemsChanged = ({ 20 | viewableItems, 21 | }: { 22 | viewableItems: ViewToken[]; 23 | }) => { 24 | const lastItem = viewableItems[viewableItems.length - 1]; 25 | 26 | if (lastItem?.isViewable && lastItem.index !== null) { 27 | lastLoggedIndex.value = lastItem.index; 28 | } 29 | }; 30 | 31 | const viewabilityConfigCallbackPairs = React.useRef([ 32 | { 33 | viewabilityConfig: { itemVisiblePercentThreshold: 100 }, 34 | onViewableItemsChanged, 35 | }, 36 | ]); 37 | 38 | const scrollY = useSharedValue(0); 39 | const totalHeight = useSharedValue(1); 40 | 41 | const scrollHandler = useAnimatedScrollHandler({ 42 | onScroll: (event) => { 43 | scrollY.value = event.contentOffset.y; 44 | totalHeight.value = 45 | event.contentSize.height - event.layoutMeasurement.height; 46 | }, 47 | }); 48 | 49 | const progress = useDerivedValue(() => 50 | totalHeight.value > 0 ? scrollY.value / totalHeight.value : 0 51 | ); 52 | 53 | return ( 54 | 55 | ( 57 |
{}} /> 58 | )} 59 | style={{ 60 | flex: 1, 61 | alignSelf: "center", 62 | maxWidth: 640, 63 | }} 64 | contentContainerStyle={{ gap: 16, paddingBottom: 280 }} 65 | data={sections} 66 | renderItem={({ item, index }) => ( 67 |
72 | )} 73 | keyExtractor={(_, index) => index.toString()} 74 | showsVerticalScrollIndicator={false} 75 | viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} 76 | onScroll={scrollHandler} 77 | scrollEventThrottle={16} 78 | /> 79 | 85 | 86 | ); 87 | } 88 | 89 | const styles = StyleSheet.create({}); 90 | -------------------------------------------------------------------------------- /assets/fonts/Geist-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/fonts/Geist-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Geist-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/fonts/Geist-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/Geist-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/fonts/Geist-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/Geist-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/fonts/Geist-SemiBold.ttf -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/dp.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/splash-icon.png -------------------------------------------------------------------------------- /assets/images/twitter-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/assets/images/twitter-cover.png -------------------------------------------------------------------------------- /components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "expo-router"; 2 | import { openBrowserAsync } from "expo-web-browser"; 3 | import { type ComponentProps } from "react"; 4 | import { Platform } from "react-native"; 5 | 6 | type Props = Omit, "href"> & { href: string }; 7 | 8 | export function ExternalLink({ href, ...rest }: Props) { 9 | return ( 10 | { 15 | if (Platform.OS !== "web") { 16 | // Prevent the default behavior of linking to the default browser on native. 17 | event.preventDefault(); 18 | // Open the link in an in-app browser. 19 | await openBrowserAsync(href); 20 | } 21 | }} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/ResponsiveText.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { Text, View, TextProps, Platform } from "react-native"; 3 | import { ThemedText, ThemedTextProps } from "./ThemedText"; 4 | 5 | type ResponsiveTextProps = { 6 | text: string; 7 | baseSize?: number; 8 | } & TextProps & 9 | ThemedTextProps; 10 | 11 | const ResponsiveText: React.FC = ({ 12 | text, 13 | baseSize = 16, 14 | style, 15 | ...props 16 | }) => { 17 | const [width, setWidth] = useState(0); 18 | const fontSize = Math.max( 19 | 12, 20 | Math.min(32, baseSize * (width / 300) - text.length * 0.1) 21 | ); 22 | 23 | return ( 24 | setWidth(e.nativeEvent.layout.width)} 26 | style={{ width: "100%" }} 27 | > 28 | 32 | {text} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default ResponsiveText; 39 | -------------------------------------------------------------------------------- /components/ThemedText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps, StyleSheet, useColorScheme } from "react-native"; 2 | 3 | import { useThemeColor } from "@/hooks/useThemeColor"; 4 | import { useScaleFont } from "@/hooks/useFontScale"; 5 | 6 | export type ThemedTextProps = TextProps & { 7 | lightColor?: string; 8 | darkColor?: string; 9 | invert?: boolean; 10 | light?: boolean; 11 | type?: "default" | "title" | "subtitle" | "link"; 12 | }; 13 | 14 | export function ThemedText({ 15 | style, 16 | lightColor, 17 | darkColor, 18 | invert, 19 | light, 20 | type = "default", 21 | ...rest 22 | }: ThemedTextProps) { 23 | const color = light 24 | ? "#fff" 25 | : useThemeColor( 26 | { light: lightColor, dark: darkColor }, 27 | invert ? "background" : "text" 28 | ); 29 | const scale = useScaleFont(); 30 | const isLight = useColorScheme() === "light"; 31 | 32 | return ( 33 | 48 | ); 49 | } 50 | 51 | const styles = { 52 | default: (light: boolean) => ({ 53 | fontFamily: light ? "GeistSemiBold" : "GeistMedium", 54 | fontSize: 20, 55 | lineHeight: 24, 56 | }), 57 | title: () => ({ 58 | fontFamily: "GeistSemiBold", 59 | fontSize: 32, 60 | lineHeight: 32, 61 | }), 62 | subtitle: (light: boolean) => ({ 63 | fontFamily: light ? "GeistMedium" : "GeistRegular", 64 | fontSize: 14.5, 65 | lineHeight: 24, 66 | }), 67 | link: () => ({ 68 | fontFamily: "GeistRegular", 69 | lineHeight: 30, 70 | fontSize: 16, 71 | color: "#0a7ea4", 72 | }), 73 | }; 74 | -------------------------------------------------------------------------------- /components/ThemedView.tsx: -------------------------------------------------------------------------------- 1 | import { View, type ViewProps } from "react-native"; 2 | 3 | import { useThemeColor } from "@/hooks/useThemeColor"; 4 | 5 | export type ThemedViewProps = ViewProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | invert?: boolean; 9 | }; 10 | 11 | export function ThemedView({ 12 | style, 13 | lightColor, 14 | darkColor, 15 | invert, 16 | ...otherProps 17 | }: ThemedViewProps) { 18 | const backgroundColor = useThemeColor( 19 | { light: lightColor, dark: darkColor }, 20 | invert ? "text" : "background" 21 | ); 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /components/ui/Article.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | import { ThemedText } from "../ThemedText"; 4 | 5 | type ArticleProps = { 6 | title: string; 7 | content: string[]; 8 | index: number; 9 | }; 10 | 11 | const Article: React.FC = ({ title, content }) => { 12 | return ( 13 | 14 | 15 | {title} 16 | 17 | 18 | {content.map((paragraph, i) => ( 19 | 20 | {paragraph} 21 | 22 | ))} 23 | 24 | 25 | ); 26 | }; 27 | 28 | const styles = StyleSheet.create({ 29 | article: { 30 | marginVertical: 10, 31 | paddingHorizontal: 16, 32 | padding: 10, 33 | borderRadius: 5, 34 | }, 35 | section: { 36 | marginTop: 10, 37 | gap: 20, 38 | }, 39 | content: { 40 | opacity: 0.8, 41 | }, 42 | }); 43 | 44 | export default Article; 45 | -------------------------------------------------------------------------------- /components/ui/FlowBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useThemeColor } from "@/hooks/useThemeColor"; 3 | import { Platform, Pressable, StyleSheet, View } from "react-native"; 4 | import { ThemedText } from "../ThemedText"; 5 | import Animated, { 6 | Easing, 7 | interpolate, 8 | SharedValue, 9 | useAnimatedProps, 10 | useAnimatedStyle, 11 | useDerivedValue, 12 | useSharedValue, 13 | withSpring, 14 | withTiming, 15 | } from "react-native-reanimated"; 16 | import { BLOG_DATA } from "@/constants"; 17 | import { LinearGradient } from "expo-linear-gradient"; 18 | import Svg, { Circle } from "react-native-svg"; 19 | import { StyleProp, ViewStyle } from "react-native"; 20 | import { useScaleFont } from "@/hooks/useFontScale"; 21 | import TimeFlow from "./TimeFlow"; 22 | import { Image } from "expo-image"; 23 | 24 | const AnimatedCircle = Animated.createAnimatedComponent(Circle); 25 | const isWeb = Platform.OS === "web"; 26 | 27 | interface FlowBarProps { 28 | currentIndex: SharedValue; 29 | progress: SharedValue; 30 | scrollHeight: SharedValue; 31 | totalSections: number; 32 | } 33 | 34 | const PEEK_VIEW_HEIGHT = 50; 35 | const FULL_VIEW_HEIGHT = 32; 36 | const FULL_VIEW_COVER_HEIGHT = 70; 37 | const FULL_BAR_HEIGHT = 260; 38 | const TIMING_CONFIG = { duration: 250, easing: Easing.out(Easing.ease) }; 39 | const SPRING_CONFIG = { 40 | damping: 18, 41 | mass: 0.5, 42 | stiffness: 180, 43 | }; 44 | 45 | const WORDS_PER_MINUTE = 200; // Average reading speed 46 | 47 | const { author, sections } = BLOG_DATA; 48 | const SECTIONS_TITLE = sections.map((section) => section.title); 49 | const TOTAL_WORDS = sections.reduce((acc, section) => { 50 | return acc + section.content.join(" ").split(/\s+/).length; 51 | }, 0); 52 | 53 | const TOTAL_TIME = Math.ceil(TOTAL_WORDS / WORDS_PER_MINUTE); 54 | 55 | export default function FlowBar({ 56 | currentIndex, 57 | progress, 58 | scrollHeight, 59 | totalSections, 60 | }: FlowBarProps) { 61 | const backgroundColor = useThemeColor({}, "barColor"); 62 | const scaleFont = useScaleFont(); 63 | 64 | const [isExpanded, setIsExpanded] = useState(false); 65 | 66 | const scrollProgress = useDerivedValue(() => Math.max(0, progress.value)); 67 | 68 | const animatedTextStyle = useAnimatedStyle(() => { 69 | return { 70 | width: withSpring(isExpanded ? "93%" : "80%", SPRING_CONFIG), 71 | height: withSpring( 72 | isExpanded ? FULL_BAR_HEIGHT : PEEK_VIEW_HEIGHT, 73 | SPRING_CONFIG 74 | ), 75 | borderRadius: withSpring(isExpanded ? 38 : 60, SPRING_CONFIG), 76 | borderTopLeftRadius: withSpring(isExpanded ? 30 : 60, SPRING_CONFIG), 77 | borderTopRightRadius: withSpring(isExpanded ? 30 : 60, SPRING_CONFIG), 78 | }; 79 | }); 80 | 81 | const scrollTitleViewStyle = useAnimatedStyle(() => { 82 | const translateY = withSpring( 83 | interpolate( 84 | currentIndex.value, 85 | [0, totalSections], 86 | [0, -totalSections * PEEK_VIEW_HEIGHT] 87 | ), 88 | SPRING_CONFIG 89 | ); 90 | 91 | if (isExpanded) return {}; 92 | 93 | return { 94 | transform: [{ translateY }], 95 | }; 96 | }); 97 | 98 | const animatedMainStyle = useAnimatedStyle(() => { 99 | return { 100 | height: withTiming(isExpanded ? 100 : PEEK_VIEW_HEIGHT, TIMING_CONFIG), 101 | padding: withTiming(isExpanded ? 11 : 0, TIMING_CONFIG), 102 | gap: withTiming(isExpanded ? 3 : 0, TIMING_CONFIG), 103 | }; 104 | }); 105 | 106 | const animatedHandleStyle = useAnimatedStyle(() => { 107 | return { 108 | borderRadius: withTiming( 109 | isExpanded ? 12 : PEEK_VIEW_HEIGHT, 110 | TIMING_CONFIG 111 | ), 112 | }; 113 | }); 114 | 115 | const animatedAuthorStyle = useAnimatedStyle(() => { 116 | return { 117 | opacity: withSpring(isExpanded ? 0.5 : 0, SPRING_CONFIG), 118 | transform: [{ translateY: -14 }], 119 | }; 120 | }); 121 | 122 | const scrollFullTitleViewStyle = useAnimatedStyle(() => { 123 | const translateY = withSpring( 124 | interpolate( 125 | currentIndex.value, 126 | [0, totalSections], 127 | [0, -totalSections * FULL_VIEW_HEIGHT] 128 | ), 129 | SPRING_CONFIG 130 | ); 131 | 132 | return { 133 | transform: [{ translateY }], 134 | }; 135 | }); 136 | 137 | return ( 138 | 141 | setIsExpanded((prev) => !prev)}> 142 | 143 | 144 | 145 | 150 | 151 | 152 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | { 166 | 167 | 173 | {author} 174 | 175 | 176 | } 177 | 178 | 179 | 180 | 181 | 186 | 191 | 205 | 206 | 207 | 208 | 209 | 218 | 219 | 220 | 221 | 222 | ); 223 | } 224 | 225 | const SectionList = ({ 226 | height, 227 | small, 228 | }: { 229 | height: number; 230 | small?: boolean; 231 | }): JSX.Element => { 232 | const scaleFont = useScaleFont(); 233 | 234 | return ( 235 | 236 | {SECTIONS_TITLE.map((title, index) => ( 237 | 244 | 256 | {title} 257 | 258 | 259 | ))} 260 | 261 | ); 262 | }; 263 | 264 | const RadialProgress = ({ 265 | progress, 266 | isExpanded, 267 | }: { 268 | progress: SharedValue; 269 | isExpanded: boolean; 270 | }) => { 271 | const radius = PEEK_VIEW_HEIGHT - 12; 272 | const strokeWidth = 9; 273 | const circumference = 2 * Math.PI * radius; 274 | const size = 32; 275 | 276 | const animatedProps = useAnimatedProps(() => ({ 277 | strokeDashoffset: withSpring( 278 | (1 - progress.value) * circumference, 279 | SPRING_CONFIG 280 | ), 281 | })); 282 | 283 | const animatedStyle = useAnimatedStyle(() => { 284 | return { 285 | opacity: withSpring(isExpanded ? 0 : 1, SPRING_CONFIG), 286 | transform: [ 287 | { 288 | translateX: withSpring( 289 | isExpanded ? FULL_BAR_HEIGHT : 0, 290 | SPRING_CONFIG 291 | ), 292 | }, 293 | { 294 | translateY: withSpring( 295 | isExpanded ? FULL_BAR_HEIGHT : 0, 296 | SPRING_CONFIG 297 | ), 298 | }, 299 | ], 300 | }; 301 | }); 302 | 303 | return ( 304 | 315 | 316 | {/* Background Circle */} 317 | 325 | 326 | {/* Progress Circle */} 327 | 339 | 340 | 341 | ); 342 | }; 343 | 344 | const Overlay = ({ 345 | style, 346 | stops, 347 | }: { 348 | style?: StyleProp; 349 | stops?: [string, string, ...string[]]; 350 | }) => { 351 | const color = useThemeColor({}, "barColor"); 352 | return ( 353 | 358 | ); 359 | }; 360 | 361 | const LinearProgress = ({ progress }: { progress: SharedValue }) => { 362 | const totalTime = useSharedValue(TOTAL_TIME * 60); 363 | 364 | const timeCovered = useDerivedValue(() => { 365 | return Math.min( 366 | Math.ceil(totalTime.value * progress.value), 367 | totalTime.value 368 | ); 369 | }); 370 | 371 | return ( 372 | 381 | 382 | 383 | 384 | 393 | ({ 401 | width: withSpring(`${progress.value * 100}%`, SPRING_CONFIG), 402 | })), 403 | ]} 404 | /> 405 | 406 | 407 | 408 | 409 | 410 | ); 411 | }; 412 | 413 | const styles = StyleSheet.create({ 414 | container: { 415 | position: "absolute", 416 | backgroundColor: "red", 417 | alignSelf: "center", 418 | maxWidth: 380, 419 | bottom: "1.6%", 420 | overflow: "hidden", 421 | }, 422 | 423 | peek: { 424 | maxHeight: PEEK_VIEW_HEIGHT, 425 | overflow: "hidden", 426 | flexDirection: "column", 427 | }, 428 | 429 | overlay: { 430 | ...StyleSheet.absoluteFillObject, 431 | pointerEvents: "none", 432 | }, 433 | 434 | main: { 435 | flexDirection: "row", 436 | alignItems: "center", 437 | }, 438 | handle: { 439 | padding: 10, 440 | height: isWeb ? "100%" : "auto", 441 | }, 442 | handleCircle: { 443 | height: "100%", 444 | aspectRatio: 1, 445 | backgroundColor: "#fff", 446 | borderRadius: "50%", 447 | overflow: "hidden", 448 | }, 449 | image: { 450 | flex: 1, 451 | width: "100%", 452 | }, 453 | }); 454 | -------------------------------------------------------------------------------- /components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | import { View, TouchableOpacity, StyleSheet } from "react-native"; 2 | import { Ionicons } from "@expo/vector-icons"; 3 | import { ThemedText } from "../ThemedText"; 4 | import ResponsiveText from "../ResponsiveText"; 5 | import { useScaleFont } from "@/hooks/useFontScale"; 6 | import { useThemeColor } from "@/hooks/useThemeColor"; 7 | 8 | interface HeaderProps { 9 | title: string; 10 | content: string; 11 | onBackPress: () => void; 12 | } 13 | 14 | const Header = ({ title, content, onBackPress }: HeaderProps) => { 15 | const scaleFont = useScaleFont(); 16 | const text = useThemeColor({}, "text"); 17 | return ( 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {content} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const styles = StyleSheet.create({ 41 | container: { 42 | flexDirection: "column", 43 | alignItems: "center", 44 | paddingHorizontal: 16, 45 | paddingVertical: 12, 46 | gap: 4, 47 | }, 48 | nav: { 49 | flexDirection: "row", 50 | width: "100%", 51 | paddingVertical: 16, 52 | alignItems: "center", 53 | justifyContent: "space-between", 54 | }, 55 | head: { 56 | flexDirection: "column", 57 | width: "100%", 58 | gap: 6, 59 | }, 60 | content: { 61 | paddingVertical: 5, 62 | }, 63 | }); 64 | 65 | export default Header; 66 | -------------------------------------------------------------------------------- /components/ui/TimeFlow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, Text, StyleSheet } from "react-native"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | interpolate, 6 | useDerivedValue, 7 | SharedValue, 8 | Extrapolation, 9 | } from "react-native-reanimated"; 10 | import { ThemedText } from "../ThemedText"; 11 | import { useScaleFont } from "@/hooks/useFontScale"; 12 | 13 | type TimeFlowProps = { 14 | seconds: SharedValue; 15 | hours?: boolean; 16 | }; 17 | 18 | const DIGIT_HEIGHT = 16; 19 | const TOTAL_DIGITS = 10; 20 | 21 | const TimeFlowDigit: React.FC<{ digit: SharedValue }> = ({ digit }) => { 22 | const scaleFont = useScaleFont(); 23 | const fontSize = scaleFont(11.5); 24 | 25 | const animatedStyle = useAnimatedStyle(() => { 26 | return { 27 | transform: [ 28 | { 29 | translateY: interpolate( 30 | digit.value, 31 | [0, 9], 32 | [0, -9 * DIGIT_HEIGHT], 33 | Extrapolation.CLAMP 34 | ), 35 | }, 36 | ], 37 | }; 38 | }); 39 | 40 | return ( 41 | 42 | 43 | {[...Array(TOTAL_DIGITS).keys()].map((num) => ( 44 | 50 | {num} 51 | 52 | ))} 53 | 54 | 55 | ); 56 | }; 57 | 58 | const extractDigit = ( 59 | value: SharedValue, 60 | divisor: number, 61 | place: number 62 | ) => { 63 | return useDerivedValue(() => { 64 | return Math.floor(((value.value / divisor) % 60) / place) % 10; 65 | }); 66 | }; 67 | 68 | const TimeFlow: React.FC = ({ seconds, hours = true }) => { 69 | const hoursTens = extractDigit(seconds, 3600, 10); 70 | const hoursOnes = extractDigit(seconds, 3600, 1); 71 | const minutesTens = extractDigit(seconds, 60, 10); 72 | const minutesOnes = extractDigit(seconds, 60, 1); 73 | const secondsTens = extractDigit(seconds, 1, 10); 74 | const secondsOnes = extractDigit(seconds, 1, 1); 75 | const scaleFont = useScaleFont(); 76 | const fontSize = scaleFont(11.5); 77 | 78 | const hourWidth = useAnimatedStyle(() => { 79 | return { 80 | width: hours || hoursOnes.value > 0 ? "auto" : 0, 81 | overflow: "hidden", 82 | flexDirection: "row", 83 | }; 84 | }); 85 | 86 | return ( 87 | 88 | 89 | 90 | 91 | 92 | : 93 | 94 | 95 | 96 | 97 | 98 | : 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default TimeFlow; 107 | 108 | const styles = StyleSheet.create({ 109 | timeFlow: { 110 | flexDirection: "row", 111 | alignItems: "center", 112 | opacity: 0.4, 113 | }, 114 | digitContainer: { width: 8, height: DIGIT_HEIGHT, overflow: "hidden" }, 115 | digitText: { 116 | textAlign: "center", 117 | lineHeight: DIGIT_HEIGHT, 118 | height: DIGIT_HEIGHT, 119 | }, 120 | separator: { 121 | lineHeight: DIGIT_HEIGHT, 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode. 3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. 4 | */ 5 | 6 | const tintColorLight = "#0a7ea4"; 7 | const tintColorDark = "#fff"; 8 | 9 | export const Colors = { 10 | light: { 11 | text: "#11181C", 12 | background: "#fff", 13 | barColor: "#000000", 14 | tint: tintColorLight, 15 | }, 16 | dark: { 17 | text: "#ECEDEE", 18 | background: "#1F1F1F", 19 | barColor: "#000000", 20 | tint: tintColorDark, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const BLOG_DATA = { 2 | id: 3, 3 | title: "Clean Mobile App UI Design", 4 | excerpt: 5 | "A practical guide to designing sleek, intuitive, and visually appealing mobile app interfaces.", 6 | content: 7 | "Great UI design is more than just looks—it improves usability, accessibility, and user experience. This guide covers essential principles for crafting clean and functional mobile app interfaces.", 8 | author: "Solarin Johnson", 9 | date: "2023-10-05", 10 | imageUrl: "https://example.com/images/clean-ui.jpg", 11 | tags: ["UI Design", "Mobile UX", "App Design"], 12 | sections: [ 13 | { 14 | title: "Why Clean UI Matters", 15 | content: [ 16 | "A cluttered UI confuses users, while a clean design makes interactions effortless. By focusing on simplicity, spacing, and consistency, you create an intuitive experience that improves usability and engagement.", 17 | "Top companies prioritize clean UI to boost user engagement and retention. A well-structured interface reduces cognitive load and enhances user satisfaction. Understanding the psychology behind clean design helps you craft more user-friendly and scalable interfaces.", 18 | ], 19 | }, 20 | { 21 | title: "Typography and Readability", 22 | content: [ 23 | "Typography sets the tone for your app. Choose readable fonts like Inter or Roboto, maintain proper spacing, and ensure a clear text hierarchy to create a pleasant reading experience.", 24 | "Headings should stand out without overwhelming the screen. Proper line height, font weight, and spacing improve readability. Keep body text at a comfortable size, typically between 14px and 16px, and avoid excessive bold or italic styles that can strain the eyes.", 25 | "Consider accessibility in typography choices. Ensure contrast ratios meet WCAG standards, avoid using only color to convey meaning, and use dynamic scaling for different screen sizes.", 26 | ], 27 | }, 28 | { 29 | title: "Spacing and Layout", 30 | content: [ 31 | "White space (negative space) helps declutter the UI and improve focus. Elements need breathing room to enhance readability and usability, making content easier to scan.", 32 | "Use consistent padding and margins. Following an 8px or 4px spacing system helps maintain a structured and balanced layout across different screen sizes and resolutions.", 33 | ], 34 | }, 35 | { 36 | title: "Color and Contrast", 37 | content: [ 38 | "Colors impact user perception. A simple, well-balanced color scheme enhances aesthetics and usability, reinforcing brand identity while improving readability.", 39 | "Use high contrast for readability (e.g., dark text on a light background). Stick to a limited color palette and avoid unnecessary gradients or excessive shadows that can reduce clarity and visual hierarchy.", 40 | "Color psychology plays a role in UX. Cool tones evoke calmness, warm tones create energy, and neutral shades provide balance. Ensure accessibility by testing color contrast and avoiding color combinations that hinder readability for visually impaired users.", 41 | ], 42 | }, 43 | { 44 | title: "Touch and Interaction Design", 45 | content: [ 46 | "Buttons, gestures, and animations should feel smooth and natural. Ensure touch targets are at least 44x44 pixels for accessibility, making interactions effortless and reducing frustration.", 47 | "Use motion subtly—smooth transitions and microinteractions should enhance the experience, not distract from it. Animations should provide feedback, such as button press effects or loading indicators, to guide users.", 48 | ], 49 | }, 50 | { 51 | title: "Icons and Imagery", 52 | content: [ 53 | "Icons should be intuitive and universally recognizable. They help users navigate and understand actions quickly. Use a consistent style and size for icons throughout the app.", 54 | "Images should be high quality and relevant. They can enhance the visual appeal and provide context, but avoid overloading the interface with too many images, which can slow down performance.", 55 | "Optimize images for different screen sizes and resolutions. Use responsive images and consider using vector graphics (SVGs) for scalability and clarity on various devices.", 56 | ], 57 | }, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useColorScheme as useRNColorScheme } from 'react-native'; 3 | 4 | /** 5 | * To support static rendering, this value needs to be re-calculated on the client side for web 6 | */ 7 | export function useColorScheme() { 8 | const [hasHydrated, setHasHydrated] = useState(false); 9 | 10 | useEffect(() => { 11 | setHasHydrated(true); 12 | }, []); 13 | 14 | const colorScheme = useRNColorScheme(); 15 | 16 | if (hasHydrated) { 17 | return colorScheme; 18 | } 19 | 20 | return 'light'; 21 | } 22 | -------------------------------------------------------------------------------- /hooks/useFontScale.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { PixelRatio, Platform, useWindowDimensions } from "react-native"; 3 | 4 | const BASE_WIDTH = 375; 5 | 6 | export function useScaleFont() { 7 | const { width } = useWindowDimensions(); 8 | const isWeb = Platform.OS === "web"; 9 | 10 | return useMemo( 11 | () => (size: number) => { 12 | return isWeb 13 | ? size 14 | : ((size * width) / BASE_WIDTH) * (1 / PixelRatio.getFontScale()); 15 | }, 16 | [width] 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /hooks/useThemeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about light and dark modes: 3 | * https://docs.expo.dev/guides/color-schemes/ 4 | */ 5 | 6 | import { Colors } from '@/constants/Colors'; 7 | import { useColorScheme } from '@/hooks/useColorScheme'; 8 | 9 | export function useThemeColor( 10 | props: { light?: string; dark?: string }, 11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 | ) { 13 | const theme = useColorScheme() ?? 'light'; 14 | const colorFromProps = props[theme]; 15 | 16 | if (colorFromProps) { 17 | return colorFromProps; 18 | } else { 19 | return Colors[theme][colorName]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/media/demo.gif -------------------------------------------------------------------------------- /media/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-blog/f6c9cb3a397c4a658e8130b952a14a1760755e63/media/demo.mp4 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-blog", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@expo/vector-icons": "^14.0.2", 19 | "@react-navigation/bottom-tabs": "^7.2.0", 20 | "@react-navigation/native": "^7.0.14", 21 | "expo": "~52.0.38", 22 | "expo-blur": "~14.0.3", 23 | "expo-constants": "~17.0.8", 24 | "expo-font": "~13.0.4", 25 | "expo-haptics": "~14.0.1", 26 | "expo-linear-gradient": "~14.0.2", 27 | "expo-linking": "~7.0.5", 28 | "expo-navigation-bar": "~4.0.8", 29 | "expo-router": "~4.0.18", 30 | "expo-splash-screen": "~0.29.22", 31 | "expo-status-bar": "~2.0.1", 32 | "expo-symbols": "~0.2.2", 33 | "expo-system-ui": "~4.0.8", 34 | "expo-web-browser": "~14.0.2", 35 | "react": "18.3.1", 36 | "react-dom": "18.3.1", 37 | "react-native": "0.76.7", 38 | "react-native-gesture-handler": "~2.20.2", 39 | "react-native-reanimated": "~3.16.1", 40 | "react-native-safe-area-context": "4.12.0", 41 | "react-native-screens": "~4.4.0", 42 | "react-native-svg": "15.8.0", 43 | "react-native-web": "~0.19.13", 44 | "react-native-webview": "13.12.5", 45 | "expo-image": "~2.0.6" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.25.2", 49 | "@types/jest": "^29.5.12", 50 | "@types/react": "~18.3.12", 51 | "@types/react-native-web": "^0.19.0", 52 | "@types/react-test-renderer": "^18.3.0", 53 | "jest": "^29.2.1", 54 | "jest-expo": "~52.0.6", 55 | "react-test-renderer": "18.3.1", 56 | "typescript": "^5.3.3" 57 | }, 58 | "private": true 59 | } 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------