From 873c7de0f1fb63c784ab0388ede9c341b0b093f0 Mon Sep 17 00:00:00 2001 From: egeakman Date: Wed, 2 Apr 2025 17:06:10 -0400 Subject: [PATCH 1/4] Initial implementation for safer rendering of SelectionList and example app --- example/App.tsx | 58 +- example/package-lock.json | 5638 +++++++++----- example/package.json | 9 +- package-lock.json | 11476 ++++++----------------------- package.json | 7 +- src/components/SelectionList.tsx | 84 +- 6 files changed, 5895 insertions(+), 11377 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index ca59696..c489153 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,8 +1,52 @@ import React from 'react'; -import { ScrollView, Text, View, SafeAreaView } from 'react-native'; +import { ScrollView, Text, View } from 'react-native'; +import { SafeAreaView, SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MultiSelect, Select, type Data } from '@rose-hulman/react-native-dropdown-selector'; import { useThemeStyles } from './styles'; +const DEBUG_INSETS = false; // Set to true to show the safe area insets + +const SafeAreaDebugOverlay = () => { + const insets = useSafeAreaInsets(); + + return ( + <> + + TopInset: {insets.top.toFixed(0)} + + + BottomInset: {insets.bottom.toFixed(0)} + + + ); +}; + const data: Data[] = [ { label: 'Item 1' }, { label: 'Item 2' }, @@ -32,10 +76,11 @@ function App(): React.JSX.Element { } return ( - ); + + {__DEV__ && DEBUG_INSETS && } + + + ); } const Content = ({ onThemeSelect, theme }: ContentProperties): React.JSX.Element => { @@ -64,6 +109,9 @@ const Content = ({ onThemeSelect, theme }: ContentProperties): React.JSX.Element + + Safe area support is active if your app uses SafeAreaProvider. + { + setUseSafeArea(datum.label === 'Enable SafeArea'); + }} + placeholderText="Toggle Safe Area" + theme={theme} + /> Safe area support is active if your app uses SafeAreaProvider. @@ -489,13 +509,15 @@ const Content = ({ onThemeSelect, theme }: ContentProperties): React.JSX.Element /> - + ); } interface ContentProperties { onThemeSelect: (e: Data) => void; theme: 'light' | 'dark' | 'system'; + setUseSafeArea: React.Dispatch>; + useSafeArea: boolean; } export default App; diff --git a/src/components/SelectionList.tsx b/src/components/SelectionList.tsx index d3db074..35d3c3c 100644 --- a/src/components/SelectionList.tsx +++ b/src/components/SelectionList.tsx @@ -13,6 +13,7 @@ import { } from 'react-native'; import { useThemeStyles } from '../styles'; import type { Data, ListProperties } from '../types'; +import { parseWidth } from '../utils/conversions'; // Gracefully import useSafeAreaInsets from 'react-native-safe-area-context' if available let useSafeAreaInsets: undefined | (() => { top: number; bottom: number; left: number; right: number }); @@ -29,7 +30,12 @@ const SelectionList = (props: ListProperties): React.JSX.Element => { const windowHeight = Dimensions.get('window').height; // Get the safe area insets if available - const insets = useSafeAreaInsets?.(); + let insets: { top: number; bottom: number; left: number; right: number } | undefined; + try { + insets = useSafeAreaInsets?.(); + } catch (e) { + insets = undefined; + } // Fall back to somewhat safe values if insets are not available const topInset = Math.max(insets?.top ?? (Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 45), 25); @@ -103,11 +109,9 @@ const SelectionList = (props: ListProperties): React.JSX.Element => { ? 0 : props.styles.list?.width ? props.selectorRect.x + - (typeof props.selectorRect.width === 'string' - ? Number(props.selectorRect.width.slice(0, -1)) * windowWidth - : props.selectorRect.width - currentListWidth) / 2 - leftInset + (parseWidth(props.selectorRect.width, windowWidth) - currentListWidth) / 2 - leftInset : props.selectorRect.x - leftInset, - width: props.styles.list?.width ?? props.selectorRect.width, + width: props.styles.list?.width ?? parseWidth(props.selectorRect.width, windowWidth), maxHeight: props.listHeight, top: keyboardHeight > 0 && listBottom > safeAreaHeight - keyboardHeight ? safeAreaHeight - keyboardHeight - currentListHeight - 5 @@ -192,12 +196,23 @@ const SelectionList = (props: ListProperties): React.JSX.Element => { : props.selectorRect.y + props.selectorRect.height ), left: props.selectorRect.x - 40, - marginLeft: props.selectorRect.width, + marginLeft: parseWidth(props.selectorRect.width, windowWidth), opacity: keyboardHeight === 0 && posReady ? 1 : 0, } : { - top: Math.max(topInset, 40), - right: 10, + // top: Math.max(topInset, props.selectorRect.y - 40), + // left: Math.min( + // windowWidth - 40, + // props.selectorRect.x + parseWidth(props.selectorRect.width, windowWidth) - 10 + // ), + left: props.selectorRect.x + parseWidth(props.selectorRect.width, windowWidth) - 40 - leftInset, + top: Math.min( + windowHeight - bottomInset - 40, + listBottom < safeAreaHeight + ? Math.max(topInset, props.selectorRect.y - 40 - topInset) + : props.selectorRect.y + props.selectorRect.height + ), + opacity: keyboardHeight === 0 && posReady ? 1 : 0, } ]} diff --git a/src/utils/conversions.ts b/src/utils/conversions.ts new file mode 100644 index 0000000..430d88b --- /dev/null +++ b/src/utils/conversions.ts @@ -0,0 +1,7 @@ +export const parseWidth = (value: string | number, base: number): number => { + if (typeof value === 'string' && value.endsWith('%')) { + const percent = parseFloat(value.slice(0, -1)); + return (percent / 100) * base; + } + return typeof value === 'number' ? value : 0; +} From 021a6f3497b806d9e226d7b9f4dd3f07127044e5 Mon Sep 17 00:00:00 2001 From: egeakman Date: Tue, 29 Apr 2025 16:53:28 -0400 Subject: [PATCH 4/4] Extract style calculations into separate blocks, utilize memoization --- src/components/SelectionList.tsx | 136 +++++++++++++++++-------------- 1 file changed, 77 insertions(+), 59 deletions(-) diff --git a/src/components/SelectionList.tsx b/src/components/SelectionList.tsx index 35d3c3c..f939ed5 100644 --- a/src/components/SelectionList.tsx +++ b/src/components/SelectionList.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Dimensions, FlatList, @@ -41,7 +41,7 @@ const SelectionList = (props: ListProperties): React.JSX.Element => { const topInset = Math.max(insets?.top ?? (Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 45), 25); const bottomInset = Math.max(insets?.bottom ?? (Platform.OS === 'ios' ? 34 : 0), 20); const leftInset = insets?.left ?? 0; - const rightInset = insets?.right ?? 0; + const rightInset = insets?.right ?? 0; // currently not used, doesn't make a difference on the tested devices const safeAreaHeight = windowHeight - topInset - bottomInset; @@ -71,6 +71,79 @@ const SelectionList = (props: ListProperties): React.JSX.Element => { () => setKeyboardHeight(0) ); + const listStyle = useMemo(() => { + const width = props.styles.list?.width ?? parseWidth(props.selectorRect.width, windowWidth); + const centerAligned = props.styles.list?.alignSelf === 'center'; + const left = centerAligned + ? 0 + : props.styles.list?.width + ? props.selectorRect.x + (parseWidth(props.selectorRect.width, windowWidth) - currentListWidth) / 2 - leftInset + : props.selectorRect.x - leftInset; + + const top = keyboardHeight > 0 && listBottom > safeAreaHeight - keyboardHeight + ? safeAreaHeight - keyboardHeight - currentListHeight - 5 + : isAbove + ? Math.max(topInset, props.selectorRect.y - currentListHeight) + : Math.min(windowHeight - bottomInset - currentListHeight, props.selectorRect.y + props.selectorRect.height); + + return { + left, + width, + maxHeight: props.listHeight, + top, + opacity: posReady ? 1 : 0, + }; + }, [ + props.styles.list, + props.selectorRect, + currentListWidth, + currentListHeight, + keyboardHeight, + safeAreaHeight, + isAbove, + posReady, + topInset, + bottomInset, + windowHeight, + leftInset, + ]); + + const clearButtonStyle = useMemo(() => { + const isPortrait = windowHeight > windowWidth; + const selectorWidth = parseWidth(props.selectorRect.width, windowWidth); + const top = Math.min( + windowHeight - bottomInset - 40, + listBottom < safeAreaHeight + ? Math.max(topInset, props.selectorRect.y - 40 - (isPortrait ? 0 : topInset)) + : props.selectorRect.y + props.selectorRect.height + ); + const opacity = keyboardHeight === 0 && posReady ? 1 : 0; + + return isPortrait + ? { + top, + left: props.selectorRect.x - 40, + marginLeft: selectorWidth, + opacity, + } + : { + top, + left: props.selectorRect.x + selectorWidth - 40 - leftInset, + opacity, + }; + }, [ + props.selectorRect, + windowWidth, + windowHeight, + topInset, + bottomInset, + leftInset, + safeAreaHeight, + keyboardHeight, + posReady, + listBottom, + ]); + return ( { setCurrentListHeight(nativeEvent.layout.height); updateListState(nativeEvent.layout.height); }} - style={[ - style.list, - props.styles.list, - { - left: props.styles.list?.alignSelf === 'center' - ? 0 - : props.styles.list?.width - ? props.selectorRect.x + - (parseWidth(props.selectorRect.width, windowWidth) - currentListWidth) / 2 - leftInset - : props.selectorRect.x - leftInset, - width: props.styles.list?.width ?? parseWidth(props.selectorRect.width, windowWidth), - maxHeight: props.listHeight, - top: keyboardHeight > 0 && listBottom > safeAreaHeight - keyboardHeight - ? safeAreaHeight - keyboardHeight - currentListHeight - 5 - : isAbove - ? Math.max(topInset, props.selectorRect.y - currentListHeight) - : Math.min( - windowHeight - bottomInset - currentListHeight, - props.selectorRect.y + props.selectorRect.height - ), - opacity: posReady ? 1 : 0, - }, - ]} + style={[style.list, props.styles.list, listStyle]} > {props.searchable && { {props.type === 'multi' && (props.selected as Data[]).length > 0 && - windowWidth - ? { - top: Math.min( - windowHeight - bottomInset - 40, - listBottom < safeAreaHeight - ? Math.max(topInset, props.selectorRect.y - 40) - : props.selectorRect.y + props.selectorRect.height - ), - left: props.selectorRect.x - 40, - marginLeft: parseWidth(props.selectorRect.width, windowWidth), - opacity: keyboardHeight === 0 && posReady ? 1 : 0, - } - : { - // top: Math.max(topInset, props.selectorRect.y - 40), - // left: Math.min( - // windowWidth - 40, - // props.selectorRect.x + parseWidth(props.selectorRect.width, windowWidth) - 10 - // ), - left: props.selectorRect.x + parseWidth(props.selectorRect.width, windowWidth) - 40 - leftInset, - top: Math.min( - windowHeight - bottomInset - 40, - listBottom < safeAreaHeight - ? Math.max(topInset, props.selectorRect.y - 40 - topInset) - : props.selectorRect.y + props.selectorRect.height - ), - opacity: keyboardHeight === 0 && posReady ? 1 : 0, - } - - ]} - > +