Skip to content
Draft
23 changes: 23 additions & 0 deletions example/src/Examples/ListItemExample.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { View, StyleSheet } from 'react-native';

import { List, Divider, Checkbox, Avatar, Switch } from 'react-native-paper';
Expand All @@ -10,9 +11,31 @@ const CenteredCheckbox = () => (
</View>
);

const SelectableSection = () => {
const [selected, setSelected] = useState(0);

return (
<List.Section title="Selected">
{[0, 1, 2].map((index) => (
<List.Item
key={index}
title="Headline"
description="Supporting text"
selected={index === selected}
onPress={() => setSelected(index)}
leading={<List.Icon icon="account-outline" />}
/>
))}
<Divider />
</List.Section>
);
};

const ListItemExample = () => {
return (
<ScreenWrapper>
<SelectableSection />

<List.Section title="Text-only">
<List.Item title="Headline" />
<List.Item title="Headline" description="Supporting text" />
Expand Down
180 changes: 136 additions & 44 deletions src/components/List/ListAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,25 @@ import type {
ViewStyle,
} from 'react-native';

import Animated, {
Easing,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import type {
EntryAnimationsValues,
ExitAnimationsValues,
} from 'react-native-reanimated';

import { ListAccordionGroupContext } from './ListAccordionGroup';
import { ListTokens } from './tokens';
import type { ListChildProps, Style } from './utils';
import { getAccordionColors, getLeftStyles } from './utils';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../../types';
import MaterialCommunityIcon from '../MaterialCommunityIcon';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
Expand Down Expand Up @@ -46,6 +60,10 @@ export type Props = {
* You'll need to update this prop when you want to toggle the component or on `onPress`.
*/
expanded?: boolean;
/**
* Whether to highlight the accordion as selected.
*/
selected?: boolean;
/**
* Function to execute on press.
*/
Expand Down Expand Up @@ -190,6 +208,7 @@ const ListAccordion = ({
onLongPress,
delayLongPress,
expanded: expandedProp,
selected,
'aria-label': ariaLabel,
pointerEvents = 'none',
titleMaxFontSizeMultiplier,
Expand All @@ -201,13 +220,14 @@ const ListAccordion = ({
const [expanded, setExpanded] = React.useState<boolean>(
expandedProp || false
);
const [alignToTop, setAlignToTop] = React.useState(false);
const [isDescriptionMultiline, setIsDescriptionMultiline] =
React.useState(false);

const onDescriptionTextLayout = (
event: NativeSyntheticEvent<TextLayoutEventData>
) => {
const { nativeEvent } = event;
setAlignToTop(nativeEvent.lines.length >= 2);
setIsDescriptionMultiline(nativeEvent.lines.length >= 2);
};

const handlePressAction = (e: GestureResponderEvent) => {
Expand All @@ -232,25 +252,94 @@ const ListAccordion = ({
? groupContext.expandedId === id
: expandedInternal;

const { descriptionColor, titleTextColor } = getAccordionColors({
theme,
isExpanded,
});
const reduceMotion = useReduceMotion();
const reanimatedReduceMotion = reduceMotion
? ReduceMotion.Always
: ReduceMotion.Never;

const timingConfig = React.useMemo(
() => ({
duration: theme.motion.duration.medium2,
easing: Easing.bezier(...theme.motion.easing.emphasized),
reduceMotion: reanimatedReduceMotion,
}),
[
theme.motion.duration.medium2,
theme.motion.easing.emphasized,
reanimatedReduceMotion,
]
);

const chevronProgress = useSharedValue(isExpanded ? 1 : 0);

React.useEffect(() => {
chevronProgress.value = withTiming(isExpanded ? 1 : 0, timingConfig);
}, [isExpanded, chevronProgress, timingConfig]);

const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${chevronProgress.value * 180}deg` }],
}));

const expandAnimation = React.useCallback(
(values: EntryAnimationsValues) => {
'worklet';
return {
initialValues: { height: 0, opacity: 0 },
animations: {
height: withTiming(values.targetHeight, timingConfig),
opacity: withTiming(1, timingConfig),
},
};
},
[timingConfig]
);

const collapseAnimation = React.useCallback(
(values: ExitAnimationsValues) => {
'worklet';
return {
initialValues: { height: values.currentHeight, opacity: 1 },
animations: {
height: withTiming(0, timingConfig),
opacity: withTiming(0, timingConfig),
},
};
},
[timingConfig]
);

const {
descriptionColor,
titleTextColor,
leadingIconColor,
trailingIconColor,
} = getAccordionColors({ theme, selected });

const handlePress =
groupContext && id !== undefined
? () => groupContext.onAccordionPress(id)
: handlePressAction;
return (
<View>
<View style={{ backgroundColor: theme?.colors?.background }}>
<View
style={{
backgroundColor: selected
? theme.colors[ListTokens.selectedContainerColor]
: theme.colors[ListTokens.containerColor],
}}
>
<TouchableRipple
style={[styles.container, style]}
style={[
styles.container,
description ? styles.containerTwoLine : styles.containerOneLine,
style,
]}
onPress={handlePress}
onLongPress={onLongPress}
delayLongPress={delayLongPress}
role="button"
aria-expanded={isExpanded}
aria-selected={selected}
aria-label={ariaLabel}
testID={testID}
theme={theme}
Expand All @@ -264,16 +353,17 @@ const ListAccordion = ({
>
{left
? left({
color: isExpanded ? theme.colors?.primary : descriptionColor,
style: getLeftStyles(alignToTop, description),
color: leadingIconColor,
style: getLeftStyles(isDescriptionMultiline, description),
})
: null}
<View style={[styles.contentItem, styles.content, contentStyle]}>
<Text
variant="bodyLarge"
theme={theme}
selectable={false}
numberOfLines={titleNumberOfLines}
style={[
styles.title,
{
color: titleTextColor,
},
Expand All @@ -285,10 +375,11 @@ const ListAccordion = ({
</Text>
{description ? (
<Text
variant="bodyMedium"
theme={theme}
selectable={false}
numberOfLines={descriptionNumberOfLines}
style={[
styles.description,
{
color: descriptionColor,
},
Expand All @@ -301,31 +392,33 @@ const ListAccordion = ({
</Text>
) : null}
</View>
<View
style={[
styles.trailingItem,
description ? styles.multiline : undefined,
]}
>
<View style={styles.trailingItem}>
{right ? (
right({
isExpanded: isExpanded,
})
) : (
<MaterialCommunityIcon
name={isExpanded ? 'chevron-up' : 'chevron-down'}
color={descriptionColor}
size={24}
direction={direction}
/>
<Animated.View style={chevronStyle}>
<MaterialCommunityIcon
name="chevron-down"
color={trailingIconColor}
size={24}
direction={direction}
/>
</Animated.View>
)}
</View>
</View>
</TouchableRipple>
</View>

{isExpanded
? React.Children.map(children, (child) => {
{isExpanded ? (
<Animated.View
entering={expandAnimation}
exiting={collapseAnimation}
style={styles.expandedContent}
>
{React.Children.map(children, (child) => {
if (
left &&
React.isValidElement<ListChildProps>(child) &&
Expand All @@ -339,8 +432,9 @@ const ListAccordion = ({
}

return child;
})
: null}
})}
</Animated.View>
) : null}
</View>
);
};
Expand All @@ -349,34 +443,32 @@ ListAccordion.displayName = 'List.Accordion';

const styles = StyleSheet.create({
container: {
paddingVertical: 8,
paddingRight: 24,
},
row: {
flexDirection: 'row',
marginVertical: 6,
},
multiline: {
height: 40,
alignItems: 'center',
paddingVertical: ListTokens.verticalPadding,
paddingRight: ListTokens.trailingSpace,
justifyContent: 'center',
},
title: {
fontSize: 16,
containerOneLine: {
minHeight: ListTokens.oneLineContainerHeight,
},
description: {
fontSize: 14,
containerTwoLine: {
minHeight: ListTokens.twoLineContainerHeight,
},
row: {
flexDirection: 'row',
},
contentItem: {
paddingLeft: 16,
paddingLeft: ListTokens.leadingSpace,
},
trailingItem: {
marginVertical: 6,
alignSelf: 'center',
paddingLeft: 8,
},
child: {
paddingLeft: 40,
},
expandedContent: {
overflow: 'hidden',
},
content: {
flex: 1,
justifyContent: 'center',
Expand Down
6 changes: 5 additions & 1 deletion src/components/List/ListIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import type { ColorValue, StyleProp, ViewStyle } from 'react-native';

import { ListItemContext } from './ListItemContext';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp } from '../../types';
import Icon from '../Icon';
Expand Down Expand Up @@ -50,10 +52,12 @@ const ListIcon = ({
theme: themeOverrides,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const listItem = React.useContext(ListItemContext);
const color = iconColor ?? listItem?.color;

return (
<View style={[styles.item, style]} pointerEvents="box-none">
<Icon source={icon} size={ICON_SIZE} color={iconColor} theme={theme} />
<Icon source={icon} size={ICON_SIZE} color={color} theme={theme} />
</View>
);
};
Expand Down
Loading