native carousel

This commit is contained in:
vineyardbovines
2026-03-31 15:12:29 -04:00
parent a697841a21
commit 7c11795f68
3 changed files with 730 additions and 0 deletions
@@ -0,0 +1,126 @@
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image, type ImageStyle} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
interface Props {
images: AppBskyEmbedImages.ViewImage[]
index: number
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: EventFunction
onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
}
export function GalleryItem({
images,
index,
imageStyle,
onPress,
onPressIn,
onLongPress,
viewContext,
insetBorderStyle,
containerRefs,
thumbDimsRef,
}: Props) {
const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable
onPress={
onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
{hasAlt && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && [
{
gap: 4,
padding: 5,
},
],
]}>
<Text
style={[a.font_bold, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</View>
) : null}
</View>
)
}
+293
View File
@@ -0,0 +1,293 @@
import {useContext, useMemo, useRef, useState} from 'react'
import {FlatList, Pressable, View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const CONTAINER_ASPECT_RATIO = 4 / 3
const PEEK_WIDTH = 40
const ITEM_GAP = 6
interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const t = useTheme()
const {_} = useLingui()
const ax = useAnalytics()
const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled()
const [currentPage, setCurrentPage] = useState(0)
const currentPageRef = useRef(0)
const [containerWidth, setContainerWidth] = useState(0)
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
const ref0 = useAnimatedRef()
const ref1 = useAnimatedRef()
const ref2 = useAnimatedRef()
const ref3 = useAnimatedRef()
const refs = [ref0, ref1, ref2, ref3]
for (let i = 0; i < images.length; i++) {
containerRefs[i] = refs[i]
}
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0
const snapInterval = itemWidth + ITEM_GAP
if (screenReaderEnabled) {
return (
<View
style={[a.rounded_md, a.overflow_hidden]}
accessibilityRole="adjustable">
{images.map((image, index) => (
<View
key={index}
ref={containerRefs[index]}
collapsable={false}
style={[
{aspectRatio: CONTAINER_ASPECT_RATIO},
t.atoms.bg_contrast_25,
]}>
<Pressable
onPress={
onPress
? () =>
onPress(
index,
containerRefs.slice(0, images.length),
thumbDimsRef.current.slice(),
)
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
accessibilityRole="button"
accessibilityLabel={
image.alt || _(msg`Image ${index + 1} of ${images.length}`)
}
accessibilityHint={_(msg`Opens full image`)}
style={[a.flex_1]}>
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading={index === 0 ? 'eager' : 'lazy'}
/>
<MediaInsetBorder />
</Pressable>
</View>
))}
</View>
)
}
return (
<View
style={[a.rounded_md, a.overflow_hidden]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
{containerWidth > 0 && (
<DrawerGestureBlocker>
<FlatList
data={images}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
snapToInterval={snapInterval}
snapToAlignment="start"
decelerationRate="fast"
contentContainerStyle={{gap: ITEM_GAP}}
onScroll={e => {
const offsetX = e.nativeEvent.contentOffset.x
if (snapInterval > 0) {
const page = Math.round(offsetX / snapInterval)
if (page !== currentPageRef.current) {
ax.metric('post:gallery:swipe', {
fromIndex: currentPageRef.current,
toIndex: page,
totalImages: images.length,
})
currentPageRef.current = page
setCurrentPage(page)
}
}
}}
scrollEventThrottle={16}
keyExtractor={(_, index) => String(index)}
renderItem={({item: image, index}) => (
<View
ref={containerRefs[index]}
collapsable={false}
style={[
{
width: itemWidth,
aspectRatio: CONTAINER_ASPECT_RATIO,
},
]}>
<Pressable
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
imageIndex: index,
totalImages: images.length,
})
onPress(
index,
containerRefs.slice(0, images.length),
thumbDimsRef.current.slice(),
)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
accessibilityRole="button"
accessibilityLabel={
image.alt || _(msg`Image ${index + 1} of ${images.length}`)
}
accessibilityHint={_(msg`Opens full image`)}
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading={index === 0 ? 'eager' : 'lazy'}
/>
<MediaInsetBorder />
</Pressable>
{image.alt && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
gap: 4,
padding: 5,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
) : null}
</View>
)}
/>
</DrawerGestureBlocker>
)}
{images.length > 1 && (
<View
accessible={false}
style={[
a.absolute,
a.rounded_full,
t.atoms.bg_contrast_975,
{
bottom: a.p_xs.padding,
left: a.p_xs.padding,
paddingHorizontal: 8,
paddingVertical: 4,
opacity: 0.75,
},
]}>
<Text
style={[
a.font_bold,
{fontSize: 11, color: t.atoms.text_inverted.color},
]}>
{currentPage + 1}/{images.length}
</Text>
</View>
)}
</View>
)
}
function DrawerGestureBlocker({children}: {children: React.ReactNode}) {
const drawerGesture = useContext(DrawerGestureContext)
const nativeGesture = useMemo(() => {
const gesture = Gesture.Native()
if (drawerGesture) {
gesture.blocksExternalGesture(drawerGesture)
}
return gesture
}, [drawerGesture])
return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector>
}
+311
View File
@@ -0,0 +1,311 @@
import {useRef, useState} from 'react'
import {Pressable, ScrollView, View} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme, web} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const CONTAINER_ASPECT_RATIO = 4 / 3
const PEEK_WIDTH = 40
const ITEM_GAP = 6
interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const t = useTheme()
const {_} = useLingui()
const ax = useAnalytics()
const largeAltBadge = useLargeAltBadgeEnabled()
const [currentPage, setCurrentPage] = useState(0)
const currentPageRef = useRef(0)
const scrollRef = useRef<ScrollView>(null)
const [containerWidth, setContainerWidth] = useState(0)
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
const ref0 = useAnimatedRef()
const ref1 = useAnimatedRef()
const ref2 = useAnimatedRef()
const ref3 = useAnimatedRef()
const refs = [ref0, ref1, ref2, ref3]
for (let i = 0; i < images.length; i++) {
containerRefs[i] = refs[i]
}
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0
const snapInterval = itemWidth + ITEM_GAP
const goToPage = (index: number) => {
scrollRef.current?.scrollTo({
x: index * snapInterval,
animated: true,
})
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft' && currentPage > 0) {
e.preventDefault()
goToPage(currentPage - 1)
} else if (e.key === 'ArrowRight' && currentPage < images.length - 1) {
e.preventDefault()
goToPage(currentPage + 1)
}
}
return (
<View
style={[a.rounded_md, a.overflow_hidden]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
// @ts-expect-error web-only prop
onKeyDown={handleKeyDown}
tabIndex={0}
role="group"
aria-roledescription="carousel"
aria-label={_(msg`Image gallery, ${images.length} images`)}>
{containerWidth > 0 && (
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
scrollEventThrottle={16}
style={[
{aspectRatio: CONTAINER_ASPECT_RATIO},
web({
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
}),
]}
contentContainerStyle={{gap: ITEM_GAP}}
onScroll={e => {
const offsetX = e.nativeEvent.contentOffset.x
if (snapInterval > 0) {
const page = Math.round(offsetX / snapInterval)
if (page !== currentPageRef.current) {
ax.metric('post:gallery:swipe', {
fromIndex: currentPageRef.current,
toIndex: page,
totalImages: images.length,
})
currentPageRef.current = page
setCurrentPage(page)
}
}
}}>
{images.map((image, index) => (
<View
key={index}
ref={containerRefs[index]}
collapsable={false}
style={[
{
width: itemWidth,
aspectRatio: CONTAINER_ASPECT_RATIO,
},
web({scrollSnapAlign: 'start'}),
]}
aria-roledescription="slide"
aria-label={
image.alt || _(msg`Image ${index + 1} of ${images.length}`)
}>
<Pressable
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
imageIndex: index,
totalImages: images.length,
})
onPress(
index,
containerRefs.slice(0, images.length),
thumbDimsRef.current.slice(),
)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
accessibilityRole="button"
accessibilityLabel={
image.alt || _(msg`Image ${index + 1} of ${images.length}`)
}
accessibilityHint={_(msg`Opens full image`)}
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({cursor: 'pointer'}),
]}>
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading={index === 0 ? 'eager' : 'lazy'}
/>
<MediaInsetBorder />
</Pressable>
{image.alt && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
gap: 4,
padding: 5,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
) : null}
</View>
))}
</ScrollView>
)}
{images.length > 1 && (
<>
<View
accessible={false}
style={[
a.absolute,
a.rounded_full,
t.atoms.bg_contrast_975,
{
bottom: a.p_xs.padding,
left: a.p_xs.padding,
paddingHorizontal: 8,
paddingVertical: 4,
opacity: 0.75,
},
]}>
<Text
style={[
a.font_bold,
{fontSize: 11, color: t.atoms.text_inverted.color},
]}>
{currentPage + 1}/{images.length}
</Text>
</View>
{currentPage > 0 && (
<Pressable
onPress={() => goToPage(currentPage - 1)}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint=""
style={[
a.absolute,
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_975,
{
left: a.p_xs.padding,
top: '50%',
marginTop: -16,
width: 32,
height: 32,
opacity: 0.75,
},
web({cursor: 'pointer'}),
]}>
<Text
style={[
a.font_bold,
a.text_md,
{color: t.atoms.text_inverted.color},
]}>
{'<'}
</Text>
</Pressable>
)}
{currentPage < images.length - 1 && (
<Pressable
onPress={() => goToPage(currentPage + 1)}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint=""
style={[
a.absolute,
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_975,
{
right: a.p_xs.padding,
top: '50%',
marginTop: -16,
width: 32,
height: 32,
opacity: 0.75,
},
web({cursor: 'pointer'}),
]}>
<Text
style={[
a.font_bold,
a.text_md,
{color: t.atoms.text_inverted.color},
]}>
{'>'}
</Text>
</Pressable>
)}
</>
)}
</View>
)
}