Image gallery

A message with several images or videos shows them as a grid of thumbnails, and pressing one opens them in a full-screen viewer. This UI cookbook restyles the grid with the theme, replaces it with a row of tiles that shows a "+N" count on the last one, and replaces the footer of the viewer with one that shows the message's text as a caption and a strip of thumbnails for jumping between items. The grid and the viewer are explained in Message UI and Image gallery.

Note:

What you'll build

  • The grid restyled with the theme
  • A row of tiles with a "+N" count on the last one
  • A viewer footer with the message's text as a caption
  • A strip of thumbnails for jumping between items

Approach: Restyle and Replace one part. Choosing where to start explains the approaches.

Best practices

  • Restyle the grid through messageItemView.gallery when only its size, corners or the "+N" overlay change. Replace the Gallery key when the layout changes.
  • Open the viewer the way the SDK does: imageGalleryStateStore.openImageGallery(), then setOverlay("gallery"). Pass the attachment's URL as selectedAttachmentUrl, and the native handle of the tile as requesterNode, so screen reader focus returns to it when the viewer closes.
  • Read the viewer's state with useStateStore() and a selector declared at module scope. A part of the viewer reads the asset on screen from imageGalleryStateStore.state.
  • Put the WithComponents that replaces a part of the viewer around OverlayProvider. The viewer renders there, outside your screens.
  • Keep the video controls in a footer of your own by rendering the ImageGalleryVideoControls key for a video asset, as the default footer does.
  • Let the user move through the items by swiping, as the viewer's pager does, and give the strip a press that moves it to an item.

Restyling the grid

The default grid lays out the first four images and videos of a message, with "+N" over the last tile when there are more. gridWidth and gridHeight size a grid of two or more items. maxWidth, maxHeight, minWidth and minHeight bound a single item. moreImagesContainer and moreImagesText style the "+N" overlay, and galleryContainer, image and imageContainer the grid and its tiles:

src/galleryTheme.ts
import type { DeepPartial, Theme } from "stream-chat-react-native";

export const galleryTheme: DeepPartial<Theme> = {
  messageItemView: {
    gallery: {
      gridHeight: 216,
      gridWidth: 288,
      moreImagesContainer: { backgroundColor: "rgba(0, 0, 0, 0.6)" },
      moreImagesText: { fontSize: 20 },
    },
  },
};

Pass the theme to OverlayProvider and Chat, as Theming shows. The colors of the tiles come from semantic tokens, so they follow the color scheme. The theme's imageGallery key styles the viewer: backgroundColor for its background, and header, footer, grid and videoControl for its parts.

A grid of four photo tiles with a dark plus 2 overlay on the last one, sized by the theme, on iOS
A grid of four photo tiles with a dark plus 2 overlay on the last one, sized by the theme, on Android

The grid

Gallery renders a message's images and videos. The message list renders it for the gallery part of the bubble, and Attachment renders it for a single image or video. In both cases it reads the images and videos from the props, or from MessageContext. This replacement shows up to four items as square tiles in a row, with "+N" over the fourth when there are more, and a play badge on a video. Pressing a tile opens the viewer at that item, and a long press opens the message menu:

