Skip to content

Commit 486e627

Browse files
committed
feat(mobile): Implement new modal header and scroll view components
- Create ModalHeader component for consistent modal UI - Add ModalScrollViewContext for scroll-related state management - Refactor SafeModalScrollView to use new context and header - Implement dynamic header height calculation utility - Update various modal screens to use new components - Simplify header and scroll view interactions Signed-off-by: Innei <tukon479@gmail.com>
1 parent 9fffc98 commit 486e627

File tree

18 files changed

+348
-213
lines changed

18 files changed

+348
-213
lines changed
Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { StyleSheet } from "react-native"
2-
import { useColor } from "react-native-uikit-colors"
32

43
import { ThemedBlurView } from "@/src/components/common/ThemedBlurView"
54

@@ -15,20 +14,3 @@ const node = (
1514
export const BlurEffect = () => {
1615
return node
1716
}
18-
19-
const InternalBlurEffectWithBottomBorder = () => {
20-
const border = useColor("opaqueSeparator")
21-
return (
22-
<ThemedBlurView
23-
style={{
24-
...StyleSheet.absoluteFillObject,
25-
overflow: "hidden",
26-
backgroundColor: "transparent",
27-
borderBottomWidth: StyleSheet.hairlineWidth,
28-
borderBottomColor: border,
29-
}}
30-
/>
31-
)
32-
}
33-
34-
export const BlurEffectWithBottomBorder = () => <InternalBlurEffectWithBottomBorder />
Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cn } from "@follow/utils"
22
import { useTheme } from "@react-navigation/native"
33
import type { StyleProp, TextProps, TextStyle } from "react-native"
4-
import { Animated, Platform, StyleSheet, Text, View } from "react-native"
4+
import { Animated, StyleSheet, Text, View } from "react-native"
55

