I recently worked on an app that required content stored online but available offline. This content (loaded from a JSON file) included links to images, so I needed an easy way to show the online version if available or have a fallback to a previously downloaded image.
I decided the best option was to download the image and displaying the local version regardless. This let me put all the image-checking stuff outside the render.
First up was to set up the basic structure. I put this in an external component to make it easier to reference. The states were to allow for possible issues: loading
while the image download is attempted, failed
for if the URL is no good, and then imageuri
, and width
and height
for once the image is loaded.
import React, { Component } from 'react' import { View, Image, ActivityIndicator, Dimensions, Platform } from 'react-native' import { FileSystem } from 'expo'class CachedImage extends Component { state = { loading: true, failed: false, imguri: '', width: 300, height: 300 }render() { { if (this.state.loading) { // while the image is being checked and downloading return(); } } { if (this.state.failed) { // if the image url has an issue return(); } } // otherwise display the image return(); } } export default CachedImage;
Next, I needed to set up the code to download the image in componentDidMount()
. There were a few possible image extensions, but I also had to deal with the JSON file sending non-image references, so I checked the extensions first, and if it wasn’t one of the expected ones, I set a state of failure to true.
async componentDidMount() {
const extension = this.props.source.slice((this.props.source.lastIndexOf(".") - 1 >>> 0) + 2)
if ((extension.toLowerCase() !== 'jpg') && (extension.toLowerCase() !== 'png') && (extension.toLowerCase() !== 'gif')) {
this.setState({ loading: false, failed: true })
}
Following this was the code to download the file, save it to the cacheDirectory and then load it with a function. this.props.source
and this.props.title
were fed into the CachedImage
function. I pulled the title from the external image filename to track it properly, as the JSON data was updated with new images and the like.
await FileSystem.downloadAsync(
this.props.source,
`${FileSystem.cacheDirectory + this.props.title}.${ extension }`
)
.then(({ uri }) => {
// load the local image
this.loadLocal(uri);
})
.catch(e => {
console.log('Image loading error:', e);
// if the online download fails, load the local version
this.loadLocal(`${FileSystem.cacheDirectory + this.props.title}.${ extension }`);
});
Next up is getting the image data and updating the state. I wanted to set the image width to the device width and then have the relative height since I couldn’t be sure of the dimensions these images were coming in with, which meant waiting on the Image.getSize
function.
loadLocal(uri) { Image.getSize(uri, (width, height) => { // once we have the original image dimensions, set the state to the relative ones this.setState({ imguri: uri, loading: false, width: Dimensions.get('window').width, height: (height/width)*Dimensions.get('window').width }); }, (e) => { // As always include an error fallback console.log('getSize error:', e); this.setState({ loading: false, failed: true }) }) }
Finally, I needed to update the render functions to reflect the states. I included a style
prop to override the sizes, set the resizeMode
, etc. if required.
render() {
const { style } = this.props
{
if (this.state.loading) {
// while the image is being checked and downloading
return(
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator
color='#42C2F3'
size='large'
/>
</View>
);
}
}
{
if (this.state.failed) {
// if the image url has an issue
return( <View></View> );
}
}
// otherwise display the image
return(
<View style={{ width: this.state.width, height: this.state.height }}>
<Image
style={[{ width: this.state.width, height: this.state.height }, style ]}
source={{ uri: this.state.imguri }}
/>
</View>
);
}
Final Gotcha
Android attempts to show the local image before it’s finished downloading. To counter this, I ended up just using the online version on that device. Loading it in later while offline is acceptable. It just triggers that then
a little too soon on the initial load.
.then(({ uri }) => {
this.loadLocal(Platform.OS === 'ios'? uri : this.props.source);
})
This was definitely cobbling together the quickest solution, and I’m sure it could be cleaner (or sorted out properly) with time, but sometimes deadlines mean if it works, it’s okay.
Enough talk. Please show me the code!
Here’s the final code:
import React, { Component } from 'react' import { View, Image, ActivityIndicator, Dimensions, Platform } from 'react-native' import { FileSystem } from 'expo'class CachedImage extends Component { state = { loading: true, failed: false, imguri: '', width: 300, height: 300 }async componentDidMount() { const extension = this.props.source.slice((this.props.source.lastIndexOf(".") - 1 >>> 0) + 2) if ((extension.toLowerCase() !== 'jpg') && (extension.toLowerCase() !== 'png') && (extension.toLowerCase() !== 'gif')) { this.setState({ loading: false, failed: true }) } await FileSystem.downloadAsync( this.props.source, `${FileSystem.cacheDirectory + this.props.title}.${ extension }` ) .then(({ uri }) => { this.loadLocal(Platform.OS === 'ios'? uri : this.props.source); }) .catch(e => { console.log('Image loading error:', e); // if the online download fails, load the local version this.loadLocal(`${FileSystem.cacheDirectory + this.props.title}.${ extension }`); }); } loadLocal(uri) { Image.getSize(uri, (width, height) => { // once we have the original image dimensions, set the state to the relative ones this.setState({ imguri: uri, loading: false, width: Dimensions.get('window').width, height: (height/width)*Dimensions.get('window').width }); }, (e) => { // As always include an error fallback console.log('getSize error:', e); this.setState({ loading: false, failed: true }) }) } render() { const { style } = this.props { if (this.state.loading) { // while the image is being checked and downloading return( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <ActivityIndicator color='#42C2F3' size='large' /> </View> ); } } { if (this.state.failed) { // if the image url has an issue return( <View></View> ); } } // otherwise display the image return( <View style={{ width: this.state.width, height: this.state.height }}> <Image style={[{ width: this.state.width, height: this.state.height }, style ]} source={{ uri: this.state.imguri }} /> </View> ); } } export default CachedImage;
And to load the image in:
<CachedImage
source={ 'online image url' }
title={ 'title for the image' }
style={ whatever extra styling you want }
/>
Thanks for reading. I hope you find it helpful. Please drop me a line if you have any advice or suggestions for how I could improve this code.