A high-performance masonry layout component for React Native and Expo applications
โจ Used in production by WiSaw - a location-based photo sharing app
- ๐ High Performance: Uses VirtualizedList for optimal performance with large datasets
- ๐ฑ Responsive: Automatically adapts to screen size and orientation changes
- ๐จ Flexible: Supports custom aspect ratios and layout configurations
- ๐ Interactive: Built-in pull-to-refresh and infinite scroll support
- ๐ Smart Layout: Intelligent row-based masonry with justified alignment
- ๐ฏ TypeScript: Full TypeScript support with comprehensive types
- โก Optimized: Minimal re-renders with memoized calculations
This component is actively used in production by:
- WiSaw - A location-based photo sharing mobile app that displays thousands of user-generated photos in a beautiful masonry layout. WiSaw demonstrates the component's ability to handle large datasets with smooth scrolling and optimal performance.
The screenshot above is taken directly from the WiSaw app, showcasing real-world usage with actual user photos.
npm install expo-masonry-layout
# or
yarn add expo-masonry-layout
## ๐ Quick Start
```tsx
import React from 'react';
import { View, Image, Text } from 'react-native';
import ExpoMasonryLayout from 'expo-masonry-layout';
const MyMasonryGrid = () => {
const data = [
{ id: '1', uri: 'https://example.com/image1.jpg', width: 300, height: 400 },
{ id: '2', uri: 'https://example.com/image2.jpg', width: 400, height: 300 },
{ id: '3', uri: 'https://example.com/image3.jpg', width: 300, height: 300 },
// ... more items
];
const renderItem = ({ item, dimensions }) => (
<View style={{ width: dimensions.width, height: dimensions.height }}>
<Image
source={{ uri: item.uri }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
);
return (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
spacing={6}
keyExtractor={item => item.id}
/>
);
};
For better performance with remote images, we recommend using expo-cached-image
alongside the masonry layout:
npm install expo-cached-image
# or
yarn add expo-cached-image
Here's how to integrate it:
import React from 'react';
import { View, Dimensions } from 'react-native';
import ExpoMasonryLayout from 'expo-masonry-layout';
import { CachedImage } from 'expo-cached-image';
const CachedMasonryGrid = () => {
const data = [
{ id: '1', uri: 'https://example.com/image1.jpg', width: 300, height: 400 },
{ id: '2', uri: 'https://example.com/image2.jpg', width: 400, height: 300 },
{ id: '3', uri: 'https://example.com/image3.jpg', width: 300, height: 300 },
// ... more items
];
const renderItem = ({ item, dimensions }) => (
<View style={{ width: dimensions.width, height: dimensions.height }}>
<CachedImage
source={{ uri: item.uri }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
cacheKey={`masonry-${item.id}`} // Unique cache key
placeholderContent={
<View
style={{
flex: 1,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
}}
/>
}
/>
</View>
);
return (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
spacing={6}
keyExtractor={item => item.id}
/>
);
};
- Automatic Caching: Images are cached locally after first load
- Placeholder Support: Shows placeholder while loading
- Better Performance: Reduces network requests for repeated views
- Memory Management: Efficient image memory handling
- Progressive Loading: Smooth loading experience
- Use Unique Cache Keys: Ensure each image has a unique
cacheKey
prop - Optimize Image Sizes: Use appropriately sized images for your layout
- Implement Placeholders: Provide placeholder content for better UX
- Clear Cache When Needed: Implement cache clearing for updated content
// Example with cache management
import { CachedImage } from 'expo-cached-image';
const clearImageCache = async () => {
await CachedImage.clearCache();
};
// Clear cache for specific images
const clearSpecificCache = async imageId => {
await CachedImage.clearCache(`masonry-${imageId}`);
};
Here's a comprehensive example inspired by the WiSaw app implementation:
import React, { useState, useCallback } from 'react';
import { TouchableOpacity, Image, Text, View } from 'react-native';
import ExpoMasonryLayout, { MasonryRenderItemInfo } from 'expo-masonry-layout';
// Example data structure similar to WiSaw's photo feed
const PhotoMasonryGrid = () => {
const [photos, setPhotos] = useState(initialPhotos);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
// Photo item renderer similar to WiSaw's implementation
const renderPhotoItem = useCallback(
({ item, dimensions }: MasonryRenderItemInfo) => (
<TouchableOpacity
style={{
width: dimensions.width,
height: dimensions.height,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
}}
onPress={() => handlePhotoPress(item)}
activeOpacity={0.9}
>
<Image
source={{ uri: item.imageUrl }}
style={{
width: '100%',
height: '85%',
}}
resizeMode="cover"
loadingIndicatorSource={require('./placeholder.png')}
/>
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: 8,
}}
>
<Text
style={{
color: 'white',
fontSize: 12,
fontWeight: '600',
}}
numberOfLines={1}
>
๐ {item.location}
</Text>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: 10,
marginTop: 2,
}}
>
โค๏ธ {item.likes} โข ๐ค {item.username}
</Text>
</View>
</TouchableOpacity>
),
[]
);
const handlePhotoPress = useCallback(photo => {
// Navigate to photo detail view (like in WiSaw)
console.log('Photo pressed:', photo.id);
}, []);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
// Fetch fresh photos from your API
const freshPhotos = await fetchLatestPhotos();
setPhotos(freshPhotos);
} catch (error) {
console.error('Error refreshing photos:', error);
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
// Load more photos for infinite scroll
const morePhotos = await fetchMorePhotos(photos.length);
setPhotos(prevPhotos => [...prevPhotos, ...morePhotos]);
} catch (error) {
console.error('Error loading more photos:', error);
} finally {
setLoading(false);
}
}, [photos.length, loading]);
return (
<ExpoMasonryLayout
data={photos}
renderItem={renderPhotoItem}
spacing={8}
maxItemsPerRow={2} // WiSaw uses 2 columns for optimal photo viewing
baseHeight={200}
keyExtractor={item => item.id}
refreshing={refreshing}
onRefresh={handleRefresh}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
aspectRatioFallbacks={[0.7, 1.0, 1.3, 1.6]} // Common photo ratios
style={{ backgroundColor: '#f8f9fa' }}
contentContainerStyle={{ padding: 8 }}
showsVerticalScrollIndicator={false}
initialNumToRender={8}
maxToRenderPerBatch={10}
windowSize={15}
/>
);
};
The component now supports passing any VirtualizedList prop directly to the underlying implementation. This gives you full control over scrolling behavior, performance tuning, and platform-specific features:
import React, { useCallback } from 'react';
import ExpoMasonryLayout from 'expo-masonry-layout';
const AdvancedMasonryGrid = () => {
const handleScroll = useCallback((event) => {
console.log('Scroll position:', event.nativeEvent.contentOffset.y);
}, []);
const handleScrollBeginDrag = useCallback(() => {
console.log('User started scrolling');
}, []);
return (
<ExpoMasonryLayout
data={photos}
renderItem={renderPhotoItem}
spacing={8}
maxItemsPerRow={2}
{/* VirtualizedList props passed through */}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
showsVerticalScrollIndicator={true}
bounces={true}
scrollEnabled={true}
nestedScrollEnabled={true} // Android
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 100,
}}
{/* Performance tuning */}
initialNumToRender={10}
maxToRenderPerBatch={5}
windowSize={10}
removeClippedSubviews={true}
updateCellsBatchingPeriod={50}
{/* Infinite scroll */}
onEndReached={loadMoreData}
onEndReachedThreshold={0.2}
{/* Pull to refresh */}
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
);
};
The component extends React Native's VirtualizedListProps
and accepts all VirtualizedList properties in addition to the masonry-specific props below:
Prop | Type | Default | Description |
---|---|---|---|
data |
MasonryItem[] |
required | Array of items to display |
renderItem |
(info: MasonryRenderItemInfo) => ReactElement |
required | Function to render each item |
spacing |
number |
6 |
Space between items in pixels |
maxItemsPerRow |
number |
6 |
Maximum number of items per row |
baseHeight |
number |
100 |
Base height for layout calculations |
aspectRatioFallbacks |
number[] |
[0.56, 0.67, 0.75, 1.0, 1.33, 1.5, 1.78] |
Fallback aspect ratios |
keyExtractor |
(item: MasonryItem, index: number) => string |
(item, index) => item.id || index |
Extract unique key for each item |
All VirtualizedList props are supported and passed through to the underlying implementation, including:
- Performance:
initialNumToRender
,maxToRenderPerBatch
,windowSize
,updateCellsBatchingPeriod
,removeClippedSubviews
- Scrolling:
onScroll
,onScrollBeginDrag
,onScrollEndDrag
,onMomentumScrollBegin
,onMomentumScrollEnd
,scrollEventThrottle
- Interaction:
onEndReached
,onEndReachedThreshold
,refreshing
,onRefresh
,scrollEnabled
,bounces
- Styling:
style
,contentContainerStyle
,showsVerticalScrollIndicator
- Platform:
nestedScrollEnabled
(Android),scrollIndicatorInsets
(iOS)
interface MasonryItem {
id: string;
width?: number;
height?: number;
[key: string]: any;
}
interface MasonryRenderItemInfo {
item: MasonryItem;
index: number;
dimensions: {
width: number;
height: number;
left: number;
top: number;
};
}
- Provide Image Dimensions: Include
width
andheight
in your data items for optimal layout calculation - Memoize Render Function: Use
useCallback
for yourrenderItem
function - Optimize Images: Use appropriate image sizes and consider lazy loading
- Key Extractor: Provide a stable
keyExtractor
function - Batch Size: Adjust
maxToRenderPerBatch
based on your item complexity
The component uses a sophisticated row-based masonry algorithm:
- Row Filling: Items are added to rows based on available width
- Height Normalization: All items in a row are scaled to the same height
- Width Justification: The entire row is scaled to fill the available width
- Vertical Positioning: Items are vertically centered within their row
This approach ensures:
- Consistent row heights for smooth scrolling
- Optimal use of screen space
- Predictable layout behavior
- Excellent performance with virtualization
MIT
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
- ๐ Issues: GitHub Issues
- ๐ฌ Discussions: GitHub Discussions
- ๐ฑ See it in action: Check out WiSaw for a real-world implementation
- ๐ง Email: Contact Echowaves
Made with โค๏ธ by Echowaves Corp.
Powering beautiful photo experiences in WiSaw and beyond