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 /** * Style for the inner content container useful for padding & margin. */ contentContainerStyle?: StyleProp /** * 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 { 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) const scrollViewContentHeight = useRef(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 ( {children} ) } /** * @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(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 ( { 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} ) } /** * 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 ( {isNonScrolling(props.preset) ? ( ) : ( )} ) } 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", }