src/ImageGrid.tsx
import { useRef } from "react";
import type { ComponentRef } from "react";
import {
  findNodeHandle,
  Image,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import type { Attachment } from "stream-chat";
import {
  useComponentsContext,
  useImageGalleryContext,
  useMessageContext,
  useOverlayContext,
} from "stream-chat-react-native";
import type { GalleryProps } from "stream-chat-react-native";

const MAX_TILES = 4;

const GridTile = ({
  attachment,
  hiddenCount,
}: {
  attachment: Attachment;
  hiddenCount: number;
}) => {
  const { message, onLongPress } = useMessageContext();
  const { imageGalleryStateStore } = useImageGalleryContext();
  const { setOverlay } = useOverlayContext();
  const { VideoThumbnail } = useComponentsContext();
  const tileRef = useRef<ComponentRef<typeof View>>(null);
  const isVideo = attachment.type === "video";

  const openViewer = () => {
    imageGalleryStateStore.openImageGallery({
      messages: [message],
      requesterNode: findNodeHandle(tileRef.current),
      // A video's `image_url` holds its playable URL in the message context.
      selectedAttachmentUrl: attachment.image_url ?? attachment.asset_url,
    });
    setOverlay("gallery");
  };

  return (
    <Pressable
      accessibilityLabel={isVideo ? "Open video" : "Open image"}
      accessibilityRole="button"
      onLongPress={(event) => onLongPress({ emitter: "gallery", event })}
      onPress={openViewer}
      ref={tileRef}
      style={styles.tile}
    >
      {isVideo ? (
        <VideoThumbnail thumb_url={attachment.thumb_url} />
      ) : (
        <Image
          source={{ uri: attachment.thumb_url ?? attachment.image_url }}
          style={styles.image}
        />
      )}
      {hiddenCount > 0 ? (
        <View style={[StyleSheet.absoluteFill, styles.more]}>
          <Text style={styles.moreText}>+{hiddenCount}</Text>
        </View>
      ) : null}
    </Pressable>
  );
};

export const ImageGrid = ({
  images: imagesProp,
  videos: videosProp,
}: GalleryProps) => {
  const { alignment, images, videos } = useMessageContext();
  const items = [...(imagesProp ?? images), ...(videosProp ?? videos)];
  const tiles = items.slice(0, MAX_TILES);
  const hiddenCount = items.length - tiles.length;

  return (
    <View
      style={[
        styles.grid,
        { alignSelf: alignment === "right" ? "flex-end" : "flex-start" },
      ]}
    >
      {tiles.map((attachment, index) => (
        <GridTile
          attachment={attachment}
          hiddenCount={index === MAX_TILES - 1 ? hiddenCount : 0}
          key={attachment.image_url ?? attachment.asset_url ?? index}
        />
      ))}
    </View>
  );
};

const styles = StyleSheet.create({
  grid: { flexDirection: "row", gap: 2, maxWidth: 256 },
  image: { flex: 1 },
  more: {
    alignItems: "center",
    backgroundColor: "rgba(0, 0, 0, 0.6)",
    justifyContent: "center",
  },
  moreText: { color: "white", fontSize: 18, fontWeight: "600" },
  tile: { aspectRatio: 1, borderRadius: 8, flex: 1, overflow: "hidden" },
});
A row of four photo tiles with a plus 2 count on the last one on iOS
A row of four photo tiles with a plus 2 count on the last one on Android

message and onLongPress come from the message the tile is in. The tile opens the viewer itself, so it doesn't call the message's onPress, and onPressMessage on Channel isn't called for these presses.

The viewer reads its assets from imageGalleryStateStore.state: assets holds one asset for each attachment of the messages it was opened with that have a viewable attachment, currentIndex the asset on screen, and messages the messages. The default footer shows the share button, the position and the grid button. This footer shows the text of the message that the asset belongs to as a caption, a strip of thumbnails that moves the viewer to the item that is pressed, the position and the grid button. For a video, it renders the video controls above them:

src/GalleryFooter.tsx
import { useEffect, useRef, useState } from "react";
import {
  FlatList,
  Image,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedStyle,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
  Button,
  useComponentsContext,
  useImageGalleryContext,
  useStateStore,
  useTheme,
  useTranslationContext,
} from "stream-chat-react-native";
import type {
  ImageGalleryAsset,
  ImageGalleryFooterProps,
  ImageGalleryState,
} from "stream-chat-react-native";

const THUMBNAIL_SIZE = 48;
const THUMBNAIL_GAP = 8;

const gallerySelector = ({
  assets,
  currentIndex,
  messages,
}: ImageGalleryState) => ({ assets, currentIndex, messages });

const getItemLayout = (_data: unknown, index: number) => ({
  index,
  length: THUMBNAIL_SIZE + THUMBNAIL_GAP,
  offset: (THUMBNAIL_SIZE + THUMBNAIL_GAP) * index,
});

export const GalleryFooter = ({
  opacity,
  openGridView,
  visible,
}: ImageGalleryFooterProps) => {
  const { icons, ImageGalleryVideoControls } = useComponentsContext();
  const { imageGalleryStateStore } = useImageGalleryContext();
  const { t } = useTranslationContext();
  const { bottom } = useSafeAreaInsets();
  const {
    theme: { semantics },
  } = useTheme();
  const { assets, currentIndex, messages } = useStateStore(
    imageGalleryStateStore.state,
    gallerySelector,
  );
  const [height, setHeight] = useState(200);
  const stripRef = useRef<FlatList<ImageGalleryAsset>>(null);

  // Slides in and out with the header, and fades while the image is dragged down.
  const animatedStyle = useAnimatedStyle(
    () => ({
      opacity: opacity.value,
      transform: [
        {
          translateY: interpolate(
            visible.value,
            [0, 1],
            [height, 0],
            Extrapolation.CLAMP,
          ),
        },
      ],
    }),
    [height],
  );

  // Keeps the strip centered on the item on screen, also after a swipe.
  useEffect(() => {
    if (currentIndex < assets.length) {
      stripRef.current?.scrollToIndex({
        animated: true,
        index: currentIndex,
        viewPosition: 0.5,
      });
    }
  }, [assets.length, currentIndex]);

  const asset = assets[currentIndex];
  if (!asset) return null;

  const caption = messages.find(({ id }) => id === asset.messageId)?.text;

  return (
    <Animated.View
      onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
      pointerEvents="box-none"
      style={[styles.wrapper, animatedStyle]}
    >
      <View
        style={[
          styles.container,
          {
            backgroundColor: semantics.backgroundCoreElevation1,
            borderTopColor: semantics.borderCoreSubtle,
            paddingBottom: bottom + 8,
          },
        ]}
      >
        {asset.type === "video" ? (
          <ImageGalleryVideoControls attachmentId={asset.id} />
        ) : null}
        {caption ? (
          <Text
            numberOfLines={3}
            style={[styles.caption, { color: semantics.textPrimary }]}
          >
            {caption}
          </Text>
        ) : null}
        <FlatList
          contentContainerStyle={styles.strip}
          data={assets}
          extraData={currentIndex}
          getItemLayout={getItemLayout}
          horizontal
          keyExtractor={(item) => item.id}
          ref={stripRef}
          renderItem={({ index, item }) => (
            <Pressable
              accessibilityLabel={`Item ${index + 1}`}
              accessibilityRole="button"
              accessibilityState={{ selected: index === currentIndex }}
              onPress={() => {
                imageGalleryStateStore.currentIndex = index;
              }}
              style={[
                styles.thumbnail,
                {
                  borderColor:
                    index === currentIndex
                      ? semantics.accentPrimary
                      : "transparent",
                },
              ]}
            >
              <Image
                source={{
                  uri: item.type === "video" ? item.thumb_url : item.uri,
                }}
                style={styles.thumbnailImage}
              />
            </Pressable>
          )}
          showsHorizontalScrollIndicator={false}
        />
        <View style={styles.bar}>
          <Text style={[styles.position, { color: semantics.textPrimary }]}>
            {t(
              "imageGallery.footer.position.text",
              "{{ index }} of {{ photoLength }}",
              {
                index: currentIndex + 1,
                photoLength: assets.length,
              },
            )}
          </Text>
          <Button
            LeadingIcon={icons.ImageGrid}
            accessibilityLabelKey="imageGallery.footer.grid.accessibilityLabel"
            iconOnly
            onPress={openGridView}
            size="md"
            type="ghost"
            variant="secondary"
          />
        </View>
      </View>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  bar: {
    alignItems: "center",
    flexDirection: "row",
    justifyContent: "space-between",
    paddingHorizontal: 12,
  },
  caption: { fontSize: 14, paddingHorizontal: 16, paddingTop: 12 },
  container: { borderTopWidth: 1, gap: 8 },
  position: { fontSize: 13, fontWeight: "600" },
  strip: { gap: THUMBNAIL_GAP, paddingHorizontal: 16, paddingTop: 12 },
  thumbnail: {
    borderRadius: 6,
    borderWidth: 2,
    height: THUMBNAIL_SIZE,
    overflow: "hidden",
    width: THUMBNAIL_SIZE,
  },
  thumbnailImage: { flex: 1 },
  wrapper: { bottom: 0, left: 0, position: "absolute", right: 0 },
});
The image viewer with the message text as a caption and a strip of six thumbnails under the photo, on iOS
The image viewer with the message text as a caption and a strip of six thumbnails under the photo, on Android

The footer has no share button. The default footer's share button shows only when the share and file system handlers are registered, and a footer of your own would have to do the saving and sharing itself.

Register the parts

Both overrides go in one WithComponents. It goes around OverlayProvider, because the viewer renders there:

src/WithImageGallery.tsx
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react-native";
import { GalleryFooter } from "./GalleryFooter";
import { ImageGrid } from "./ImageGrid";

// Declared once, so WithComponents gets the same object on every render.
const overrides = { Gallery: ImageGrid, ImageGalleryFooter: GalleryFooter };

export const WithImageGallery = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
src/App.tsx
import type { PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat, OverlayProvider } from "stream-chat-react-native";
import { WithImageGallery } from "./WithImageGallery";

export const App = ({
  children,
  client,
}: PropsWithChildren<{ client: StreamChat }>) => (
  <WithImageGallery>
    <OverlayProvider>
      <Chat client={client}>{children}</Chat>
    </OverlayProvider>
  </WithImageGallery>
);

How it works

Info:

Example

Cleo shares five trail photos in one message, with the text "Saturday's hike". Her message shows four tiles, and "+1" over the fourth. The user presses the second tile:

  1. GridTile gives the image gallery store the message and the second photo's URL, and sets the overlay to "gallery". OverlayProvider renders the viewer.
  2. The store turns the five photos into assets and finds the one whose URL matches, so the viewer opens on the second.
  3. GalleryFooter reads assets, currentIndex and messages from the store. It shows "Saturday's hike", five thumbnails with the second one outlined, and "2 of 5".
  4. The user swipes to the fifth photo, which the grid doesn't show. The store's currentIndex moves to it, the strip scrolls to its thumbnail and the position reads "5 of 5".
  5. The user presses the first thumbnail. GalleryFooter sets imageGalleryStateStore.currentIndex, and the viewer pages back to the first photo.
  • The store keeps the messages that have an image that isn't a link preview, a Giphy, or a video when a video player is installed. Without a player a video renders as a file, so the grid never gets one. A file in the same message as an image becomes a slide too, so the strip can hold more items than the grid.
  • Setting currentIndex pauses the video of the asset the viewer leaves, and moves the pager.
  • The default header keeps the back button, the sender and the time, and clears the store when the viewer closes. This cookbook leaves it as it is, so the store is cleared as usual.
  • The grid of every asset in a bottom sheet is the ImageGalleryGrid key. The footer's grid button calls the openGridView that the viewer passes.

Read more