307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
|
|
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",
|
||
|
|
}
|