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-imageHere'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
cacheKeyprop - 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 |
preserveItemDimensions |
boolean |
false |
Whether to respect exact item dimensions when provided |
getItemDimensions |
(item: MasonryItem, index: number) => { width: number; height: number } | null |
undefined |
Function to calculate custom dimensions for items |
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;
preserveDimensions?: boolean;
[key: string]: any;
}interface MasonryRenderItemInfo {
item: MasonryItem;
index: number;
dimensions: {
width: number;
height: number;
left: number;
top: number;
};
}The library supports multiple ways to override the automatic dimension calculation:
Set preserveDimensions: true on individual items to use their exact width and height:
const dataWithExactSizes = [
{
id: '1',
width: 300,
height: 200,
preserveDimensions: true, // This item will be exactly 300x200
imageUrl: 'https://example.com/image1.jpg'
},
{
id: '2',
width: 400,
height: 300,
// No preserveDimensions flag - will be auto-calculated
imageUrl: 'https://example.com/image2.jpg'
}
];Use the preserveItemDimensions prop to respect exact dimensions for all items that have width and height:
<ExpoMasonryLayout data={data} preserveItemDimensions={true} renderItem={renderItem} />Use getItemDimensions for dynamic dimension calculation:
const getCustomDimensions = (item, index) => {
// Make every 5th item extra wide
if (index % 5 === 0) {
return { width: 300, height: 150 };
}
// Featured items get special treatment
if (item.featured) {
return { width: 250, height: 200 };
}
// Return null for auto-calculation
return null;
};
<ExpoMasonryLayout data={data} getItemDimensions={getCustomDimensions} renderItem={renderItem} />;Combine all approaches for maximum flexibility:
const mixedData = [
{
id: '1',
width: 200,
height: 300,
preserveDimensions: true // Exact size
},
{
id: '2',
featured: true // Will use getItemDimensions
},
{
id: '3',
width: 400,
height: 300 // Will be auto-calculated unless preserveItemDimensions=true
}
];
<ExpoMasonryLayout
data={mixedData}
preserveItemDimensions={false}
getItemDimensions={(item, index) => {
if (item.featured) return { width: 250, height: 200 };
return null;
}}
renderItem={renderItem}
/>;Priority Order:
getItemDimensionsfunction result (highest priority)preserveDimensions: trueon item + item'swidth/heightpreserveItemDimensions: trueprop + item'swidth/height- Auto-calculated from aspect ratio (lowest priority)
- Provide Image Dimensions: Include
widthandheightin your data items for optimal layout calculation - Memoize Render Function: Use
useCallbackfor yourrenderItemfunction - Optimize Images: Use appropriate image sizes and consider lazy loading
- Key Extractor: Provide a stable
keyExtractorfunction - Batch Size: Adjust
maxToRenderPerBatchbased 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