66
type Props = Omit<TextProps, "style"> & {
77
tintColor?: string
@@ -20,7 +20,7 @@ export function HeaderTitleExtra({
2020
subTextClassName,
2121
...rest
2222
}: Props) {
23-
const { colors, fonts } = useTheme()
23+
const { fonts } = useTheme()
2424

2525
return (
2626
<View>
@@ -29,33 +29,21 @@ export function HeaderTitleExtra({
2929
aria-level="1"
3030
numberOfLines={1}
3131
{...rest}
32-
style={[
33-
{ color: tintColor === undefined ? colors.text : tintColor },
34-
Platform.select({ ios: fonts.bold, default: fonts.medium }),
35-
styles.title,
36-
style,
37-
]}
32+
className={"text-label"}
33+
style={[fonts.bold, styles.title, style]}
3834
/>
39-
<Text
40-
className={cn("text-text/50 text-center text-xs", subTextClassName)}
41-
style={subTextStyle}
42-
>
43-
{subText}
44-
</Text>
35+
{!!subText && (
36+
<Text
37+
className={cn("text-text/50 text-center text-xs", subTextClassName)}
38+
style={subTextStyle}
39+
>
40+
{subText}
41+
</Text>
42+
)}
4543
</View>
4644
)
4745
}
4846

4947
const styles = StyleSheet.create({
50-
title: Platform.select({
51-
ios: {
52-
fontSize: 17,
53-
},
54-
android: {
55-
fontSize: 20,
56-
},
57-
default: {
58-
fontSize: 18,
59-
},
60-
}),
48+
title: { fontSize: 17 },
6149
})

apps/mobile/src/components/common/ModalSharedComponents.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { withOpacity } from "@follow/utils"
22
import { router } from "expo-router"
3+
import { useMemo } from "react"
34
import { TouchableOpacity } from "react-native"
45

56
import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne"
@@ -18,10 +19,11 @@ const ModalHeaderCloseButtonImpl = () => {
1819
const label = useColor("label")
1920

2021
const routeOnlyOne = useIsRouteOnlyOne()
22+
const memoedRouteOnlyOne = useMemo(() => routeOnlyOne, [])
2123

2224
return (
2325
<TouchableOpacity onPress={() => router.dismiss()}>
24-
{routeOnlyOne ? (
26+
{memoedRouteOnlyOne ? (
2527
<CloseCuteReIcon height={20} width={20} color={label} />
2628
) : (
2729
<MingcuteLeftLineIcon height={20} width={20} color={label} />
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createContext, useContext } from "react"
2+
import type { ScrollView } from "react-native"
3+
import type { AnimatedRef, SharedValue } from "react-native-reanimated"
4+
5+
interface ModalScrollViewContextType {
6+
scrollViewRef: AnimatedRef<ScrollView>
7+
animatedY: SharedValue<number>
8+
}
9+
export const ModalScrollViewContext = createContext<ModalScrollViewContextType>(null!)
10+
11+
export const useModalScrollViewContext = () => {
12+
const context = useContext(ModalScrollViewContext)
13+
if (!context) {
14+
throw new Error("ModalScrollViewContext not found")
15+
}
16+
return context
17+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useTypeScriptHappyCallback } from "@follow/hooks/exports"
2+
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"
3+
import { Stack } from "expo-router"
4+
import type { FC } from "react"
5+
import { memo } from "react"
6+
import { StyleSheet, View } from "react-native"
7+
import type { SharedValue } from "react-native-reanimated"
8+
import Animated, { useAnimatedStyle } from "react-native-reanimated"
9+
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
10+
import { useColor } from "react-native-uikit-colors"
11+
12+
import { HeaderTitleExtra } from "../../common/HeaderTitleExtra"
13+
import { ModalHeaderCloseButton } from "../../common/ModalSharedComponents"
14+
import { ThemedBlurView } from "../../common/ThemedBlurView"
15+
import { useModalScrollViewContext } from "../contexts/ModalScrollViewContext"
16+
import { getDefaultHeaderHeight } from "../utils"
17+
18+
interface ModalHeaderProps
19+
extends Omit<NativeStackNavigationOptions, "headerLeft" | "headerRight" | "headerTitle"> {
20+
headerLeft?: React.ReactNode
21+
headerRight?: React.ReactNode
22+
headerTitle?: string
23+
headerSubtitle?: string
24+
}
25+
26+
export const ModalHeader: FC<ModalHeaderProps> = (props) => {
27+
const { animatedY } = useModalScrollViewContext()
28+
29+
return (
30+
<Stack.Screen
31+
options={{
32+
...props,
33+
headerTransparent: true,
34+
headerShown: true,
35+
headerLeft: () => props.headerLeft ?? <ModalHeaderCloseButton />,
36+
headerRight: () => props.headerRight,
37+
38+
header: useTypeScriptHappyCallback(
39+
({ options }) => (
40+
<Header
41+
headerTitle={props.headerTitle}
42+
headerLeft={options.headerLeft?.({} as any)}
43+
headerRight={options.headerRight?.({} as any)}
44+
animatedY={animatedY}
45+
headerSubtitle={props.headerSubtitle}
46+
/>
47+
),
48+
[animatedY, props.headerSubtitle, props.headerTitle],
49+
),
50+
}}
51+
/>
52+
)
53+
}
54+
55+
interface HeaderProps {
56+
headerTitle?: string
57+
headerLeft?: React.ReactNode
58+
headerRight?: React.ReactNode
59+
animatedY: SharedValue<number>
60+
headerSubtitle?: string
61+
}
62+
63+
const InternalBlurEffectWithBottomBorder = (props: { animatedY: SharedValue<number> }) => {
64+
const border = useColor("opaqueSeparator")
65+
const animatedStyle = useAnimatedStyle(() => {
66+
return {
67+
opacity: Math.max(0, Math.min(1, props.animatedY.value / 10)),
68+
borderBottomWidth: StyleSheet.hairlineWidth,
69+
borderBottomColor: border,
70+
}
71+
})
72+
return (
73+
<Animated.View className={"absolute inset-0 overflow-hidden"} style={animatedStyle}>
74+
<ThemedBlurView style={StyleSheet.absoluteFillObject} />
75+
</Animated.View>
76+
)
77+
}
78+
79+
const Header: FC<HeaderProps> = memo(
80+
({ headerTitle, headerLeft, headerRight, headerSubtitle, animatedY }) => {
81+
const frame = useSafeAreaFrame()
82+
const insets = useSafeAreaInsets()
83+
const height = getDefaultHeaderHeight(frame, true, insets.top)
84+
85+
return (
86+
<View style={{ height }} className="items-center justify-center">
87+
<InternalBlurEffectWithBottomBorder animatedY={animatedY} />
88+
{/* Grid */}
89+
<View className="mx-5 flex-row items-center">
90+
{/* Left actions */}
91+
<View className="flex-1 flex-row items-center justify-start gap-2">{headerLeft}</View>
92+
93+
{/* Title */}
94+
<View className="mx-8 flex-row items-center justify-center text-center">
95+
{/* <Text>Title</Text> */}
96+
<HeaderTitleExtra subText={headerSubtitle}>{headerTitle}</HeaderTitleExtra>
97+
</View>
98+
99+
{/* Right actions */}
100+
<View className="flex-1 flex-row items-center justify-end gap-2">{headerRight}</View>
101+
</View>
102+
</View>
103+
)
104+
},
105+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { PixelRatio, Platform } from "react-native"
2+
3+
type Layout = { width: number; height: number }
4+
/**
5+
* @description In order to make android header height same as ios, we need to custom this function.
6+
* @copyright copy from @react-navigation/elements/src/Header/getDefaultHeaderHeight.tsx
7+
*/
8+
export function getDefaultHeaderHeight(
9+
layout: Layout,
10+
modalPresentation: boolean,
11+
topInset: number,
12+
): number {
13+
let headerHeight = 0
14+
15+
// On models with Dynamic Island the status bar height is smaller than the safe area top inset.
16+
const hasDynamicIsland = topInset > 50
17+
const statusBarHeight = hasDynamicIsland ? topInset - (5 + 1 / PixelRatio.get()) : topInset
18+
19+
const isLandscape = layout.width > layout.height
20+
21+
if (Platform.OS === "ios" && (Platform.isPad || Platform.isTV)) {
22+
if (modalPresentation) {
23+
headerHeight = 56
24+
} else {
25+
headerHeight = 50
26+
}
27+
} else {
28+
if (isLandscape) {
29+
headerHeight = 32
30+
} else {
31+
if (modalPresentation) {
32+
headerHeight = 56
33+
} else {
34+
headerHeight = 44
35+
}
36+
}
37+
}
38+
39+
return headerHeight + (!modalPresentation ? statusBarHeight : 0)
40+
}
Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,41 @@
1-
/**
2-
* @description only for iOS modal
3-
*
4-
* ```
5-
* Set screen options:
6-
* + headerTransparent: true,
7-
* + headerBackground: BlurEffectWithBottomBorder,
8-
* ```
9-
*/
10-
import { useHeaderHeight } from "@react-navigation/elements"
11-
import type { ScrollViewProps } from "react-native"
12-
import { ScrollView } from "react-native"
13-
import { useSafeAreaInsets } from "react-native-safe-area-context"
1+
import { useMemo } from "react"
2+
import type { ScrollView } from "react-native"
3+
import { View } from "react-native"
4+
import type { AnimatedScrollViewProps } from "react-native-reanimated"
5+
import { useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from "react-native-reanimated"
6+
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
147

15-
interface SafeModalScrollViewProps extends ScrollViewProps {}
8+
import { ReAnimatedScrollView } from "../../common/AnimatedComponents"
9+
import { ModalScrollViewContext } from "../contexts/ModalScrollViewContext"
10+
import { getDefaultHeaderHeight } from "../utils"
11+
12+
interface SafeModalScrollViewProps extends AnimatedScrollViewProps {}
1613
export const SafeModalScrollView = (props: SafeModalScrollViewProps) => {
17-
const headerHeight = useHeaderHeight()
14+
const frame = useSafeAreaFrame()
1815
const insets = useSafeAreaInsets()
16+
const headerHeight = getDefaultHeaderHeight(frame, true, insets.top)
17+
const animatedY = useSharedValue(0)
18+
const animatedRef = useAnimatedRef<ScrollView>()
1919
return (
20-
<ScrollView
21-
{...props}
22-
scrollIndicatorInsets={{ top: headerHeight, bottom: insets.bottom }}
23-
contentContainerStyle={{ paddingTop: headerHeight, paddingBottom: insets.bottom }}
24-
>
25-
{props.children}
26-
</ScrollView>
20+
<View className="bg-yellow flex-1">
21+
<ModalScrollViewContext.Provider
22+
value={useMemo(() => ({ scrollViewRef: animatedRef, animatedY }), [animatedRef, animatedY])}
23+
>
24+
<ReAnimatedScrollView
25+
{...props}
26+
ref={animatedRef}
27+
onScroll={useAnimatedScrollHandler({
28+
onScroll: (e) => {
29+
animatedY.value = e.contentOffset.y
30+
},
31+
})}
32+
scrollEventThrottle={16}
33+
scrollIndicatorInsets={{ top: headerHeight, bottom: insets.bottom }}
34+
contentContainerStyle={{ paddingTop: headerHeight, paddingBottom: insets.bottom }}
35+
>
36+
{props.children}
37+
</ReAnimatedScrollView>
38+
</ModalScrollViewContext.Provider>
39+
</View>
2740
)
2841
}

apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useTypeScriptHappyCallback } from "@follow/hooks"
2-
import { getDefaultHeaderHeight } from "@react-navigation/elements"
32
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"
43
import { Stack } from "expo-router"
54
import type { FC, PropsWithChildren } from "react"
@@ -17,6 +16,7 @@ import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
1716

1817
import { AnimatedScrollView } from "../../common/AnimatedComponents"
1918
import { NavigationHeader } from "../header/NavigationHeader"
19+
import { getDefaultHeaderHeight } from "../utils"
2020
import { NavigationContext } from "./NavigationContext"
2121
import {
2222
NavigationHeaderHeightContext,

apps/mobile/src/hooks/useDefaultHeaderHeight.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// https://github.com/react-navigation/react-navigation/blob/main/packages/native-stack/src/views/NativeStackView.native.tsx
22

3-
import { getDefaultHeaderHeight } from "@react-navigation/elements"
43
import { Platform } from "react-native"
54
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
65

6+
import { getDefaultHeaderHeight } from "../components/layouts/utils"
7+
78
export const useDefaultHeaderHeight = () => {
89
const insets = useSafeAreaInsets()
910
const frame = useSafeAreaFrame()

apps/mobile/src/modules/discover/search.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { RSSHubCategories } from "@follow/constants"
2-
import { getDefaultHeaderHeight } from "@react-navigation/elements"
32
import { router } from "expo-router"
43
import { useAtom, useAtomValue, useSetAtom } from "jotai"
54
import type { FC } from "react"
@@ -15,6 +14,7 @@ import Animated, {
1514
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
1615

1716
import { BlurEffect } from "@/src/components/common/BlurEffect"
17+
import { getDefaultHeaderHeight } from "@/src/components/layouts/utils"
1818
import { TabBar } from "@/src/components/ui/tabview/TabBar"
1919
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
2020
import { accentColor, useColor } from "@/src/theme/colors"

0 commit comments

Comments
 (0)