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;