Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
192 changes: 148 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 { 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,24 @@ 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 getVerticalPaddingStyle = () => {
if (!description) {
return styles.containerOneLine;
}

return isDescriptionMultiline
? styles.containerThreeLine
: styles.containerTwoLine;
};

const handlePressAction = (e: GestureResponderEvent) => {
Expand All @@ -232,25 +262,97 @@ 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 selectedContentColor = theme.colors[ListTokens.selectedContentColor];
const titleTextColor = selected
? selectedContentColor
: theme.colors[ListTokens.headlineColor];
const descriptionColor = selected
? selectedContentColor
: theme.colors[ListTokens.supportingTextColor];
const leadingIconColor = selected
? selectedContentColor
: theme.colors[ListTokens.leadingIconColor];
const trailingIconColor = selected
? selectedContentColor
: theme.colors[ListTokens.trailingIconColor];

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, getVerticalPaddingStyle(), 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 +366,16 @@ 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"
selectable={false}
numberOfLines={titleNumberOfLines}
style={[
styles.title,
{
color: titleTextColor,
},
Expand All @@ -285,10 +387,10 @@ const ListAccordion = ({
</Text>
{description ? (
<Text
variant="bodyMedium"
selectable={false}
numberOfLines={descriptionNumberOfLines}
style={[
styles.description,
{
color: descriptionColor,
},
Expand All @@ -301,31 +403,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 +443,9 @@ const ListAccordion = ({
}

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

const styles = StyleSheet.create({
container: {
paddingVertical: 8,
paddingRight: 24,
paddingRight: ListTokens.trailingSpace,
},
row: {
flexDirection: 'row',
marginVertical: 6,
containerOneLine: {
paddingVertical: ListTokens.oneLineVerticalPadding,
},
multiline: {
height: 40,
alignItems: 'center',
justifyContent: 'center',
containerTwoLine: {
paddingVertical: ListTokens.twoLineVerticalPadding,
},
title: {
fontSize: 16,
containerThreeLine: {
paddingVertical: ListTokens.threeLineVerticalPadding,
},
description: {
fontSize: 14,
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