Размытие, которое плавно усиливается к краю блока. Оно собрано из нескольких слоёв BlurView разной силы, каждый под своей градиентной маской, поэтому переход получается мягким, без видимой границы. Прикрепите его к любой стороне прокручиваемой области — контент будет уходить под него при прокрутке.
Внутрь можно положить свои элементы, например панель кнопок, и добавить затемнение к краю через tint. Размытие не перехватывает касания, так что список под ним продолжает листаться. На Android используется метод размытия dimezisBlurView, в вебе — backdrop-filter. Работает на iOS, Android и в вебе.
Поставьте зависимости:
npm install expo-blur expo-linear-gradient @react-native-masked-view/masked-viewpnpm add expo-blur expo-linear-gradient @react-native-masked-view/masked-viewyarn add expo-blur expo-linear-gradient @react-native-masked-view/masked-viewbun add expo-blur expo-linear-gradient @react-native-masked-view/masked-viewСкопируйте эти файлы в src/shared/components/molecules/progressive-blur/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import React, { useMemo, useState } from "react";
import { Image, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent } from "react-native";
import { ProgressiveBlur } from "@/shared/components/molecules/progressive-blur";
import { PHOTO_SOURCES, type PhotoName } from "~/assets/photos";
import { Showcase, useCustomize } from "~/showcase";
const PHOTOS: PhotoName[] = [
"3d-carousel-architecture",
"3d-carousel-bridge",
"3d-carousel-building",
"3d-carousel-city",
"3d-carousel-cityscape",
"3d-carousel-downtown",
"3d-carousel-night",
"3d-carousel-sky",
"3d-carousel-sunset",
"3d-carousel-winter",
"3d-carousel-sunrise",
];
const ORDER = [1, 6, 2, 8, 3, 7, 4, 9, 5, 10, 11, 1, 6, 2];
const COLUMNS = 3;
const GAP = 2;
const TABS = ["Годы", "Месяцы", "Все"];
const CUSTOMIZE = [
{ key: "bottomBlur", label: "Размытие снизу", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 20 },
{ key: "bottomSize", label: "Высота зоны снизу", type: "slider", min: 40, max: 260, step: 4, unit: "px", default: 128 },
{ key: "showTop", label: "Размытие сверху", type: "toggle", default: true },
{ key: "topBlur", label: "Сила размытия сверху", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 18 },
{ key: "topSize", label: "Высота зоны сверху", type: "slider", min: 40, max: 260, step: 4, unit: "px", default: 96 },
{ key: "tinted", label: "Затемнение к краю", type: "toggle", default: false },
{ key: "tint", label: "Цвет затемнения", type: "color", default: "#000000" },
{ key: "height", label: "Высота ленты", type: "slider", min: 360, max: 800, step: 10, unit: "px", default: 600 },
] as const;
interface ICell {
readonly row: number;
readonly col: number;
readonly span: number;
}
const placeCells = (count: number): { cells: ICell[]; rows: number } => {
const taken = new Set<string>();
const cells: ICell[] = [];
let row = 0;
let col = 0;
let rows = 0;
const free = (r: number, c: number, span: number) => {
if (c + span > COLUMNS) return false;
for (let dr = 0; dr < span; dr++) for (let dc = 0; dc < span; dc++) if (taken.has(`${r + dr}:${c + dc}`)) return false;
return true;
};
for (let i = 0; i < count; i++) {
const span = i % 7 === 0 ? 2 : 1;
while (!free(row, col, span)) {
col += 1;
if (col >= COLUMNS) {
col = 0;
row += 1;
}
}
for (let dr = 0; dr < span; dr++) for (let dc = 0; dc < span; dc++) taken.add(`${row + dr}:${col + dc}`);
cells.push({ row, col, span });
rows = Math.max(rows, row + span);
col += span;
if (col >= COLUMNS) {
col = 0;
row += 1;
}
}
return { cells, rows };
};
const toRgba = (hex: string, alpha: number) => {
const raw = hex.replace("#", "");
const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw.slice(0, 6);
const value = parseInt(full, 16);
if (Number.isNaN(value)) return `rgba(0,0,0,${alpha})`;
return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
};
export default function ProgressiveBlurScreen() {
const custom = useCustomize(CUSTOMIZE);
const [width, setWidth] = useState(0);
const tint = custom.tinted ? toRgba(custom.tint, 0.6) : undefined;
const layout = useMemo(() => placeCells(ORDER.length), []);
const unit = width > 0 ? (width - GAP * (COLUMNS - 1)) / COLUMNS : 0;
return (
<Showcase>
<View style={styles.container}>
<View style={styles.frame}>
<View
onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
style={[styles.viewport, { height: custom.height }]}
>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={{ height: layout.rows * unit + (layout.rows - 1) * GAP }}>
{unit > 0
? ORDER.map((n, index) => {
const cell = layout.cells[index]!;
const side = unit * cell.span + GAP * (cell.span - 1);
return (
<Image
key={index}
source={{ uri: PHOTO_SOURCES[PHOTOS[n - 1]!] }}
resizeMode="cover"
style={{
position: "absolute",
left: cell.col * (unit + GAP),
top: cell.row * (unit + GAP),
width: side,
height: side,
}}
/>
);
})
: null}
</View>
</ScrollView>
{custom.showTop ? (
<ProgressiveBlur side="top" size={custom.topSize} blur={custom.topBlur} tint={tint} />
) : null}
<ProgressiveBlur side="bottom" size={custom.bottomSize} blur={custom.bottomBlur} tint={tint}>
<View pointerEvents="box-none" style={styles.tabsWrap}>
<View style={styles.tabs}>
{TABS.map((tab, index) => (
<View key={tab} style={[styles.tab, index === 0 ? styles.tabActive : null]}>
<Text style={[styles.tabText, index === 0 ? styles.tabTextActive : null]}>{tab}</Text>
</View>
))}
</View>
</View>
</ProgressiveBlur>
</View>
</View>
</View>
</Showcase>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0a0a0a",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 32,
},
frame: {
width: "100%",
maxWidth: 384,
borderRadius: 32,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
shadowColor: "#000",
shadowOpacity: 0.35,
shadowRadius: 40,
shadowOffset: { width: 0, height: 24 },
},
viewport: {
width: "100%",
borderRadius: 31,
overflow: "hidden",
backgroundColor: "#09090b",
},
tabsWrap: {
flex: 1,
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: 16,
},
tabs: {
flexDirection: "row",
alignItems: "center",
gap: 4,
borderRadius: 999,
padding: 4,
backgroundColor: "rgba(24,24,27,0.7)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 15,
shadowOffset: { width: 0, height: 8 },
},
tab: {
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 6,
},
tabActive: {
backgroundColor: "#ffffff",
},
tabText: {
fontSize: 13,
lineHeight: 18,
fontWeight: "500",
color: "#e4e4e7",
},
tabTextActive: {
color: "#18181b",
},
});Положите компонент поверх ScrollView в общем контейнере — он сам встанет к выбранному краю.
side?Тип ProgressiveBlurSide
size?Тип number | `${number}%`
blur?Тип number
tint?Тип string
blurTint?Тип "dark" | "light" | "default"
style?Тип StyleProp<ViewStyle>
children?Тип ReactNode