Resizing and Cropping

Resize, crop and re-encode images with expo-image-manipulator, and cache thumbnails instead of handling full-resolution files.

Syntaxconst image = await ImageManipulator.manipulate(uri).resize({ width: 1080 }).renderAsync()

A phone camera produces roughly 4032 by 3024 pixels. That file is several megabytes to upload, and decoding it to draw costs about 48 MB of memory, because every pixel is four bytes in a bitmap. Showing it in a 100-point list row wastes all of that. expo-image-manipulator is how you make a smaller file first.

Context, render, save

The API has three steps. ImageManipulator.manipulate(uri) returns a context. resize, crop, rotate, flip and reset are synchronous on that context and queue work on a background thread. Then await context.renderAsync() gives an ImageRef, and await image.saveAsync({ format, compress, base64 }) writes a file and resolves with { uri, width, height }.

Those operations are chainable, and chaining is the same thing as separate statements, because each call mutates the one context: context.resize({ width: 1080 }).rotate(90) equals two lines doing the same two calls.

resize takes { width }, { height } or both. Pass one and the other is computed from the aspect ratio. crop takes { originX, originY, width, height } in source pixels.

Caching

saveAsync writes into the cache directory under a generated name, so repeating the work produces a new file every time. Save thumbnails under a key you can predict, check whether the file exists, and skip the work.

The older manipulateAsync() function is deprecated in favour of this context API.

Example

Example · tsx
import { useState } from "react";
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
import * as ImagePicker from "expo-image-picker";
import { Button, Image, StyleSheet, Text, View } from "react-native";

export default function ShrinkPhoto() {
  const [uri, setUri] = useState<string | null>(null);
  const [label, setLabel] = useState("No image yet");

  async function pickAndShrink() {
    const picked = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"] });
    if (picked.canceled) return;

    // Chained and step-by-step are the same thing: every call mutates this context.
    const context = ImageManipulator.manipulate(picked.assets[0].uri);
    context.resize({ width: 1080 }); // height follows the aspect ratio

    const image = await context.renderAsync(); // waits for the queued work
    const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: 0.7 });

    setUri(saved.uri); // a new file in the cache directory, not the original
    setLabel(`${saved.width} x ${saved.height}`);
  }

  return (
    <View style={styles.screen}>
      {uri ? <Image source={{ uri }} style={styles.preview} /> : null}
      <Text>{label}</Text>
      <Button title="Pick and resize" onPress={pickAndShrink} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
  preview: { width: 240, height: 180, resizeMode: "contain" },
});

When to use it

  • Capping an upload at 1080 pixels on the long edge before sending it.
  • Generating 240-pixel thumbnails for an image grid.
  • Applying a user's crop box to the full-resolution original.
  • Converting a large PNG screenshot to a compressed JPEG.

More examples

Cropping a square with the hook form

useImageManipulator holds one context for one source image across renders, which suits an editing screen where the user adjusts and re-renders. The reset() call matters: the context accumulates operations, so without it a second crop applies to the already cropped result. Crop coordinates are in source pixels, so a crop box drawn on screen has to be scaled by asset.width divided by the displayed width before it is passed here.

Example · tsx
import { useState } from "react";
import { SaveFormat, useImageManipulator } from "expo-image-manipulator";
import { Button, Image, StyleSheet, View } from "react-native";

type SquareCropperProps = { uri: string; side: number };

export default function SquareCropper({ uri, side }: SquareCropperProps) {
  const context = useImageManipulator(uri); // tied to this component's lifetime
  const [out, setOut] = useState<string | null>(null);

  async function cropSquare() {
    context.reset(); // start from the original, not from the previous crop
    context.crop({ originX: 0, originY: 0, width: side, height: side });

    const image = await context.renderAsync();
    const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: 0.8 });
    setOut(saved.uri);
  }

  return (
    <View style={styles.screen}>
      <Image source={{ uri: out ?? uri }} style={styles.preview} />
      <Button title="Crop to square" onPress={cropSquare} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { gap: 12, padding: 16 },
  preview: { width: 200, height: 200 },
});

A thumbnail cache keyed by id

This is the pattern behind every fast image list. The file name is derived from a key you already have, such as a post id, so the existence check replaces the resize on every later render. Without it, a FlatList re-resizes the same photo each time a row scrolls back into view. The cache directory is the right place for derived files: it is yours to regenerate, and the OS may reclaim it when storage runs low.

Example · tsx
import { Directory, File, Paths } from "expo-file-system";
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";

const thumbs = new Directory(Paths.cache, "thumbs");

export async function thumbnailFor(sourceUri: string, key: string): Promise<string> {
  if (!thumbs.exists) thumbs.create();

  const cached = new File(thumbs, `${key}.jpg`);
  if (cached.exists) return cached.uri; // second call does no work at all

  const context = ImageManipulator.manipulate(sourceUri);
  context.resize({ width: 240 });

  const image = await context.renderAsync();
  const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: 0.6 });

  // saveAsync chooses its own file name, so move it to the predictable one.
  await new File(saved.uri).move(cached);
  return cached.uri;
}

Deciding the target size before you resize

Hard-coding resize({ width: 1080 }) enlarges a portrait image's long edge and upscales anything already small, which adds bytes without adding detail. This pure function works out the real target from the asset's width and height, which the picker and the camera both give you. It is plain JavaScript with no native dependency, so it is also the easy part of this feature to unit test.

Example · tsx
export type Size = { width: number; height: number };

// Scales a size down so neither side exceeds max. Never scales up.
export function fitWithin({ width, height }: Size, max: number): Size {
  if (width <= max && height <= max) return { width, height };

  const scale = max / Math.max(width, height);
  return {
    width: Math.round(width * scale),
    height: Math.round(height * scale),
  };
}

console.log(fitWithin({ width: 4032, height: 3024 }, 1080));
// Output: { width: 1080, height: 810 }

console.log(fitWithin({ width: 800, height: 600 }, 1080));
// Output: { width: 800, height: 600 }

Discussion

  • Be the first to comment on this lesson.