Для сравнения мы выбрали самый нагруженный экран приложения — ленту ресторанов с фильтрами, анимированными карточками и бесконечной прокруткой. Прототип включал:
- Список из 500+ карточек ресторанов с ленивой подгрузкой
- Анимации при скролле (параллакс-эффект на обложках)
- Фильтрация с анимированным переключением
- Навигация на экран детальной информации с shared element transition
Для React Native мы использовали New Architecture (Fabric + TurboModules), FlashList вместо FlatList и Reanimated 3 для анимаций:
// RestaurantFeed.tsx
import { FlashList } from '@shopify/flash-list';
import Animated, {
useAnimatedScrollHandler,
useSharedValue,
useAnimatedStyle,
interpolate,
} from 'react-native-reanimated';
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
export function RestaurantFeed({ restaurants }) {
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const renderItem = ({ item, index }) => (
<RestaurantCard
restaurant={item}
index={index}
scrollY={scrollY}
/>
);
return (
<AnimatedFlashList
data={restaurants}
renderItem={renderItem}
estimatedItemSize={280}
onScroll={onScroll}
scrollEventThrottle={16}
drawDistance={500}
/>
);
}
// RestaurantCard.tsx — параллакс-анимация
function RestaurantCard({ restaurant, index, scrollY }) {
const cardStyle = useAnimatedStyle(() => {
const inputRange = [
(index - 1) * 280,
index * 280,
(index + 1) * 280,
];
const scale = interpolate(
scrollY.value,
inputRange,
[0.95, 1, 0.95],
'clamp'
);
const opacity = interpolate(
scrollY.value,
inputRange,
[0.7, 1, 0.7],
'clamp'
);
return { transform: [{ scale }], opacity };
});
return (
<Animated.View style={[styles.card, cardStyle]}>
<FastImage source={{ uri: restaurant.coverUrl }} />
<Text>{restaurant.name}</Text>
</Animated.View>
);
}
Для Flutter использовали Riverpod для состояния и стандартный CustomScrollView с SliverList:
// restaurant_feed.dart
class RestaurantFeed extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final restaurants = ref.watch(restaurantListProvider);
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
title: Text('Рестораны'),
),
),
restaurants.when(
data: (list) => SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => RestaurantCard(
restaurant: list[index],
index: index,
),
childCount: list.length,
),
),
loading: () => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => SliverFillRemaining(
child: Center(child: Text('Ошибка: $e')),
),
),
],
);
}
}
class RestaurantCard extends StatelessWidget {
final Restaurant restaurant;
final int index;
const RestaurantCard({
required this.restaurant,
required this.index,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8,
),
child: Hero(
tag: 'restaurant_${restaurant.id}',
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
CachedNetworkImage(
imageUrl: restaurant.coverUrl,
height: 180,
fit: BoxFit.cover,
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(restaurant.name,
style: Theme.of(context).textTheme.titleMedium),
),
],
),
),
),
);
}
}
Оставить комментарий