// eslint-disable-next-line import/no-extraneous-dependencies import { createAnimatedGestureHandler } from '@gallery-toolkit/common'; import type { RenderImageProps } from '@gallery-toolkit/image-transformer'; import { SimpleGallery } from '@gallery-toolkit/simple-gallery'; import { useNavigation } from '@react-navigation/native'; import type { StackNavigationOptions } from '@react-navigation/stack'; import React, { useCallback, useRef, useState } from 'react'; import { ActivityIndicator, Dimensions, Image, StatusBar, StyleSheet, Text, View } from 'react-native'; import { PanGestureHandlerGestureEvent, RectButton } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, useWorkletCallback, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DetachedHeader, HeaderPropsScrapper } from '../DetachedHeader'; const dimensions = Dimensions.get('window'); function getRandomIntInclusive(min: number, max: number) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } const heights = [300, 400, 500, 540, 580, 600]; type GalleryItemType = | { type: 'image'; id: string; width: number; height: number; uri: string; } | { type: 'video'; id: string; uri: string; }; const images: GalleryItemType[] = Array.from( { length: 5 }, (_, index) => { const height = heights[getRandomIntInclusive(0, heights.length - 1)]; return { type: 'image', id: index.toString(), uri: `https://picsum.photos/id/${index + 100}/${height}/400`, width: height, height: dimensions.width, }; }, ); images.push({ type: 'image', id: '8', uri: 'https://picsum.photos/id/index/height/400', width: 200, height: 200, }); images.push({ type: 'video', id: '7', uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', }); const s = StyleSheet.create({ button: { backgroundColor: 'black', padding: 16, borderRadius: 8, }, buttonText: { color: 'white' }, loadingContainer: { justifyContent: 'center', alignItems: 'center', ...StyleSheet.absoluteFillObject, }, errorContainer: { justifyContent: 'center', alignItems: 'center', paddingTop: 80, ...StyleSheet.absoluteFillObject, }, bottomBar: { flexDirection: 'row', position: 'absolute', padding: 20, bottom: 0, left: 0, right: 0, flex: 1, justifyContent: 'space-around', alignItems: 'center', backgroundColor: 'white', }, }); function Button({ onPress, text, }: { onPress: () => void; text: string; }) { return ( <RectButton onPress={onPress} style={s.button}> <Text style={s.buttonText}>{text}</Text> </RectButton> ); } export function useToggleOpacity( prop: Animated.SharedValue<boolean>, ) { const translateY = useSharedValue(1); const styles = useAnimatedStyle(() => { if (prop.value) { return { opacity: withTiming(1), transform: [{ translateY: 0 }], }; } return { opacity: withTiming(0, undefined, () => { translateY.value = -99999; }), transform: [{ translateY: translateY.value }], }; }, []); return styles; } function ImageRender({ width, height, source, onLoad, index, }: RenderImageProps & { index: number; }) { const [isLoading, setLoadingState] = useState(true); const [isError, setErrorState] = useState(false); return ( <> {isLoading && ( <View style={[s.loadingContainer, { width, height }]}> <ActivityIndicator size="large" color="blue" /> </View> )} {isError && ( <View style={[ { width, height, }, s.errorContainer, ]} > <Text>Error loading image</Text> </View> )} <Image testID={`image-${index}`} onError={() => { setErrorState(true); }} onLoad={() => { onLoad(); setLoadingState(false); }} style={{ width, height, }} source={source} /> </> ); } export default function FullFeatured() { const nav = useNavigation(); const [index, setIndex] = useState(1); const headerShown = useSharedValue(true); const translateY = useSharedValue(0); const bottomTranslateY = useSharedValue(0); const galleryRef = useRef<SimpleGallery<GalleryItemType[]>>( null, ); const onIndexChange = useWorkletCallback((nextIndex: number) => { runOnJS(setIndex)(nextIndex); }, []); function onNext() { galleryRef.current!.goNext(); } function onBack() { galleryRef.current!.goBack(); } function setHeaderShown(value: boolean) { headerShown.value = value; nav.setParams({ headerShown: value }); StatusBar.setHidden(!value); } function toggleHeaderShown() { const nextValue = !headerShown.value; setHeaderShown(nextValue); } function hide() { setHeaderShown(false); } const opacityAnimatedStyles = useToggleOpacity(headerShown); const translateYAnimatedStyles = useAnimatedStyle(() => { return { transform: [{ translateY: bottomTranslateY.value }], }; }, []); function handleClose() { nav.goBack(); } function shouldPagerHandleGestureEvent() { 'worklet'; return translateY.value === 0; } const handler = useCallback( createAnimatedGestureHandler<PanGestureHandlerGestureEvent, {}>({ shouldHandleEvent: (evt) => { 'worklet'; return ( evt.numberOfPointers === 1 && Math.abs(evt.velocityX) < Math.abs(evt.velocityY) ); }, onActive: (evt) => { 'worklet'; translateY.value = evt.translationY; bottomTranslateY.value = evt.translationY > 0 ? evt.translationY : 0; }, onEnd: () => { 'worklet'; if (translateY.value > 80) { translateY.value = withTiming( -800, undefined, runOnJS(handleClose), ); } else { translateY.value = withTiming(0); bottomTranslateY.value = withTiming(0); } }, }), [], ); const translateStyles = useAnimatedStyle(() => { return { transform: [ { translateY: translateY.value, }, ], }; }, []); const onInteraction = useWorkletCallback(() => { runOnJS(hide)(); }, []); const onTap = useWorkletCallback(() => { runOnJS(toggleHeaderShown)(); }, []); const onDoubleTap = useWorkletCallback((isScaled: boolean) => { if (!isScaled) { runOnJS(hide); } }, []); const insets = useSafeAreaInsets(); return ( <View style={{ flex: 1, backgroundColor: 'black' }}> <CustomHeader topInset={insets.top} bottomTranslateY={bottomTranslateY} headerShown={headerShown} /> <Animated.View style={[translateStyles, StyleSheet.absoluteFill]} > <SimpleGallery ref={galleryRef} initialIndex={1} items={images} keyExtractor={(item) => item.id} gutterWidth={56} onIndexChange={onIndexChange} renderImage={(props, _, index) => { return <ImageRender index={index} {...props} />; }} renderPage={({ item, ...rest }) => { if (item.type === 'image') { return ( <SimpleGallery.ImageRenderer item={item} {...rest} /> ); } // TODO: Figure out why Video component is not working return ( <View> <Text>I can be a video</Text> </View> ); }} onInteraction={onInteraction} onTap={onTap} onDoubleTap={onDoubleTap} numToRender={2} shouldPagerHandleGestureEvent={ shouldPagerHandleGestureEvent } onPagerEnabledGesture={handler} // onPagerTranslateChange={(translateX) => { // console.log(translateX); // }} /> </Animated.View> <Animated.View style={[ s.bottomBar, opacityAnimatedStyles, translateYAnimatedStyles, ]} > <Button onPress={onBack} text="Back" /> <Text>Index: {index}</Text> <Button onPress={onNext} text="Next" /> </Animated.View> </View> ); } function CustomHeader({ bottomTranslateY, headerShown, topInset, }: { bottomTranslateY: Animated.SharedValue<number>; headerShown: Animated.SharedValue<boolean>; topInset: number; }) { const style = useAnimatedStyle(() => { return { paddingTop: topInset, zIndex: 1, transform: [ { translateY: bottomTranslateY.value * -1, }, ], }; }, []); const opacityAnimatedStyles = useToggleOpacity(headerShown); return ( <Animated.View style={[style, opacityAnimatedStyles]}> <DetachedHeader /> </Animated.View> ); } FullFeatured.options = (): StackNavigationOptions => ({ header: HeaderPropsScrapper, });