Files
RN_Template/RN_TEMPLATE/app/components/Screen.tsx

307 lines
8.9 KiB
TypeScript
Raw Normal View History

2026-02-05 13:16:05 +08:00
import { ReactNode, useRef, useState } from "react"
import {
KeyboardAvoidingView,
KeyboardAvoidingViewProps,
LayoutChangeEvent,
Platform,
ScrollView,
ScrollViewProps,
StyleProp,
View,
ViewStyle,
} from "react-native"
import { useScrollToTop } from "@react-navigation/native"
import { SystemBars, SystemBarsProps, SystemBarStyle } from "react-native-edge-to-edge"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import { s } from "@/utils/responsive"
import { ExtendedEdge, useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
export const DEFAULT_BOTTOM_OFFSET = s(50)
interface BaseScreenProps {
/**
* Children components.
*/
children?: ReactNode
/**
* Style for the outer content container useful for padding & margin.
*/
style?: StyleProp<ViewStyle>
/**
* Style for the inner content container useful for padding & margin.
*/
contentContainerStyle?: StyleProp<ViewStyle>
/**
* Override the default edges for the safe area.
*/
safeAreaEdges?: ExtendedEdge[]
/**
* Background color
*/
backgroundColor?: string
/**
* System bar setting. Defaults to dark.
*/
systemBarStyle?: SystemBarStyle
/**
* By how much should we offset the keyboard? Defaults to 0.
*/
keyboardOffset?: number
/**
* By how much we scroll up when the keyboard is shown. Defaults to 50.
*/
keyboardBottomOffset?: number
/**
* Pass any additional props directly to the SystemBars component.
*/
SystemBarsProps?: SystemBarsProps
/**
* Pass any additional props directly to the KeyboardAvoidingView component.
*/
KeyboardAvoidingViewProps?: KeyboardAvoidingViewProps
}
interface FixedScreenProps extends BaseScreenProps {
preset?: "fixed"
}
interface ScrollScreenProps extends BaseScreenProps {
preset?: "scroll"
/**
* Should keyboard persist on screen tap. Defaults to handled.
* Only applies to scroll preset.
*/
keyboardShouldPersistTaps?: "handled" | "always" | "never"
/**
* Pass any additional props directly to the ScrollView component.
*/
ScrollViewProps?: ScrollViewProps
}
interface AutoScreenProps extends Omit<ScrollScreenProps, "preset"> {
preset?: "auto"
/**
* Threshold to trigger the automatic disabling/enabling of scroll ability.
* Defaults to `{ percent: 0.92 }`.
*/
scrollEnabledToggleThreshold?: { percent?: number; point?: number }
}
export type ScreenProps = ScrollScreenProps | FixedScreenProps | AutoScreenProps
const isIos = Platform.OS === "ios"
type ScreenPreset = "fixed" | "scroll" | "auto"
/**
* @param {ScreenPreset?} preset - The preset to check.
* @returns {boolean} - Whether the preset is non-scrolling.
*/
function isNonScrolling(preset?: ScreenPreset) {
return !preset || preset === "fixed"
}
/**
* Custom hook that handles the automatic enabling/disabling of scroll ability based on the content size and screen size.
* @param {UseAutoPresetProps} props - The props for the `useAutoPreset` hook.
* @returns {{boolean, Function, Function}} - The scroll state, and the `onContentSizeChange` and `onLayout` functions.
*/
function useAutoPreset(props: AutoScreenProps): {
scrollEnabled: boolean
onContentSizeChange: (w: number, h: number) => void
onLayout: (e: LayoutChangeEvent) => void
} {
const { preset, scrollEnabledToggleThreshold } = props
const { percent = 0.92, point = 0 } = scrollEnabledToggleThreshold || {}
const scrollViewHeight = useRef<null | number>(null)
const scrollViewContentHeight = useRef<null | number>(null)
const [scrollEnabled, setScrollEnabled] = useState(true)
function updateScrollState() {
if (scrollViewHeight.current === null || scrollViewContentHeight.current === null) return
// check whether content fits the screen then toggle scroll state according to it
const contentFitsScreen = (function () {
if (point) {
return scrollViewContentHeight.current < scrollViewHeight.current - point
} else {
return scrollViewContentHeight.current < scrollViewHeight.current * percent
}
})()
// content is less than the size of the screen, so we can disable scrolling
if (scrollEnabled && contentFitsScreen) setScrollEnabled(false)
// content is greater than the size of the screen, so let's enable scrolling
if (!scrollEnabled && !contentFitsScreen) setScrollEnabled(true)
}
/**
* @param {number} w - The width of the content.
* @param {number} h - The height of the content.
*/
function onContentSizeChange(w: number, h: number) {
// update scroll-view content height
scrollViewContentHeight.current = h
updateScrollState()
}
/**
* @param {LayoutChangeEvent} e = The layout change event.
*/
function onLayout(e: LayoutChangeEvent) {
const { height } = e.nativeEvent.layout
// update scroll-view height
scrollViewHeight.current = height
updateScrollState()
}
// update scroll state on every render
if (preset === "auto") updateScrollState()
return {
scrollEnabled: preset === "auto" ? scrollEnabled : true,
onContentSizeChange,
onLayout,
}
}
/**
* @param {ScreenProps} props - The props for the `ScreenWithoutScrolling` component.
* @returns {JSX.Element} - The rendered `ScreenWithoutScrolling` component.
*/
function ScreenWithoutScrolling(props: ScreenProps) {
const { style, contentContainerStyle, children, preset } = props
return (
<View style={[$outerStyle, style]}>
<View style={[$innerStyle, preset === "fixed" && $justifyFlexEnd, contentContainerStyle]}>
{children}
</View>
</View>
)
}
/**
* @param {ScreenProps} props - The props for the `ScreenWithScrolling` component.
* @returns {JSX.Element} - The rendered `ScreenWithScrolling` component.
*/
function ScreenWithScrolling(props: ScreenProps) {
const {
children,
keyboardShouldPersistTaps = "handled",
keyboardBottomOffset = DEFAULT_BOTTOM_OFFSET,
contentContainerStyle,
ScrollViewProps,
style,
} = props as ScrollScreenProps
const ref = useRef<ScrollView>(null)
const { scrollEnabled, onContentSizeChange, onLayout } = useAutoPreset(props as AutoScreenProps)
// Add native behavior of pressing the active tab to scroll to the top of the content
// More info at: https://reactnavigation.org/docs/use-scroll-to-top/
useScrollToTop(ref)
return (
<KeyboardAwareScrollView
bottomOffset={keyboardBottomOffset}
{...{ keyboardShouldPersistTaps, scrollEnabled, ref }}
{...ScrollViewProps}
onLayout={(e) => {
onLayout(e)
ScrollViewProps?.onLayout?.(e)
}}
onContentSizeChange={(w: number, h: number) => {
onContentSizeChange(w, h)
ScrollViewProps?.onContentSizeChange?.(w, h)
}}
style={[$outerStyle, ScrollViewProps?.style, style]}
contentContainerStyle={[
$innerStyle,
ScrollViewProps?.contentContainerStyle,
contentContainerStyle,
]}
>
{children}
</KeyboardAwareScrollView>
)
}
/**
* Represents a screen component that provides a consistent layout and behavior for different screen presets.
* The `Screen` component can be used with different presets such as "fixed", "scroll", or "auto".
* It handles safe area insets, status bar settings, keyboard avoiding behavior, and scrollability based on the preset.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Screen/}
* @param {ScreenProps} props - The props for the `Screen` component.
* @returns {JSX.Element} The rendered `Screen` component.
*/
export function Screen(props: ScreenProps) {
const {
theme: { colors },
themeContext,
} = useAppTheme()
const {
backgroundColor,
KeyboardAvoidingViewProps,
keyboardOffset = 0,
safeAreaEdges,
SystemBarsProps,
systemBarStyle,
} = props
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
return (
<View
style={[
$containerStyle,
{ backgroundColor: backgroundColor || colors.background },
$containerInsets,
]}
>
<SystemBars
style={systemBarStyle || (themeContext === "dark" ? "light" : "dark")}
{...SystemBarsProps}
/>
<KeyboardAvoidingView
behavior={isIos ? "padding" : "height"}
keyboardVerticalOffset={keyboardOffset}
{...KeyboardAvoidingViewProps}
style={[$styles.flex1, KeyboardAvoidingViewProps?.style]}
>
{isNonScrolling(props.preset) ? (
<ScreenWithoutScrolling {...props} />
) : (
<ScreenWithScrolling {...props} />
)}
</KeyboardAvoidingView>
</View>
)
}
const $containerStyle: ViewStyle = {
flex: 1,
height: "100%",
width: "100%",
}
const $outerStyle: ViewStyle = {
flex: 1,
height: "100%",
width: "100%",
}
const $justifyFlexEnd: ViewStyle = {
justifyContent: "flex-end",
}
const $innerStyle: ViewStyle = {
justifyContent: "flex-start",
alignItems: "stretch",
}