diff --git a/example/src/ExamplePicker.tsx b/example/src/ExamplePicker.tsx index 53eed0a..b9c2ae5 100644 --- a/example/src/ExamplePicker.tsx +++ b/example/src/ExamplePicker.tsx @@ -34,6 +34,29 @@ const exampleCases: ExampleCase[] = [ description: "The same 10k dynamic chat dataset rendered through Legend List.", }, + { + id: "cards-feed", + title: "Cards feed", + description: + "A mixed card feed with stories, media, polls, quotes, and events in react-native-list.", + }, + { + id: "legend-list-cards-feed", + title: "Legend List cards feed", + description: "The same mixed card feed rendered through Legend List.", + }, + { + id: "cards", + title: "Cards", + description: + "1k expandable card rows based on the Legend List cards example in react-native-list.", + }, + { + id: "legend-list-cards", + title: "Legend List cards", + description: + "The Legend List cards example adjusted for the current Legend List API.", + }, ]; export function ExamplePicker(props: { diff --git a/example/src/ExamplesApp.tsx b/example/src/ExamplesApp.tsx index 9d6b8b4..8354d9a 100644 --- a/example/src/ExamplesApp.tsx +++ b/example/src/ExamplesApp.tsx @@ -6,10 +6,14 @@ import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import { Text, View } from "react-native"; import { ExampleHeader } from "./components"; import { ExamplePicker } from "./ExamplePicker"; +import { CardsFeedExample } from "./examples/CardsFeedExample"; import { ChatBenchmarkExample } from "./examples/ChatBenchmarkExample"; import { DynamicTextHeightsExample } from "./examples/DynamicTextHeightsExample"; +import { LegendListCardsFeedExample } from "./examples/LegendListCardsFeedExample"; import { LegendListChatBenchmarkExample } from "./examples/LegendListChatBenchmarkExample"; +import { LegendListCardsExample } from "./examples/LegendListCardsExample"; import { ListUpdateLabExample } from "./examples/ListUpdateLabExample"; +import { CardsExample } from "./examples/CardsExample"; import { styles } from "./styles"; import type { ExampleId } from "./types"; @@ -20,6 +24,10 @@ type ExamplesStackParamList = { DynamicTextHeightsPushStress: undefined; ChatBenchmark: undefined; LegendListChatBenchmark: undefined; + CardsFeed: undefined; + LegendListCardsFeed: undefined; + Cards: undefined; + LegendListCards: undefined; }; type DynamicTextHeightsRouteParams = { @@ -57,6 +65,23 @@ type LegendListChatBenchmarkScreenProps = NativeStackScreenProps< "LegendListChatBenchmark" >; +type CardsFeedScreenProps = NativeStackScreenProps< + ExamplesStackParamList, + "CardsFeed" +>; + +type LegendListCardsFeedScreenProps = NativeStackScreenProps< + ExamplesStackParamList, + "LegendListCardsFeed" +>; + +type CardsScreenProps = NativeStackScreenProps; + +type LegendListCardsScreenProps = NativeStackScreenProps< + ExamplesStackParamList, + "LegendListCards" +>; + const Stack = createNativeStackNavigator(); const screenOptions: NativeStackNavigationOptions = { @@ -85,6 +110,26 @@ function ExamplePickerScreen(props: ExamplePickerScreenProps) { return; } + if (exampleId === "cards-feed") { + props.navigation.navigate("CardsFeed"); + return; + } + + if (exampleId === "legend-list-cards-feed") { + props.navigation.navigate("LegendListCardsFeed"); + return; + } + + if (exampleId === "cards") { + props.navigation.navigate("Cards"); + return; + } + + if (exampleId === "legend-list-cards") { + props.navigation.navigate("LegendListCards"); + return; + } + props.navigation.navigate("DynamicTextHeights"); } @@ -229,6 +274,38 @@ function LegendListChatBenchmarkScreen( return ; } +function CardsFeedScreen(props: CardsFeedScreenProps) { + function goBack() { + props.navigation.goBack(); + } + + return ; +} + +function LegendListCardsFeedScreen(props: LegendListCardsFeedScreenProps) { + function goBack() { + props.navigation.goBack(); + } + + return ; +} + +function CardsScreen(props: CardsScreenProps) { + function goBack() { + props.navigation.goBack(); + } + + return ; +} + +function LegendListCardsScreen(props: LegendListCardsScreenProps) { + function goBack() { + props.navigation.goBack(); + } + + return ; +} + export function ExamplesApp() { return ( @@ -251,6 +328,16 @@ export function ExamplesApp() { name="LegendListChatBenchmark" component={LegendListChatBenchmarkScreen} /> + + + + ); diff --git a/example/src/examples/CardsExample.tsx b/example/src/examples/CardsExample.tsx new file mode 100644 index 0000000..37021c6 --- /dev/null +++ b/example/src/examples/CardsExample.tsx @@ -0,0 +1,324 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Dimensions, + Image, + Pressable, + Text, + useWindowDimensions, + View, +} from "react-native"; +import { scheduleOnRN } from "react-native-worklets"; +import { + createListDataSource, + List, + useLinearListLayout, +} from "react-native-list"; +import type { + ListContentEqualByType, + ListDataSource, + ListItem, + ListRenderers, +} from "react-native-list"; +import { ExampleHeader } from "../components"; +import { styles } from "../styles"; +import { buildCards } from "./cardsData"; +import type { Card } from "./cardsData"; + +type CardsHeaderItem = ListItem; + +type CardsCardData = Card & { + isExpanded: boolean; +}; + +type CardsCardItem = ListItem; + +type CardsItem = CardsHeaderItem | CardsCardItem; + +const cardsHeaderItemType = "cards-header"; +const cardsCardItemType = "cards-card"; +const cardsEstimatedItemLength = Dimensions.get("window").height / 4; + +function makeCardsRows(cards: readonly Card[]): CardsItem[] { + const headerItem: CardsHeaderItem = { + key: "cards-header", + type: cardsHeaderItemType, + data: {}, + }; + const rows: CardsItem[] = [headerItem]; + + for (let index = 0; index < cards.length; index += 1) { + const card = cards[index]!; + const row: CardsCardItem = { + key: card.id, + type: cardsCardItemType, + data: { + ...card, + isExpanded: false, + }, + }; + + rows.push(row); + } + + return rows; +} + +function renderCardsHeader(contentWidth: number): React.ReactElement { + "worklet"; + + return ( + + + + ); +} + +function renderCard( + item: CardsCardItem | undefined, + contentWidth: number, + onToggleExpanded: (key: string) => void, +): React.ReactElement { + "worklet"; + + let authorName = ""; + let itemId = ""; + let timestamp = ""; + let title = ""; + let body = ""; + let expandedBody = ""; + let avatarSource = {}; + + if (item != null) { + authorName = item.data.authorName; + itemId = item.data.id; + timestamp = item.data.timestamp; + title = item.data.title; + body = item.data.body; + avatarSource = { + uri: item.data.avatarUrl, + }; + + if (item.data.isExpanded) { + expandedBody = item.data.expandedBody; + } + } + + return ( + + {/* { + if (item == null) { + return; + } + + scheduleOnRN(onToggleExpanded, item.key); + }} + > */} + + + + + + {authorName} {itemId} + + {timestamp} + + + + {title} + + {body} + {expandedBody} + + + 💗 42 + 💬 12 + 🔁 8 + + + {/* */} + + ); +} + +function makeCardsRenderers( + contentWidth: number, + onToggleExpanded: (key: string) => void, +): ListRenderers { + return { + [cardsHeaderItemType]: { + renderItemWorklet: () => { + "worklet"; + + return renderCardsHeader(contentWidth); + }, + }, + [cardsCardItemType]: { + renderItemWorklet: ({ item }) => { + "worklet"; + + return renderCard(item, contentWidth, onToggleExpanded); + }, + }, + }; +} + +function makeCardsContentEqualByType(): ListContentEqualByType { + return { + [cardsHeaderItemType]: () => { + return true; + }, + [cardsCardItemType]: (oldItem, newItem) => { + if (oldItem.data.id !== newItem.data.id) { + return false; + } + + if (oldItem.data.isExpanded !== newItem.data.isExpanded) { + return false; + } + + return true; + }, + }; +} + +function replaceDataSourceData( + dataSource: ListDataSource, + rows: readonly CardsItem[], + animated: boolean, +) { + dataSource.replaceData(rows, animated); +} + +export function CardsExample(props: { onBack: () => void }) { + const { width } = useWindowDimensions(); + const contentWidth = width; + const cards = useMemo(() => { + return buildCards(); + }, []); + const initialRows = useMemo(() => { + return makeCardsRows(cards); + }, [cards]); + const [rows, setRows] = useState(initialRows); + const rowsRef = useRef(rows); + const contentEqualByType = useMemo(() => { + return makeCardsContentEqualByType(); + }, []); + const dataSource = useMemo(() => { + return createListDataSource({ + isContentEqualByType: contentEqualByType, + }); + }, [contentEqualByType]); + const toggleExpanded = useCallback( + (key: string) => { + const currentRows = rowsRef.current; + const rowIndex = currentRows.findIndex((row) => { + return row.key === key; + }); + + if (rowIndex === -1) { + throw new Error("Missing card row " + key); + } + + const currentRow = currentRows[rowIndex]!; + + if (currentRow.type !== cardsCardItemType) { + throw new Error("Expected card row " + key); + } + + const nextRows = currentRows.slice(); + const nextRow: CardsCardItem = { + ...currentRow, + data: { + ...currentRow.data, + isExpanded: !currentRow.data.isExpanded, + }, + }; + + nextRows[rowIndex] = nextRow; + rowsRef.current = nextRows; + dataSource.updateItem(rowIndex, nextRow); + setRows(nextRows); + }, + [dataSource], + ); + const renderersByType = useMemo(() => { + return makeCardsRenderers(contentWidth, toggleExpanded); + }, [contentWidth, toggleExpanded]); + const layoutConfig = useMemo(() => { + return { + topInset: 0, + bottomInset: 0, + itemSpacing: 0, + iosConfig: { + estimatedItemSize: { + height: cardsEstimatedItemLength, + }, + }, + }; + }, []); + const layout = useLinearListLayout(layoutConfig); + const didHydrateDataSource = useRef(false); + const listKey = "cards-" + String(contentWidth); + + useEffect(() => { + rowsRef.current = rows; + }, [rows]); + + useEffect(() => { + if (didHydrateDataSource.current) { + return; + } + + didHydrateDataSource.current = true; + replaceDataSourceData(dataSource, rows, false); + }, [dataSource, rows]); + + useEffect(() => { + return () => { + dataSource.release(); + }; + }, [dataSource]); + + return ( + + + + + {cards.length} cards + react-native-list + + + + + ); +} diff --git a/example/src/examples/CardsFeedExample.tsx b/example/src/examples/CardsFeedExample.tsx new file mode 100644 index 0000000..29d3233 --- /dev/null +++ b/example/src/examples/CardsFeedExample.tsx @@ -0,0 +1,555 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Dimensions, + Pressable, + Text, + useWindowDimensions, + View, +} from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { scheduleOnRN } from "react-native-worklets"; +import { + createListDataSource, + List, + useLinearListLayout, +} from "react-native-list"; +import type { + ListContentEqualByType, + ListDataSource, + ListItem, + ListRenderers, +} from "react-native-list"; +import { ExampleHeader } from "../components"; +import { styles } from "../styles"; +import { buildFeedCards } from "./cardsFeedData"; +import type { FeedCard, FeedPollOption } from "./cardsFeedData"; + +type CardsFeedItemData = FeedCard & { + isExpanded: boolean; + isLiked: boolean; + selectedOptionId: string | null; +}; + +type CardsFeedItem = ListItem; + +const cardsFeedItemType = "cards-feed-card"; + +function getFeedPollVotes( + optionId: string, + selectedOptionId: string | null, + votes: number, +) { + "worklet"; + + let nextVotes = votes; + + if (selectedOptionId === optionId) { + nextVotes += 1; + } + + return nextVotes; +} + +function makeCardsFeedItems(cards: readonly FeedCard[]): CardsFeedItem[] { + const rows: CardsFeedItem[] = []; + + for (let index = 0; index < cards.length; index += 1) { + const card = cards[index]!; + const row: CardsFeedItem = { + key: card.id, + type: cardsFeedItemType, + data: { + ...card, + isExpanded: false, + isLiked: false, + selectedOptionId: null, + }, + }; + + rows.push(row); + } + + return rows; +} + +const horizontalEdgeInset = 16; +function getContentWidth(windowWidth: number) { + let contentWidth = windowWidth - horizontalEdgeInset * 2; + + if (contentWidth > 560) { + contentWidth = 560; + } + + if (contentWidth < 220) { + contentWidth = 220; + } + + return contentWidth; +} + +function renderFeedPlaceholder(contentWidth: number): React.ReactElement { + "worklet"; + + return ( + + ); +} + +function renderPollOption( + option: FeedPollOption, + item: CardsFeedItem, + onSelectPollOption: (key: string, optionId: string) => void, +) { + "worklet"; + + const selectedOptionId = item.data.selectedOptionId; + const isSelected = selectedOptionId === option.id; + let optionStyle: StyleProp = styles.feedPollOption; + + if (isSelected) { + optionStyle = [styles.feedPollOption, styles.feedPollOptionSelected]; + } + + return ( + { + if (isSelected) { + return; + } + + scheduleOnRN(onSelectPollOption, item.key, option.id); + }} + style={optionStyle} + > + {option.label} + + {getFeedPollVotes(option.id, selectedOptionId, option.votes)} votes + + + ); +} + +function renderFeedCard( + item: CardsFeedItem, + contentWidth: number, + onToggleLike: (key: string) => void, + onToggleExpand: (key: string) => void, + onSelectPollOption: (key: string, optionId: string) => void, +): React.ReactElement { + "worklet"; + + let storyContent = null; + let photoContent = null; + let pollContent = null; + let quoteContent = null; + let eventContent = null; + let expandedContent = null; + let expandButton = null; + let likeLabel = "Like"; + let likeCount = item.data.reactionCount; + let likeButtonStyle: StyleProp = styles.feedButton; + let likeButtonTextStyle = styles.feedButtonText; + + if (item.data.isLiked) { + likeLabel = "Liked"; + likeCount += 1; + likeButtonStyle = [styles.feedButton, styles.feedButtonActive]; + likeButtonTextStyle = styles.feedButtonTextActive; + } + + if (item.data.kind === "story") { + storyContent = ( + <> + + + {item.data.categoryLabel} + + + {item.data.title} + {item.data.body} + + ); + } + + if (item.data.kind === "photo") { + photoContent = ( + <> + + {item.data.mediaLabel} + {item.data.title} + + {item.data.mediaSubtitle} + + + {item.data.body} + + ); + } + + if (item.data.kind === "poll") { + pollContent = ( + <> + {item.data.title} + {item.data.body} + + {item.data.pollOptions.map((option) => { + return renderPollOption(option, item, onSelectPollOption); + })} + + + ); + } + + if (item.data.kind === "quote") { + quoteContent = ( + <> + + {item.data.quote} + {item.data.source} + + {item.data.body} + + ); + } + + if (item.data.kind === "event") { + eventContent = ( + <> + + + {item.data.highlight} + + + + {item.data.attendeesLabel} + + + + {item.data.title} + {item.data.body} + {item.data.location} + + ); + } + + if (item.data.kind !== "poll" && item.data.isExpanded) { + expandedContent = ( + {item.data.expandedBody} + ); + } + + if (item.data.kind !== "poll") { + let expandLabel = "Expand"; + + if (item.data.isExpanded) { + expandLabel = "Collapse"; + } + + expandButton = ( + { + scheduleOnRN(onToggleExpand, item.key); + }} + style={styles.feedButton} + > + {expandLabel} + + ); + } + + return ( + + + + + {item.data.author.slice(0, 1)} + + + + {item.data.author} + {item.data.timestampLabel} + + + {item.data.kind} + + + + {storyContent} + {photoContent} + {pollContent} + {quoteContent} + {eventContent} + {expandedContent} + + + { + scheduleOnRN(onToggleLike, item.key); + }} + style={likeButtonStyle} + > + + {likeLabel} - {likeCount} + + + + {item.data.commentCount} comments + + {expandButton} + + + ); +} + +function makeFeedRenderers( + contentWidth: number, + onToggleLike: (key: string) => void, + onToggleExpand: (key: string) => void, + onSelectPollOption: (key: string, optionId: string) => void, +): ListRenderers { + return { + [cardsFeedItemType]: { + renderItemWorklet: ({ item }) => { + "worklet"; + + if (item == null) { + return renderFeedPlaceholder(contentWidth); + } + + return renderFeedCard( + item, + contentWidth, + onToggleLike, + onToggleExpand, + onSelectPollOption, + ); + }, + }, + }; +} + +function makeFeedContentEqualByType(): ListContentEqualByType { + return { + [cardsFeedItemType]: (oldItem, newItem) => { + if (oldItem.data.id !== newItem.data.id) { + return false; + } + if (oldItem.data.isExpanded !== newItem.data.isExpanded) { + return false; + } + if (oldItem.data.isLiked !== newItem.data.isLiked) { + return false; + } + if (oldItem.data.selectedOptionId !== newItem.data.selectedOptionId) { + return false; + } + return true; + }, + }; +} + +function replaceDataSourceData( + dataSource: ListDataSource, + rows: readonly CardsFeedItem[], + animated: boolean, +) { + dataSource.replaceData(rows, animated); +} + +export function CardsFeedExample(props: { onBack: () => void }) { + const { width } = useWindowDimensions(); + const contentWidth = getContentWidth(width); + const cards = useMemo(() => { + return buildFeedCards(); + }, []); + const initialRows = useMemo(() => { + return makeCardsFeedItems(cards); + }, [cards]); + const [rows, setRows] = useState(initialRows); + const rowsRef = useRef(rows); + const contentEqualByType = useMemo(() => { + return makeFeedContentEqualByType(); + }, []); + const dataSource = useMemo(() => { + return createListDataSource({ + isContentEqualByType: contentEqualByType, + }); + }, [contentEqualByType]); + + const updateRow = useCallback( + ( + key: string, + updateData: (data: CardsFeedItemData) => CardsFeedItemData, + ) => { + const currentRows = rowsRef.current; + const rowIndex = currentRows.findIndex((row) => { + return row.key === key; + }); + + if (rowIndex === -1) { + throw new Error("Missing cards feed row " + key); + } + + const currentRow = currentRows[rowIndex]!; + const nextRows = currentRows.slice(); + const nextRow = { + ...currentRow, + data: updateData(currentRow.data), + }; + + nextRows[rowIndex] = nextRow; + rowsRef.current = nextRows; + dataSource.updateItem(rowIndex, nextRow); + setRows(nextRows); + }, + [dataSource], + ); + + const toggleLike = useCallback( + (key: string) => { + updateRow(key, (data) => { + return { + ...data, + isLiked: !data.isLiked, + }; + }); + }, + [updateRow], + ); + + const toggleExpand = useCallback( + (key: string) => { + updateRow(key, (data) => { + return { + ...data, + isExpanded: !data.isExpanded, + }; + }); + }, + [updateRow], + ); + + const selectPollOption = useCallback( + (key: string, optionId: string) => { + updateRow(key, (data) => { + return { + ...data, + selectedOptionId: optionId, + }; + }); + }, + [updateRow], + ); + + const renderersByType = useMemo(() => { + return makeFeedRenderers( + contentWidth, + toggleLike, + toggleExpand, + selectPollOption, + ); + }, [contentWidth, selectPollOption, toggleExpand, toggleLike]); + const layoutConfig = useMemo(() => { + return { + topInset: 16, + bottomInset: 24, + itemSpacing: 12, + itemHorizontalInset: horizontalEdgeInset, + iosConfig: { + estimatedItemSize: { + height: Dimensions.get("window").height / 3, + }, + }, + }; + }, []); + const layout = useLinearListLayout(layoutConfig); + const didHydrateDataSource = useRef(false); + const listKey = "cards-feed-" + String(contentWidth); + + useEffect(() => { + rowsRef.current = rows; + }, [rows]); + + useEffect(() => { + if (didHydrateDataSource.current) { + return; + } + + didHydrateDataSource.current = true; + replaceDataSourceData(dataSource, rows, false); + }, [dataSource, rows]); + + useEffect(() => { + return () => { + dataSource.release(); + }; + }, [dataSource]); + + return ( + + + + + {rows.length} cards + react-native-list + + + + + ); +} diff --git a/example/src/examples/ChatBenchmarkExample.tsx b/example/src/examples/ChatBenchmarkExample.tsx index 52b48f0..9904f21 100644 --- a/example/src/examples/ChatBenchmarkExample.tsx +++ b/example/src/examples/ChatBenchmarkExample.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef } from "react"; import { Text, useWindowDimensions, View } from "react-native"; -import type { StyleProp, ViewStyle } from "react-native"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { createListDataSource, List, @@ -14,7 +14,10 @@ import type { } from "react-native-list"; import { ExampleHeader } from "../components"; import { styles } from "../styles"; -import type { ChatBenchmarkMessage } from "./chatBenchmarkData"; +import type { + ChatBenchmarkMessage, + ChatBenchmarkReaction, +} from "./chatBenchmarkData"; import { areChatBenchmarkMessagesEqual, chatBenchmarkMessageCount, @@ -50,11 +53,23 @@ function makeChatItems( } function renderChatItem( - item: ChatBenchmarkItem, + item: ChatBenchmarkItem | undefined, contentWidth: number, ): React.ReactElement { "worklet"; + let authorText = ""; + let messageText = ""; + let timestampText = ""; + let reactions: readonly ChatBenchmarkReaction[] = []; + let authorStyle: StyleProp = [ + styles.chatAuthor, + styles.chatAuthorHidden, + ]; + let reactionsStyle: StyleProp = [ + styles.chatReactions, + styles.chatReactionsHidden, + ]; let rowStyle: StyleProp = [ styles.chatRow, styles.chatRowOther, @@ -66,71 +81,72 @@ function renderChatItem( styles.chatBubble, styles.chatBubbleOther, ]; - let author = null; - let reactions = null; - - if (item.data.isOwnMessage) { - rowStyle = [ - styles.chatRow, - styles.chatRowOwn, - { - width: contentWidth, - }, - ]; - bubbleStyle = [styles.chatBubble, styles.chatBubbleOwn]; - } else { - author = {item.data.author}; - } - if (item.data.reactions.length > 0) { - reactions = ( - - {item.data.reactions.map((reaction) => { - return ( - - {reaction.emoji} - {reaction.count} - - ); - })} - - ); + if (item != null) { + messageText = item.data.message; + timestampText = item.data.timestamp; + reactions = item.data.reactions; + + if (item.data.isOwnMessage) { + rowStyle = [ + styles.chatRow, + styles.chatRowOwn, + { + width: contentWidth, + }, + ]; + bubbleStyle = [styles.chatBubble, styles.chatBubbleOwn]; + } else { + authorText = item.data.author; + authorStyle = styles.chatAuthor; + } + + if (item.data.reactions.length > 0) { + reactionsStyle = styles.chatReactions; + } } return ( - {author} - {item.data.message} - {item.data.timestamp} + {authorText} + {messageText} + {timestampText} + + + {renderChatReactionSlot("reaction-0", reactions, 0)} + {renderChatReactionSlot("reaction-1", reactions, 1)} + {renderChatReactionSlot("reaction-2", reactions, 2)} - {reactions} ); } -function renderChatPlaceholder(contentWidth: number): React.ReactElement { +function renderChatReactionSlot( + slotKey: string, + reactions: readonly ChatBenchmarkReaction[], + index: number, +) { "worklet"; - const rowStyle: StyleProp = [ - styles.chatRow, - styles.chatRowOther, - { - width: contentWidth, - }, - ]; - const bubbleStyle: StyleProp = [ - styles.chatBubble, - styles.chatBubbleOther, + const reaction = reactions[index]; + let emojiText = ""; + let countText = ""; + let chipStyle: StyleProp = [ + styles.chatReactionChip, + styles.chatReactionChipHidden, ]; + if (reaction != null) { + emojiText = reaction.emoji; + countText = String(reaction.count); + chipStyle = styles.chatReactionChip; + } + return ( - - - {""} - {""} - {""} - + + {emojiText} + {countText} ); } @@ -143,10 +159,6 @@ function makeChatRenderers( renderItemWorklet: ({ item }) => { "worklet"; - if (item == null) { - return renderChatPlaceholder(contentWidth); - } - return renderChatItem(item, contentWidth); }, }, diff --git a/example/src/examples/LegendListCardsExample.tsx b/example/src/examples/LegendListCardsExample.tsx new file mode 100644 index 0000000..55dacf3 --- /dev/null +++ b/example/src/examples/LegendListCardsExample.tsx @@ -0,0 +1,112 @@ +import React, { memo, useMemo } from "react"; +import { Image, Pressable, Text, View } from "react-native"; +import { LegendList, useRecyclingState } from "@legendapp/list/react-native"; +import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { ExampleHeader } from "../components"; +import { styles } from "../styles"; +import { buildCards } from "./cardsData"; +import type { Card } from "./cardsData"; + +const cardsEstimatedItemLength = 400; +const cardsDrawDistance = 250; + +function keyExtractor(item: Card) { + return item.id; +} + +const LegendCard = memo(function LegendCard( + props: LegendListRenderItemProps, +) { + const item = props.item; + const [isExpanded, setExpanded] = useRecyclingState(false); + let expandedBody = null; + + if (isExpanded) { + expandedBody = item.expandedBody; + } + + return ( + + { + setExpanded((current) => { + return !current; + }); + }} + > + + + + + + {item.authorName} {item.id} + + {item.timestamp} + + + + {item.title} + + {item.body} + {expandedBody} + + + 💗 42 + 💬 12 + 🔁 8 + + + + + ); +}); + +function renderItem(props: LegendListRenderItemProps) { + return ; +} + +function ListHeaderComponent() { + return ; +} + +export function LegendListCardsExample(props: { onBack: () => void }) { + const cards = useMemo(() => { + return buildCards(); + }, []); + + return ( + + + + + {cards.length} cards + Legend List + + + + + ); +} diff --git a/example/src/examples/LegendListCardsFeedExample.tsx b/example/src/examples/LegendListCardsFeedExample.tsx new file mode 100644 index 0000000..71adb65 --- /dev/null +++ b/example/src/examples/LegendListCardsFeedExample.tsx @@ -0,0 +1,291 @@ +import React, { useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { + LegendList, + type LegendListRenderItemProps, + useRecyclingState, +} from "@legendapp/list/react-native"; +import { ExampleHeader } from "../components"; +import { styles } from "../styles"; +import { buildFeedCards } from "./cardsFeedData"; +import type { FeedCard, FeedPollOption } from "./cardsFeedData"; + +function keyExtractor(item: FeedCard) { + return item.id; +} + +function getFeedPollVotes( + optionId: string, + selectedOptionId: string | null, + votes: number, +) { + let nextVotes = votes; + + if (selectedOptionId === optionId) { + nextVotes += 1; + } + + return nextVotes; +} + +function renderPollOption( + option: FeedPollOption, + selectedOptionId: string | null, + setSelectedOptionId: (optionId: string) => void, +) { + const isSelected = selectedOptionId === option.id; + let optionStyle: StyleProp = styles.feedPollOption; + + if (isSelected) { + optionStyle = [styles.feedPollOption, styles.feedPollOptionSelected]; + } + + return ( + { + if (isSelected) { + return; + } + + setSelectedOptionId(option.id); + }} + style={optionStyle} + > + {option.label} + + {getFeedPollVotes(option.id, selectedOptionId, option.votes)} votes + + + ); +} + +function LegendFeedCardItem(props: LegendListRenderItemProps) { + const item = props.item; + const [isExpanded, setExpanded] = useRecyclingState(false); + const [isLiked, setLiked] = useRecyclingState(false); + const [selectedOptionId, setSelectedOptionId] = useRecyclingState< + string | null + >(null); + let storyContent = null; + let photoContent = null; + let pollContent = null; + let quoteContent = null; + let eventContent = null; + let expandedContent = null; + let expandButton = null; + let likeLabel = "Like"; + let likeCount = item.reactionCount; + let likeButtonStyle: StyleProp = styles.feedButton; + let likeButtonTextStyle = styles.feedButtonText; + + if (isLiked) { + likeLabel = "Liked"; + likeCount += 1; + likeButtonStyle = [styles.feedButton, styles.feedButtonActive]; + likeButtonTextStyle = styles.feedButtonTextActive; + } + + if (item.kind === "story") { + storyContent = ( + <> + + {item.categoryLabel} + + {item.title} + {item.body} + + ); + } + + if (item.kind === "photo") { + photoContent = ( + <> + + {item.mediaLabel} + {item.title} + {item.mediaSubtitle} + + {item.body} + + ); + } + + if (item.kind === "poll") { + pollContent = ( + <> + {item.title} + {item.body} + + {item.pollOptions.map((option) => { + return renderPollOption( + option, + selectedOptionId, + setSelectedOptionId, + ); + })} + + + ); + } + + if (item.kind === "quote") { + quoteContent = ( + <> + + {item.quote} + {item.source} + + {item.body} + + ); + } + + if (item.kind === "event") { + eventContent = ( + <> + + + {item.highlight} + + + + {item.attendeesLabel} + + + + {item.title} + {item.body} + {item.location} + + ); + } + + if (item.kind !== "poll" && isExpanded) { + expandedContent = ( + {item.expandedBody} + ); + } + + if (item.kind !== "poll") { + let expandLabel = "Expand"; + + if (isExpanded) { + expandLabel = "Collapse"; + } + + expandButton = ( + { + setExpanded((current) => { + return !current; + }); + }} + style={styles.feedButton} + > + {expandLabel} + + ); + } + + return ( + + + + {item.author.slice(0, 1)} + + + {item.author} + {item.timestampLabel} + + + {item.kind} + + + + {storyContent} + {photoContent} + {pollContent} + {quoteContent} + {eventContent} + {expandedContent} + + + { + setLiked((current) => { + return !current; + }); + }} + style={likeButtonStyle} + > + + {likeLabel} - {likeCount} + + + {item.commentCount} comments + {expandButton} + + + ); +} + +function renderItem(props: LegendListRenderItemProps) { + return ; +} + +export function LegendListCardsFeedExample(props: { onBack: () => void }) { + const feed = useMemo(() => { + return buildFeedCards(); + }, []); + + return ( + + + + + {feed.length} cards + Legend List + + + + + ); +} diff --git a/example/src/examples/cardsData.ts b/example/src/examples/cardsData.ts new file mode 100644 index 0000000..9562203 --- /dev/null +++ b/example/src/examples/cardsData.ts @@ -0,0 +1,108 @@ +export type Card = { + authorName: string; + avatarUrl: string; + body: string; + expandedBody: string; + id: string; + timestamp: string; + title: string; +}; + +const cardsRandomNames = [ + "Alex Thompson", + "Jordan Lee", + "Sam Parker", + "Taylor Kim", + "Morgan Chen", + "Riley Zhang", + "Casey Williams", + "Quinn Anderson", + "Blake Martinez", + "Avery Rodriguez", + "Drew Campbell", + "Jamie Foster", + "Skylar Patel", + "Charlie Wright", + "Sage Mitchell", + "River Johnson", + "Phoenix Garcia", + "Jordan Taylor", + "Reese Cooper", + "Morgan Bailey", +] as const; + +const cardsLoremSentences = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "Duis aute irure dolor in reprehenderit in voluptate velit esse.", + "Excepteur sint occaecat cupidatat non proident, sunt in culpa.", + "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit.", + "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "Duis aute irure dolor in reprehenderit in voluptate velit esse.", + "Excepteur sint occaecat cupidatat non proident, sunt in culpa.", + "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit.", + "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.", +] as const; + +function makeSentenceCount(index: number) { + return ((index * 7919) % 12) + 1; +} + +function makeBody(sentenceCount: number) { + let body = ""; + + for (let index = 0; index < sentenceCount; index += 1) { + const sentenceIndex = index % cardsLoremSentences.length; + const sentence = cardsLoremSentences[sentenceIndex]!; + + if (body.length > 0) { + body += " "; + } + + body += sentence; + } + + return body; +} + +function makeTimestamp(index: number) { + const hourValue = Math.max(1, index % 24); + return String(hourValue) + "h ago"; +} + +function makeAvatarUrl(index: number) { + const avatarIndex = (index % 20) + 1; + return "https://i.pravatar.cc/150?img=" + String(avatarIndex); +} + +function makeCard(index: number): Card { + const nameIndex = index % cardsRandomNames.length; + const sentenceCount = makeSentenceCount(index); + const body = makeBody(sentenceCount); + const id = String(index); + + return { + authorName: cardsRandomNames[nameIndex]!, + avatarUrl: makeAvatarUrl(index), + body, + expandedBody: body, + id, + timestamp: makeTimestamp(index), + title: "Item #" + id, + }; +} + +export function buildCards(count = 1000) { + const cards: Card[] = []; + + for (let index = 0; index < count; index += 1) { + const card = makeCard(index); + cards.push(card); + } + + return cards; +} diff --git a/example/src/examples/cardsFeedData.ts b/example/src/examples/cardsFeedData.ts new file mode 100644 index 0000000..097d60b --- /dev/null +++ b/example/src/examples/cardsFeedData.ts @@ -0,0 +1,309 @@ +export type FeedPollOption = { + id: string; + label: string; + votes: number; +}; + +export type FeedCardBase = { + accentColor: string; + author: string; + body: string; + commentCount: number; + expandedBody: string; + id: string; + reactionCount: number; + timestampLabel: string; + title: string; +}; + +export type FeedCard = + | (FeedCardBase & { + categoryLabel: string; + kind: "story"; + }) + | (FeedCardBase & { + kind: "photo"; + mediaHeight: number; + mediaLabel: string; + mediaSubtitle: string; + }) + | (FeedCardBase & { + kind: "poll"; + pollOptions: FeedPollOption[]; + totalVotes: number; + }) + | (FeedCardBase & { + kind: "quote"; + quote: string; + source: string; + }) + | (FeedCardBase & { + attendeesLabel: string; + highlight: string; + kind: "event"; + location: string; + }); + +const feedAuthors = [ + "Avery Chen", + "Jordan Kim", + "Morgan Patel", + "Nina Brooks", + "Sam Rivera", + "Quinn Foster", +] as const; + +const feedTitles = [ + "Release Notes", + "Feed QA", + "Bench Snapshot", + "Launch Debrief", + "Design Review", + "Support Pulse", +] as const; + +const feedBodies = [ + "Shipped the new measurement overlay and tightened the scroll anchor behavior on dynamic rows.", + "Testing the revised card feed with image-heavy posts and swipe actions. The recycled cells now preserve interaction state cleanly.", + "Pinned a new benchmark result comparing cold render time and steady-state scroll under mixed row heights.", + "Documented the fallback path for variable-size rows so the list holds position while content streams in.", + "Refined the card composition to keep avatars, actions, and media blocks stable as cells recycle.", + "Captured another batch of reports from long-session scrolling and queued follow-up fixture cases for edge paths.", +] as const; + +const feedAccentColors = [ + "#d7e8f8", + "#f7e7bc", + "#f1d7dd", + "#d8e0f6", + "#d9ebd6", + "#e8dbf5", +] as const; + +const feedCategoryLabels = [ + "Engineering", + "Design", + "Operations", + "Launch", + "Research", + "Support", +] as const; + +const feedMediaLabels = [ + "Preview Board", + "Field Photo", + "Snapshot", + "Moodboard", + "Run Capture", + "Launch Still", +] as const; + +const feedMediaSubtitles = [ + "Tall image block to vary the measured height.", + "A media-heavy row that recycles differently than text-only posts.", + "The preview area helps make the feed visually heterogeneous.", + "Use this shape to show a post that is mostly image and only partly text.", +] as const; + +const feedQuoteLines = [ + "The point of this feed is not just to look polished. It should make mixed templates obvious enough that virtualization work is visible.", + "A good feed example carries text-only posts, oversized media, quote cards, and interactive polls in the same viewport.", + "If every post has the same structure, the feed hides exactly the variation a list library needs to handle well.", + "Heterogeneous templates are where estimate quality, recycling, and in-place updates become visible.", +] as const; + +const feedEventLocations = [ + "Pier 19", + "Studio 4", + "Archive Hall", + "Workshop East", + "Skyline Room", + "North Commons", +] as const; + +const feedHighlights = [ + "Starts soon", + "Pinned update", + "RSVP open", + "Schedule change", + "Limited seats", + "Live now", +] as const; + +const feedPollLabels = [ + [ + "Keep reactions inline", + "Collapse older cards faster", + "Ship the new media card", + ], + [ + "More height variance", + "Faster scroll-to-end", + "Better sticky header backdrop", + ], + ["Auto-play previews", "Expandable threads", "Pinned composer card"], +] as const; + +function createSeededRandom(seed: number) { + let current = seed >>> 0; + + return () => { + current = (current * 1664525 + 1013904223) >>> 0; + return current / 0x100000000; + }; +} + +function pickOne(values: readonly T[], random: () => number) { + const nextValue = random(); + const nextIndexValue = nextValue * values.length; + const nextIndex = Math.floor(nextIndexValue); + return values[nextIndex]!; +} + +function makeTimestampLabel(index: number) { + if (index < 5) { + return "Now"; + } + + const minuteValue = 6 + (index % 45); + const minuteText = String(minuteValue); + return minuteText + "m"; +} + +function makeBaseFeedCard(index: number, accentColor: string): FeedCardBase { + const cardNumber = index + 1; + const authorIndex = index % feedAuthors.length; + const titleIndex = index % feedTitles.length; + const firstBodyIndex = index % feedBodies.length; + const secondBodyIndex = (index + 2) % feedBodies.length; + const firstExpandedIndex = (index + 1) % feedBodies.length; + const secondExpandedIndex = (index + 3) % feedBodies.length; + const thirdExpandedIndex = (index + 4) % feedBodies.length; + const commentCount = 6 + ((index * 5) % 19); + const reactionCount = 18 + ((index * 7) % 29); + + return { + accentColor, + author: feedAuthors[authorIndex]!, + body: feedBodies[firstBodyIndex]! + " " + feedBodies[secondBodyIndex]!, + commentCount, + expandedBody: + feedBodies[firstExpandedIndex]! + + " " + + feedBodies[secondExpandedIndex]! + + " " + + feedBodies[thirdExpandedIndex]!, + id: "feed-" + String(cardNumber), + reactionCount, + timestampLabel: makeTimestampLabel(index), + title: feedTitles[titleIndex]!, + }; +} + +function makePollOptions(baseId: string, index: number): FeedPollOption[] { + const optionSetIndex = index % feedPollLabels.length; + const optionSet = feedPollLabels[optionSetIndex]!; + const pollOptions: FeedPollOption[] = []; + + for (let optionIndex = 0; optionIndex < optionSet.length; optionIndex += 1) { + const optionNumber = optionIndex + 1; + const voteOffset = (index * 3) % 11; + const votes = 18 + optionIndex * 9 + voteOffset; + const option: FeedPollOption = { + id: baseId + "-option-" + String(optionNumber), + label: optionSet[optionIndex]!, + votes, + }; + + pollOptions.push(option); + } + + return pollOptions; +} + +function getTotalVotes(pollOptions: readonly FeedPollOption[]) { + let totalVotes = 0; + + for (let index = 0; index < pollOptions.length; index += 1) { + totalVotes += pollOptions[index]!.votes; + } + + return totalVotes; +} + +function makeFeedCard(index: number, random: () => number): FeedCard { + const kindIndex = index % 5; + const accentColor = pickOne(feedAccentColors, random); + const base = makeBaseFeedCard(index, accentColor); + + if (kindIndex === 0) { + const categoryIndex = index % feedCategoryLabels.length; + return { + ...base, + categoryLabel: feedCategoryLabels[categoryIndex]!, + kind: "story", + }; + } + + if (kindIndex === 1) { + const mediaHeights = [180, 220, 280, 340] as const; + const mediaHeightIndex = index % mediaHeights.length; + const mediaLabelIndex = index % feedMediaLabels.length; + const mediaSubtitleIndex = index % feedMediaSubtitles.length; + + return { + ...base, + kind: "photo", + mediaHeight: mediaHeights[mediaHeightIndex]!, + mediaLabel: feedMediaLabels[mediaLabelIndex]!, + mediaSubtitle: feedMediaSubtitles[mediaSubtitleIndex]!, + }; + } + + if (kindIndex === 2) { + const pollOptions = makePollOptions(base.id, index); + const totalVotes = getTotalVotes(pollOptions); + + return { + ...base, + kind: "poll", + pollOptions, + totalVotes, + }; + } + + if (kindIndex === 3) { + const quoteIndex = index % feedQuoteLines.length; + + return { + ...base, + kind: "quote", + quote: feedQuoteLines[quoteIndex]!, + source: base.author + " - Weekly review", + }; + } + + const attendeeCount = 12 + ((index * 4) % 38); + const highlightIndex = index % feedHighlights.length; + const locationIndex = index % feedEventLocations.length; + + return { + ...base, + attendeesLabel: String(attendeeCount) + " attendees", + highlight: feedHighlights[highlightIndex]!, + kind: "event", + location: feedEventLocations[locationIndex]!, + }; +} + +export function buildFeedCards(count = 84) { + const random = createSeededRandom(4311); + const feedCards: FeedCard[] = []; + + for (let index = 0; index < count; index += 1) { + const feedCard = makeFeedCard(index, random); + feedCards.push(feedCard); + } + + return feedCards; +} diff --git a/example/src/styles.ts b/example/src/styles.ts index a068cae..ca4a684 100644 --- a/example/src/styles.ts +++ b/example/src/styles.ts @@ -200,6 +200,11 @@ export const styles = StyleSheet.create({ fontWeight: "700", color: "#445066", }, + chatAuthorHidden: { + height: 0, + marginBottom: 0, + opacity: 0, + }, chatMessage: { fontSize: 15, lineHeight: 20, @@ -218,6 +223,11 @@ export const styles = StyleSheet.create({ gap: 5, marginTop: 3, }, + chatReactionsHidden: { + height: 0, + marginTop: 0, + opacity: 0, + }, chatReactionChip: { flexDirection: "row", alignItems: "center", @@ -229,9 +239,309 @@ export const styles = StyleSheet.create({ borderColor: "#cfd4dc", backgroundColor: "#ffffff", }, + chatReactionChipHidden: { + width: 0, + height: 0, + paddingHorizontal: 0, + paddingVertical: 0, + borderWidth: 0, + opacity: 0, + }, chatReactionText: { fontSize: 12, fontWeight: "700", color: "#343a40", }, + feedListContent: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 24, + }, + feedCard: { + padding: 16, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#d7dde5", + borderRadius: 8, + backgroundColor: "#ffffff", + }, + feedHeader: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 12, + }, + feedAvatar: { + alignItems: "center", + justifyContent: "center", + width: 36, + height: 36, + borderRadius: 18, + }, + feedAvatarText: { + fontSize: 14, + fontWeight: "800", + color: "#1d4ed8", + }, + feedPersonCopy: { + flex: 1, + gap: 2, + }, + feedPersonName: { + fontSize: 14, + fontWeight: "800", + color: "#111827", + }, + feedPersonMeta: { + fontSize: 13, + color: "#64748b", + }, + feedKindBadge: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: "#eef2ff", + }, + feedKindBadgeText: { + fontSize: 12, + fontWeight: "700", + color: "#4338ca", + textTransform: "capitalize", + }, + feedCategoryChip: { + alignSelf: "flex-start", + marginBottom: 10, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: "#f8fafc", + }, + feedCategoryChipText: { + fontSize: 12, + fontWeight: "700", + color: "#334155", + }, + feedSectionTitle: { + marginBottom: 8, + fontSize: 18, + fontWeight: "800", + color: "#111827", + }, + feedBody: { + fontSize: 14, + lineHeight: 20, + color: "#111827", + }, + feedMediaCard: { + justifyContent: "flex-end", + marginBottom: 12, + padding: 14, + borderRadius: 8, + }, + feedMediaLabel: { + fontSize: 12, + fontWeight: "800", + color: "#0f172a", + opacity: 0.72, + textTransform: "uppercase", + }, + feedMediaTitle: { + marginTop: 6, + fontSize: 20, + fontWeight: "800", + color: "#0f172a", + }, + feedMediaSubtitle: { + maxWidth: 260, + marginTop: 6, + color: "#0f172a", + opacity: 0.78, + }, + feedPollList: { + gap: 10, + marginTop: 14, + }, + feedPollOption: { + padding: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#d7dde5", + borderRadius: 8, + backgroundColor: "#f8fafc", + }, + feedPollOptionSelected: { + borderColor: "#60a5fa", + backgroundColor: "#dbeafe", + }, + feedPollOptionLabel: { + fontWeight: "700", + color: "#0f172a", + }, + feedPollOptionVotes: { + marginTop: 4, + fontSize: 12, + color: "#64748b", + }, + feedQuoteCard: { + marginBottom: 12, + padding: 16, + borderLeftWidth: 4, + borderRadius: 8, + backgroundColor: "#f8fafc", + }, + feedQuoteText: { + marginBottom: 10, + fontSize: 20, + fontWeight: "700", + lineHeight: 30, + color: "#0f172a", + }, + feedEventBadgeRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginBottom: 12, + }, + feedEventBadge: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: "#dcfce7", + }, + feedEventBadgeText: { + fontSize: 12, + fontWeight: "700", + color: "#166534", + }, + feedExpandedBody: { + marginTop: 14, + fontSize: 14, + lineHeight: 22, + color: "#334155", + }, + feedActionRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: 10, + marginTop: 16, + }, + feedButton: { + justifyContent: "center", + minHeight: 36, + paddingHorizontal: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#cbd5e1", + borderRadius: 8, + backgroundColor: "#ffffff", + }, + feedButtonActive: { + borderColor: "#111827", + backgroundColor: "#111827", + }, + feedButtonText: { + fontSize: 13, + fontWeight: "700", + color: "#111827", + }, + feedButtonTextActive: { + fontSize: 13, + fontWeight: "700", + color: "#ffffff", + }, + cardsListBackground: { + flex: 1, + backgroundColor: "#455f72", + }, + cardsHeaderBlock: { + alignSelf: "center", + width: 100, + height: 100, + borderRadius: 8, + backgroundColor: "#456aaa", + }, + cardsLegendListHeader: { + alignSelf: "center", + width: 100, + height: 100, + marginHorizontal: 8, + marginVertical: 8, + borderRadius: 8, + backgroundColor: "#456aaa", + }, + cardsItemOuter: { + paddingHorizontal: 12, + paddingVertical: 12, + }, + cardsItemContainer: { + padding: 16, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#d9dee4", + borderRadius: 8, + backgroundColor: "#ffffff", + overflow: "hidden", + shadowColor: "#000000", + shadowOffset: { + width: 0, + height: 6, + }, + shadowOpacity: 0.45, + shadowRadius: 12, + elevation: 4, + }, + cardsHeaderContainer: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + }, + cardsAvatar: { + alignItems: "center", + justifyContent: "center", + width: 40, + height: 40, + marginRight: 12, + borderRadius: 20, + backgroundColor: "#e5e7eb", + }, + cardsAvatarText: { + fontSize: 15, + fontWeight: "800", + color: "#111827", + }, + cardsHeaderText: { + flex: 1, + }, + cardsAuthorName: { + fontSize: 16, + fontWeight: "600", + color: "#111111", + }, + cardsTimestamp: { + marginTop: 2, + fontSize: 12, + color: "#8a8f96", + }, + cardsItemTitle: { + marginBottom: 8, + fontSize: 18, + fontWeight: "700", + color: "#111111", + }, + cardsItemBody: { + fontSize: 14, + lineHeight: 20, + color: "#5f6368", + }, + cardsItemFooter: { + flexDirection: "row", + flexWrap: "wrap", + gap: 16, + justifyContent: "flex-start", + marginTop: 12, + paddingTop: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "#edf0f2", + }, + cardsFooterText: { + fontSize: 14, + color: "#8a8f96", + }, }); diff --git a/example/src/types.ts b/example/src/types.ts index 803da5e..36b1b39 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -3,7 +3,11 @@ export type ExampleId = | "dynamic-text" | "dynamic-text-push-stress" | "chat-benchmark" - | "legend-list-chat-benchmark"; + | "legend-list-chat-benchmark" + | "cards-feed" + | "legend-list-cards-feed" + | "cards" + | "legend-list-cards"; export type ExampleCase = { id: ExampleId;