Per-image carousel badge + glass pager dots

Move the in-feed carousel "{n}/{total}" badge from the carousel
container's top-right onto each image's top-right so every slide
carries its own index, and give the Lightbox PagerDots the same
blurred dark pill background as CircleChromeButton.
This commit is contained in:
vineyardbovines
2026-06-04 12:26:49 -04:00
parent cd8ed8c5eb
commit d0b15bb90d
3 changed files with 117 additions and 84 deletions
+27 -16
View File
@@ -1,6 +1,5 @@
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
import {BlurView} from 'expo-blur'
type Props = {
count: number
@@ -14,26 +13,38 @@ const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
<View style={styles.root}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</BlurView>
</View>
)
}
const styles = StyleSheet.create({
row: {
root: {
borderRadius: 999,
overflow: 'hidden',
},
inner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
activeDot: {
width: ACTIVE,
@@ -0,0 +1,62 @@
import {StyleSheet, View} from 'react-native'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={styles.root}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
root: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
+28 -68
View File
@@ -165,13 +165,6 @@ export function Gallery({
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
const currentIndexRef = useRef(0)
/*
* Reactive mirror of currentIndexRef for the numbering badge. The ref drives
* scroll/keyboard logic without re-rendering; this state only updates when
* the index actually changes (once per image, not per scroll frame), so the
* badge stays live and cheap.
*/
const [currentIndex, setCurrentIndexState] = useState(0)
const emitSwipeMetric = useMemo(
() =>
@@ -189,7 +182,6 @@ export function Gallery({
const prev = currentIndexRef.current
if (prev !== index) {
currentIndexRef.current = index
setCurrentIndexState(index)
emitSwipeMetric(prev, index)
}
}
@@ -356,70 +348,10 @@ export function Gallery({
}}
/>
</BlockDrawerGesture>
{!hideBadges && images.length > 1 ? (
<GalleryCounter
currentIndex={currentIndex}
imageCount={images.length}
/>
) : null}
</View>
)
}
/*
* Top-right "{n}/{total}" badge for the in-feed carousel. The visible pill is
* decorative (accessible={false}) since the digits are redundant for screen
* reader users.
*
* On native, screen reader users never reach this carousel branch - the Gallery
* renders a separate per-image stack early when screenReaderEnabled is true,
* and each slide there is labelled "Image N of M". So the live region only
* needs to exist on web, where screenReaderEnabled is always false and the
* carousel is the only branch. There we announce position changes via a
* visually-hidden aria-live region.
*/
function GalleryCounter({
currentIndex,
imageCount,
}: {
currentIndex: number
imageCount: number
}) {
const {t: l} = useLingui()
return (
<>
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
{
top: a.p_xs.padding,
right: a.p_xs.padding,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
]}>
<Text
style={[a.font_bold, a.text_xs, a.leading_tight, {color: '#fff'}]}>
{currentIndex + 1}/{imageCount}
</Text>
</View>
{IS_WEB ? (
<View aria-live="polite" style={a.sr_only}>
<Text>{l`Image ${currentIndex + 1} of ${imageCount}`}</Text>
</View>
) : null}
</>
)
}
function computeDims({
height,
aspectRatio,
@@ -551,6 +483,34 @@ function GalleryImage({
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
{
top: a.p_sm.padding,
right: a.p_sm.padding,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
]}>
<Text
style={[
a.font_bold,
a.text_xs,
a.leading_tight,
{color: '#fff'},
]}>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}