React native dealing with images loading, viewing, zooming, and caching
Nader Alfakesh
Posted on July 18, 2021
Introduction
I started using react native at my work 9 months ago and it is amazing.
We are launching a new feature that involves users uploading pictures, then we show them in different ways according to the context.
I want to share my experience with dealing with images in react native.
Objective
- I need a reusable base image component that takes care of the following:
- making sure that the image takes all the available space unless I pass fixed width height.
- Image loading state.
- Image caching for better speed third-party lifelong;
- I need to make some reusable components that consume the image component I can use directly in my screen with my data;
Component list
- Avatar
- Card with image
- Input field for image upload.
- Image with caption
- Full screen view with and zoom feature
This is a demo screen uses those components
Before writing this article I coded the demo with typescript and storybook then uploaded it to Github so you can check the code
Visit the Github repo
Image base component:
This is a very basic component that has a touch opacity container to contain the image and give us an onPress event. I replaced the react-native image component with the fast image from react-native-fast-image because it is providing very good caching which gives a better user experience.
import React, { useState } from "react"
import { ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"
import FastImage from "react-native-fast-image"
const Image = ({ containerStyle, url, onPress, onLoad, style, loaderSize, ...restProps }) => {
const [loaded, setLoaded] = useState(false)
const handleLoading = (event) => {
setLoaded(true)
onLoad && onLoad(event)
}
return (
<TouchableOpacity style={[styles.base, containerStyle]} onPress={onPress} disabled={!onPress}>
<FastImage
style={[styles.base, style]}
onLoad={handleLoading}
source={{ uri: url }}
{...restProps}
/>
{!loaded && (
<ActivityIndicator color={LOADER_COLOR} style={styles.loader} size={loaderSize} />
)}
</TouchableOpacity>
)
}
export default Image
const BG_COLOR = "rgba(240, 242, 245, 1)"
const LOADER_COLOR = "rgba(55, 107, 251, 1)"
const styles = StyleSheet.create({
base: {
height: "100%",
width: "100%",
},
loader: {
...StyleSheet.absoluteFillObject,
backgroundColor: BG_COLOR,
},
})
Notice that I am getting the loading state from the onLoad event and still passing the event if I need to use it in different scenario.
AVATAR
When I make an avatar component I would like to have multiple sizes and shapes.
import React from "react"
import { StyleSheet } from "react-native"
import Image from "../Image"
const LARGE_SIZE = 90
const MEDIUM_SIZE = 65
const SMALL_SIZE = 40
const Avatar = ({
style,
url,
resizeMode = "cover",
size = "medium",
shape = "square",
onPress,
}) => {
return (
<Image
containerStyle={[sizeStyle[size], shapeStyle(shape, size), style]}
url={url}
resizeMode={resizeMode}
onPress={onPress}
/>
)
}
export default Avatar
const sizeStyle = StyleSheet.create({
large: {
height: LARGE_SIZE,
width: LARGE_SIZE,
},
medium: {
height: MEDIUM_SIZE,
width: MEDIUM_SIZE,
},
small: {
height: SMALL_SIZE,
width: SMALL_SIZE,
},
})
const shapeStyle = (shape, size) => {
switch (shape) {
case "circle":
return { borderRadius: 0.5 * sizeStyle[size].height, overflow: "hidden" }
case "round":
return { borderRadius: 0.25 * sizeStyle[size].height, overflow: "hidden" }
default:
return { borderRadius: 0 }
}
}
Nothing fancy here just notice that for getting a full circle you need the width and height to be equal and then you set the border radius to half of the height or width.
Card with image
Regardless of the layout of the end of the cart in most cases it has been a title and description
import React from "react"
import { StyleSheet, View, Text } from "react-native"
import Image from "../Image"
const ImageCard = ({ style, url, title, description }) => {
return (
<View style={[styles.base, style]}>
<Image containerStyle={styles.image} url={url} resizeMode="cover" />
<View style={styles.textContainer}>
<Text style={styles.title} numberOfLines={1}>
{title.toUpperCase()}
</Text>
<Text style={styles.description}>{description}</Text>
</View>
</View>
)
}
export default ImageCard
const CARD_BG_COLOR = "rgba(240, 242, 245, 1)"
const TITLE_COLOR = "rgba(22, 42, 76, 0.9)"
const DESCRIPTION_COLOR = "rgba(22, 42, 76, 0.7)"
const styles = StyleSheet.create({
base: {
backgroundColor: CARD_BG_COLOR,
borderRadius: 20,
flexDirection: "row",
height: 200,
overflow: "hidden",
width: "100%",
},
description: { color: DESCRIPTION_COLOR, fontSize: 14, lineHeight: 20 },
image: { height: "100%", width: "35%" },
textContainer: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 20,
},
title: { color: TITLE_COLOR, fontSize: 16, lineHeight: 24 },
})
Input field for image upload:
I want to have an upload icon when the image is not selected yet and when the image is selected I want to show a thumbnail of that image and I want an integrated text field so I can give that image a name.
import React, { Fragment, useState } from "react"
import { StyleSheet, TouchableOpacity, TextInput, Image as RNIImage } from "react-native"
import Image from "../Image"
const uploadIcon = require("./Upload.png")
const { uri: uploadIconUrl } = RNIImage.resolveAssetSource(uploadIcon)
const InputField = ({ url, onPress }) => {
const [name, setName] = useState("")
const [focus, setFocus] = useState(false)
return (
<Fragment>
<TouchableOpacity activeOpacity={0.7} style={styles.base} onPress={onPress}>
{url ? (
<Image url={url} resizeMode="cover" />
) : (
// Don't use this, instead use an svg icon please.
<Image containerStyle={styles.uploadIcon} url={uploadIconUrl} resizeMode="contain" />
)}
</TouchableOpacity>
<TextInput
style={[styles.input, focus && styles.focused]}
placeholder={"File name..."}
clearButtonMode="while-editing"
value={name}
onChangeText={setName}
autoCorrect={false}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</Fragment>
)
}
export default InputField
const BG_COLOR = "rgba(240, 242, 245, 1)"
const BORDER_COLOR = "rgba(22, 42, 76, 0.7)"
const FOCUSED_COLOR = "rgba(55, 107, 251, 1)"
const ICON_SIZE = 32
const styles = StyleSheet.create({
base: {
alignItems: "center",
backgroundColor: BG_COLOR,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
height: 120,
justifyContent: "center",
overflow: "hidden",
width: "100%",
},
focused: { borderBottomColor: FOCUSED_COLOR, borderBottomWidth: 3 },
input: {
backgroundColor: BG_COLOR,
borderBottomColor: BORDER_COLOR,
borderBottomWidth: 2,
height: 32,
paddingHorizontal: 5,
width: "100%",
},
uploadIcon: { height: ICON_SIZE, width: ICON_SIZE },
})
Image with caption
We use this component for listing purposes so I want to list all the images with an overlay caption that can be a hashtag.
import React from "react"
import { StyleSheet, View, Text } from "react-native"
import Image from "../Image"
const ImageWithCaption = ({ style, url, caption, onPress }) => {
return (
<View style={[styles.base, style]}>
<Image url={url} resizeMode="cover" onPress={onPress} />
<View style={styles.caption}>
<Text style={styles.captionText} numberOfLines={1} ellipsizeMode="clip">
{"#" + caption.split(" ")[0].toUpperCase()}
</Text>
</View>
</View>
)
}
export default ImageWithCaption
const BORDER_COLOR = "rgba(46, 56, 47, 0.2)"
const CAPTION_BG_COLOR = "rgba(255, 255, 255, 0.6)"
const CAPTION_TEXT_COLOR = "rgba(46, 56, 47, 0.8)"
const styles = StyleSheet.create({
base: {
borderColor: BORDER_COLOR,
borderRadius: 3,
borderWidth: StyleSheet.hairlineWidth,
height: 144,
overflow: "hidden",
width: 126,
},
caption: {
backgroundColor: CAPTION_BG_COLOR,
borderBottomRightRadius: 3,
borderTopRightRadius: 3,
bottom: 15,
left: 0,
paddingHorizontal: 12,
paddingVertical: 4,
position: "absolute",
},
captionText: {
color: CAPTION_TEXT_COLOR,
fontSize: 10,
lineHeight: 12,
},
})
Please just keep in mind to use SVG icons instead of the image I am using I was feeling lazy to set up icons support so I went with the easy path.
If you need to get the URI, width, or height of an image shipped with code locally (asset) you can use this Image.resolveAssetSource method.
Full screen view with and zoom feature
This is the most interesting and exciting component to work with even though I am using a third-party library to get the gesture of the pan zoom it's still very fun to have the image covering the whole screen and you can pinch-zooming in and out with your two fingers
import React, { useState } from "react"
import { Dimensions, Modal, StyleSheet, View, Text, StatusBar } from "react-native"
import ImageZoom from "react-native-image-pan-zoom"
import Image from "../Image"
const ImageViewer = ({ url, visible, title, onClose }) => {
const [imageSize, setImageSize] = useState({ width: 0, height: 0 })
const screenWidth = Dimensions.get("window").width
const screenHeight = Dimensions.get("window").height
const calculateImageSize = ({ nativeEvent }) => {
let width = nativeEvent.width
let height = nativeEvent.height
// If image width is bigger than screen => zoom ratio will be image width
if (width > screenWidth) {
const widthPixel = screenWidth / width
width *= widthPixel
height *= widthPixel
}
// If image height is still bigger than screen => zoom ratio will be image height
if (height > screenHeight) {
const HeightPixel = screenHeight / height
width *= HeightPixel
height *= HeightPixel
}
setImageSize({ height, width })
}
return (
<Modal visible={visible} onRequestClose={onClose} statusBarTranslucent animationType="slide">
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<Text style={styles.BackText} onPress={onClose}>
{"< Back"}
</Text>
<Text numberOfLines={1} ellipsizeMode="middle" style={styles.headerText}>
{title}
</Text>
</View>
<ImageZoom
style={styles.container}
cropWidth={screenWidth}
cropHeight={screenHeight}
imageWidth={imageSize.width}
imageHeight={imageSize.height}
maxOverflow={0}
>
<Image url={url} resizeMode="contain" loaderSize="large" onLoad={calculateImageSize} />
</ImageZoom>
</Modal>
)
}
export default ImageViewer
const BG_COLOR = "rgba(0, 0, 0, 1)"
const OVERLAY_COLOR = "rgba(0, 0, 0, 0.5)"
const TEXT_COLOR = "rgba(255, 255, 255, 1)"
const styles = StyleSheet.create({
BackText: {
color: TEXT_COLOR,
fontSize: 16,
fontWeight: "500",
lineHeight: 24,
},
container: { backgroundColor: BG_COLOR },
header: {
alignItems: "flex-end",
backgroundColor: OVERLAY_COLOR,
flexDirection: "row",
height: 70,
justifyContent: "space-between",
left: 0,
paddingBottom: 8,
paddingHorizontal: 20,
position: "absolute",
right: 0,
top: 0,
zIndex: 1,
},
headerText: {
color: TEXT_COLOR,
flex: 1,
fontSize: 16,
lineHeight: 24,
paddingLeft: 12,
paddingRight: 6,
},
})
The important part here is the image size because we want it to be shown in a full-screen mode completely without losing the aspect ratio and the user can zoom in and out.
- Get the actual width height of the image from the onLoad event.
- While the width of the screen is smaller than height for mobile, start with checking if the image width is wider than the screen, then calculate the ratio that should be multiplied by and multiply both width and height.
- After we already solved the width part we recheck the height if it is still bigger than the screen height we do the same of the previous step using height ratio this time.
Posted on July 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.