DEV Community

loading...
Cover image for React native dealing with images loading, viewing, zooming, and caching

React native dealing with images loading, viewing, zooming, and caching

Nader Alfakesh
・7 min read

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

  1. 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;
  2. 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
Demo view of the final project

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,
  },
})
Enter fullscreen mode Exit fullscreen mode

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.

Example of avatar component

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 }
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Example of card with image component

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 },
})
Enter fullscreen mode Exit fullscreen mode

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.

Example of image input field

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 },
})
Enter fullscreen mode Exit fullscreen mode

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.

Example of image with caption

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,
  },
})
Enter fullscreen mode Exit fullscreen mode

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

Example of full screen Image viewer

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,
  },
})
Enter fullscreen mode Exit fullscreen mode

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.

Example of full screen Image viewer zoomed in

Discussion (0)