expo-masonry-layout
A high-performance masonry layout component for React Native and Expo applications
⨠Used in production by WiSaw - a location-based photo sharing app
⨠Features
- š 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
š Real-world Usage
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.
š Installation
npm install expo-masonry-layout
yarn add expo-masonry-layout
```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}
/>
);
};
š¼ļø Using with Expo Cached Image
For better performance with remote images, we recommend using expo-cached-image alongside the masonry layout:
npm install expo-cached-image
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 }
];
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}
/>
);
};
Benefits of Using Expo Cached Image:
- 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
Performance Tips with Cached Images:
- 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
import { CachedImage } from 'expo-cached-image';
const clearImageCache = async () => {
await CachedImage.clearCache();
};
const clearSpecificCache = async (imageId) => {
await CachedImage.clearCache(`masonry-${imageId}`);
};
š§ Advanced Usage
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';
const PhotoMasonryGrid = () => {
const [photos, setPhotos] = useState(initialPhotos);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
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) => {
console.log('Photo pressed:', photo.id);
}, []);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
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 {
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}
/>
);
};
š§ VirtualizedList Pass-Through
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}
/>
);
};
š API Reference
Props
The component extends React Native's VirtualizedListProps and accepts all VirtualizedList properties in addition to the masonry-specific props below:
Masonry-Specific Props
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 |
VirtualizedList Props
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)
š· Types
MasonryItem
interface MasonryItem {
id: string;
width?: number;
height?: number;
preserveDimensions?: boolean;
[key: string]: any;
}
MasonryRenderItemInfo
interface MasonryRenderItemInfo {
item: MasonryItem;
index: number;
dimensions: {
width: number;
height: number;
left: number;
top: number;
};
}
ļæ½ Custom Dimensions
The library supports multiple ways to override the automatic dimension calculation:
1. Per-Item Dimension Preservation
Set preserveDimensions: true on individual items to use their exact width and height:
const dataWithExactSizes = [
{
id: '1',
width: 300,
height: 200,
preserveDimensions: true,
imageUrl: 'https://example.com/image1.jpg'
},
{
id: '2',
width: 400,
height: 300,
imageUrl: 'https://example.com/image2.jpg'
}
];
2. Global Dimension Preservation
Use the preserveItemDimensions prop to respect exact dimensions for all items that have width and height:
<ExpoMasonryLayout data={data} preserveItemDimensions={true} renderItem={renderItem} />
3. Custom Dimension Function
Use getItemDimensions for dynamic dimension calculation:
const getCustomDimensions = (item, index) => {
if (index % 5 === 0) {
return { width: 300, height: 150 };
}
if (item.featured) {
return { width: 250, height: 200 };
}
return null;
};
<ExpoMasonryLayout data={data} getItemDimensions={getCustomDimensions} renderItem={renderItem} />;
4. Mixed Layout Strategy
Combine all approaches for maximum flexibility:
const mixedData = [
{
id: '1',
width: 200,
height: 300,
preserveDimensions: true
},
{
id: '2',
featured: true
},
{
id: '3',
width: 400,
height: 300
}
];
<ExpoMasonryLayout
data={mixedData}
preserveItemDimensions={false}
getItemDimensions={(item, index) => {
if (item.featured) return { width: 250, height: 200 };
return null;
}}
renderItem={renderItem}
/>;
Priority Order:
getItemDimensions function result (highest priority)
preserveDimensions: true on item + item's width/height
preserveItemDimensions: true prop + item's width/height
- Auto-calculated from aspect ratio (lowest priority)
ļæ½šÆ Performance Tips
- Provide Image Dimensions: Include
width and height in your data items for optimal layout calculation
- Memoize Render Function: Use
useCallback for your renderItem 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
š§® Layout Algorithm
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
š License
MIT
š¤ Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
š Support
Made with ā¤ļø by Echowaves Corp.
Powering beautiful photo experiences in WiSaw and beyond
