Gradient carousel for transparent header
To be used as top most section on page
import React, { useEffect, useState, useRef, useMemo } from "react";
import {
Image,
Flexbox,
Box,
Text,
Actionable,
usePageInsets,
TransparentHeader,
} from "@evlop/native-components";
import { Animated, FlatList, Dimensions, StyleSheet } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { BlurView } from '@react-native-community/blur';
import { useSharedState, useScope, useParams, useTheme } from '@evlop/commons'
import { memoize } from 'lodash';
const { width: windowWidth } = Dimensions.get('window');
const getAspectRatio = memoize((url: string): number => {
try {
const _url = new URL(url);
const searchParams = _url.searchParams;
return searchParams.has('r') ? searchParams.get('r') : 0.75;
} catch (e) {
return 0.75;
}
});
const AppBlock: NativeAppBlock = function ({data}) {
const theme = useTheme();
const pageInsets = usePageInsets();
const animatedPageScrollPosition = useScope(s=>s.animatedPageScrollPosition);
const translateY = animatedPageScrollPosition?.interpolate({
inputRange: [-1, 0],
outputRange: [-1, 0],
extrapolateLeft: 'extend',
extrapolateRight: 'clamp',
})
const items = data.items || [];
const backgroundType = data.backgroundType || 'color-gradient';
// layout / snapping configuration: each item occupies 75% of screen width
const itemWidth = useMemo(() => Math.round(windowWidth * 0.75), []);
const sidePadding = useMemo(() => Math.round((windowWidth - itemWidth) / 2), [itemWidth]);
const itemSpacing = 12;
const listRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
const onMomentumScrollEnd = (e) => {
const offsetX = e.nativeEvent.contentOffset.x;
// offsets align with itemWidth + spacing multiples
const index = Math.round(offsetX / (itemWidth + itemSpacing));
const clamped = Math.max(0, Math.min(index, items.length - 1));
if (clamped !== activeIndex) {
setActiveIndex(clamped);
}
}
const _activeColor = items[activeIndex]?.color
const activeColor = theme.lightModeColors?.[_activeColor] || theme.colors?.[_activeColor] || _activeColor;
const activeImage = items[activeIndex]?.image
const [overlayIndex, setOverlayIndex] = useState(0);
const _overlayColor = items[overlayIndex]?.color
const overlayColor = theme.lightModeColors?.[_overlayColor] || theme.colors?.[_overlayColor] || _overlayColor;
const overlayImage = items[overlayIndex]?.image
const gradientEndColor = theme.colors['body-background'];
const renderItem = ({ item, index }) => (
<Actionable action={item.action}>
<Box
width={itemWidth}
marginRight={itemSpacing}
borderRadius={10}
overflow="visible"
backgroundColor={theme.lightModeColors?.[item.color] || item.color}
style={styles.card}
flexGrow={1}
>
<Flexbox padding="2xs" flexDirection="column" gap="md" borderRadius={10} flexGrow={1} justifyContent="space-between">
<Flexbox gap="7xs" flexDirection="column">
<Text fontWeight="Semibold" color="white" fontSize="2xl">{item.title}</Text>
<Text fontWeight="Thin" color="white/800">{item.description}</Text>
</Flexbox>
<Image
src={item.image}
resizeMode="cover"
aspectRatio={getAspectRatio(item.image)}
borderRadius={10}
/>
</Flexbox>
</Box>
</Actionable>
);
const overlayOpacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
// start with overlay fully visible, then fade out to reveal new active color underneath
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(()=>{
setOverlayIndex(activeIndex);
})
return () => {
overlayOpacity.setValue(1)
};
}, [activeIndex]);
return (
<Flexbox width="100%">
<TransparentHeader active />
<Box position="absolute" top={-pageInsets.top} left={0} right={0} bottom={0} >
<Animated.View style={[styles.backgroundContainer, {transform: [{translateY: translateY}]}]}>
{backgroundType === 'color-gradient' && <LinearGradient
colors={[activeColor, gradientEndColor]}
style={styles.gradient}
/>}
{backgroundType === 'image' && <Image resizeMode="cover" style={styles.gradient} src={activeImage} />}
<Animated.View style={[styles.gradient, { opacity: overlayOpacity}]}>
{backgroundType === 'color-gradient' && <LinearGradient
colors={[overlayColor || activeColor, gradientEndColor]}
style={styles.gradient}
/>}
{backgroundType === 'image' && <Image resizeMode="cover" style={styles.gradient} src={overlayImage || activeImage} />}
</Animated.View>
{backgroundType === 'image' && <>
<BlurView style={styles.gradient} />
<LinearGradient style={styles.fill} colors={["#00000000", gradientEndColor]} />
</>}
</Animated.View>
</Box>
<FlatList
ref={listRef}
data={items}
horizontal
snapToInterval={itemWidth + itemSpacing}
decelerationRate={'fast'}
snapToAlignment={'start'}
showsHorizontalScrollIndicator={false}
keyExtractor={(it, idx) => it?.id ?? idx}
renderItem={renderItem}
onMomentumScrollEnd={onMomentumScrollEnd}
contentContainerStyle={[styles.listContent, { paddingHorizontal: sidePadding }]}
overScrollMode={'never'}
/>
</Flexbox>
);
};
const styles = StyleSheet.create({
backgroundContainer: {
width: '100%',
height: '100%',
position: "relative",
overflow: 'hidden',
},
gradient: {
position: 'absolute',
width: '100%',
height: '100%',
},
fill: {
width: '100%',
height: '100%',
},
listContent: {
paddingVertical: 20,
},
card: {
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 4,
}
});
export default AppBlock;
{
"backgroundType": "color-gradient",
"items": [
{
"id": 1,
"image": "https://uploads-cdn.evlop.com/66cefe363c5537aa5439f727/c89f5729-d4b1-4747-b196-d55f42b8b868.png?v=1725520306000&r=1",
"color": "pink-900",
"title": "Amazing shoes",
"description": "get it 50% for (1 per user)"
},
{
"id": 2,
"image": "https://uploads-cdn.evlop.com/66cefe363c5537aa5439f727/a1ce0c81-6be1-4490-bf4d-b9ee4c4d8f7f.png?v=1725520306000&r=1",
"color": "success-900",
"title": "Laptops",
"description": "Developers series"
}
]
}