# How to Add a Dynamic Image Gallery with Filters to Your App (Copy-Paste Prompts)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A dynamic image gallery with filters needs four pieces: a Supabase table with a category column, CachedNetworkImage for lazy loading with shimmer placeholders, FilterChip widgets for category selection, and photo_view for full-screen pinch-to-zoom. FlutterFlow builds almost all of this visually with a Backend Query — only the masonry layout and full-screen viewer need Custom Widget blocks. Expect 2–4 hours with FlutterFlow for a standard grid, 3–7 days custom. Running costs start at $0/mo on the Supabase free tier.

## What a Dynamic Image Gallery with Filters Actually Is

A dynamic image gallery with filters is a scrollable grid of images pulled from a backend, organized into categories the user can switch between using tabs or chip buttons. Tapping an image opens a full-screen viewer with pinch-to-zoom and swipe navigation between images in the active filter set. The feature appears in portfolio apps, product catalogues, art platforms, real estate listings, and any app where browsing images is a primary activity. The product decisions that matter: whether categories are single-select or multi-select, whether you want masonry (variable heights, Pinterest-style) or uniform grid, how images are paginated (page-based or infinite scroll), and whether the gallery is public or user-specific.

## Anatomy of the Feature

Six components. FlutterFlow's visual builder handles the filter chips, GridView, and Supabase Backend Query natively. The full-screen viewer (photo_view) and masonry layout (flutter_staggered_grid_view) each need one Custom Widget registration, but both packages are well-documented for FlutterFlow.

- **Filter tab bar** (ui): A horizontally scrollable Row of FilterChip widgets, one per category pulled from the gallery_categories Supabase table. The selected category is stored in page state (or App State for multi-screen persistence). Selecting a chip sets the active category and triggers a Backend Query refresh with .eq('category', selectedCategory). AnimatedSwitcher wraps the grid for a smooth content fade on filter change.
- **Image grid** (ui): GridView.builder with crossAxisCount: 2 and childAspectRatio: 1.0 for a uniform grid. CachedNetworkImage (^3.4.x) handles lazy loading with a shimmer placeholder (Shimmer.fromColors from the shimmer package) while each image fetches. Images are paginated with Supabase range(0, 19) and loaded in additional pages of 20 as the user scrolls toward the bottom.
- **Full-screen viewer** (ui): photo_view (^0.15.x) renders individual images with pinch-to-zoom (min scale 0.5, max scale 4.0). PhotoViewGallery.builder wraps a PageView for swipe navigation between images in the current filtered set. Hero widget on each thumbnail with tag: image.id provides the shared-element transition from grid to full-screen.
- **Image data layer** (data): Supabase table gallery_images with columns: title, description, image_url, thumbnail_url, category, tags (text[]), sort_order, is_published. A companion gallery_categories table drives the filter chip list. Queries use .eq('category', selectedCategory) for single-category filters and .contains('tags', [searchQuery]) for tag-based search.
- **Search layer** (ui): Flutter SearchBar widget (Material 3) at the top of the screen. Debounced 400 ms after the last keystroke using a Timer that cancels and restarts on each onChange event. On fire: executes a Supabase query with .ilike('title', '%query%') or .contains('tags', [query]) against the current filtered category. Clears to the full gallery when the search field is emptied.
- **Pull-to-refresh** (ui): RefreshIndicator widget wrapping the GridView triggers a re-fetch from Supabase with the current active filter and search query. Resets the page counter to 0 so pagination restarts. In FlutterFlow, the RefreshIndicator onRefresh callback should call the Backend Query's refetch action.

## Data model

Two tables: gallery_images for image data and gallery_categories for the filter list. Run this in the Supabase SQL editor. RLS keeps the gallery public for published images and restricts admin writes.

```sql
create table public.gallery_categories (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text not null unique,
  sort_order int not null default 0
);

create table public.gallery_images (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  description text,
  image_url text not null,
  thumbnail_url text,
  category text not null,
  tags text[] not null default '{}',
  sort_order int not null default 0,
  is_published boolean not null default true,
  created_at timestamptz not null default now()
);

alter table public.gallery_categories enable row level security;
alter table public.gallery_images enable row level security;

create policy "Public can read gallery categories"
  on public.gallery_categories for select
  to anon, authenticated
  using (true);

create policy "Public can read published images"
  on public.gallery_images for select
  to anon, authenticated
  using (is_published = true);

create policy "Authenticated admins can manage categories"
  on public.gallery_categories for all
  to authenticated
  using (auth.jwt() ->> 'role' = 'admin')
  with check (auth.jwt() ->> 'role' = 'admin');

create policy "Authenticated admins can manage images"
  on public.gallery_images for all
  to authenticated
  using (auth.jwt() ->> 'role' = 'admin')
  with check (auth.jwt() ->> 'role' = 'admin');

create index gallery_images_category_sort_idx
  on public.gallery_images (category, sort_order)
  where is_published = true;

create index gallery_images_tags_idx
  on public.gallery_images using gin (tags);
```

The GIN index on tags makes .contains() queries on the text[] column fast even with thousands of images. The partial index on category + sort_order (where is_published = true) keeps category filter queries snappy without scanning unpublished rows.

## Build paths

### Lovable — fit 1/10, Not applicable for mobile

Lovable produces a Vite/React web app, not a Flutter mobile app. A mobile image gallery with CachedNetworkImage shimmer, Hero transitions, and photo_view pinch-to-zoom requires native Flutter. Lovable can build a web counterpart gallery if a browser-based display is acceptable, but mobile-native UX patterns are not available.

1. Use FlutterFlow for the mobile gallery as described in the primary build path above
2. If a web gallery is a separate product goal, prompt Lovable to build a responsive image grid fetching from the same Supabase gallery_images table with the same category filter logic

Starter prompt:

```
Build a web image gallery with category filters. Fetch gallery_categories from Supabase and display them as filter chips in a horizontal scrollable row at the top of the page. When a chip is selected, re-query the Supabase gallery_images table with .eq('category', selectedCategory) and .eq('is_published', true), ordered by sort_order, with pagination using range(0, 19). Display images in a responsive CSS grid (2 columns on mobile, 3 on tablet, 4 on desktop) using thumbnail_url for grid cells and image_url in the lightbox. Show a shimmer placeholder while each image loads. On image click, open a lightbox with the full-resolution image, left/right arrows to navigate within the current filtered set, and a close button. Add a search input debounced at 400 ms that re-queries with .ilike('title', '%query%'). Show an empty state illustration when no images match. Implement infinite scroll: load the next page of 20 images when the user scrolls within 200 px of the bottom.
```

Limitations:

- Lovable outputs a Vite/React web app — this mobile gallery feature requires FlutterFlow or custom Flutter for native Hero animations, CachedNetworkImage, and photo_view pinch-to-zoom
- The web build lacks native swipe gestures, pull-to-refresh feel, and offline image caching available in the Flutter path
- Lovable connects to the same Supabase database, so the web and mobile galleries can share data — but the user experience differs significantly between the two outputs

### Flutterflow — fit 5/10, 2–4 hours

FlutterFlow's Backend Query for Supabase, GridView component, and FilterChip can build this gallery visually with minimal custom Dart. The standard 2-column grid and filter chips require no custom code at all — only masonry layout and the full-screen photo_view viewer need Custom Widget registrations.

1. In Supabase, run the SQL schema above; set the Supabase URL and anon key in FlutterFlow Project Settings under API & Integrations
2. Create a GalleryPage with a Backend Query on gallery_categories to populate the filter chip Row, and a separate Backend Query on gallery_images filtered by the selected category App State variable
3. Add a horizontally scrollable Row of FilterChip widgets bound to the categories query result; in each chip's onSelected action, update the active category App State variable and call the images Backend Query's refetch action
4. Add a GridView component with 2 columns; in each cell, place a CachedNetworkImage bound to thumbnail_url with a ShimmerEffect placeholder from FlutterFlow's built-in widget list
5. For the full-screen viewer: add a Custom Widget using photo_view's PhotoViewGallery.builder; wrap each grid thumbnail in a Hero widget with uniqueTag set to the image ID; on thumbnail tap, navigate to the full-screen page passing the filtered image list and the tapped index
6. Test on a physical device via the FlutterFlow mobile preview — the web preview does not replicate Flutter Hero animations or CachedNetworkImage shimmer behavior accurately

Limitations:

- flutter_staggered_grid_view masonry layout is not available as a native FlutterFlow widget — it requires a Custom Widget registration; for most galleries, the standard 2-column GridView is sufficient
- FlutterFlow's built-in filter chip action fires only on select, not on deselect — the gallery doesn't update when a user taps the active chip to clear the filter; fix this by adding an explicit Backend Query refetch action to the onSelected callback for both true and false states
- Infinite scroll requires a page counter variable and manual range calculation — FlutterFlow's Backend Query pagination helpers don't fully automate this; expect 20–30 minutes of action flow configuration
- The FlutterFlow web preview does not accurately render CachedNetworkImage shimmer or Hero animations — always test on a physical device

### Custom — fit 4/10, 3–7 days

Custom Flutter development gives full control over masonry layout with variable aspect ratios, cursor-based Supabase pagination for smooth infinite scroll, and custom Hero transition curves that feel truly native.

1. Set up flutter_staggered_grid_view with StaggeredGridView.countBuilder and crossAxisCount: 3; use a Random-seeded height multiplier keyed to the image ID so the stagger pattern is consistent across rebuilds and filter changes
2. Implement cursor-based Supabase pagination using .gt('sort_order', lastSortOrder).limit(20) instead of range() — this avoids the duplicate-item problem that appears when new images are inserted while the user is paginating
3. Add multi-select filter mode where multiple FilterChip widgets can be active simultaneously; build the Supabase query dynamically: if selectedCategories.length > 1, use .in_('category', selectedCategories)
4. Implement a custom Hero transition with a CurvedAnimation (Curves.easeOutCubic) so the thumbnail-to-full-screen animation matches iOS system app feel

Limitations:

- Custom masonry layout requires calculating and caching image aspect ratios before rendering — either store aspect_ratio in the database or pre-fetch image dimensions on load, which adds complexity
- Cursor-based pagination is harder to implement than offset-based range() but avoids duplicates during infinite scroll when content is updated — worth the extra effort for galleries that change frequently

## Gotchas

- **CachedNetworkImage shows blank white on Supabase private bucket images** — If gallery images are stored in a private Supabase Storage bucket, CachedNetworkImage HTTP requests return 403 Forbidden — no error is thrown, the widget simply renders blank white where the image should be. This looks identical to a layout bug or a network timeout, making it difficult to diagnose without checking the Supabase Storage bucket policy. Fix: Use a Supabase public bucket for gallery images. In Supabase Storage, set the bucket visibility to Public and enable public access. If images must stay private (e.g., user-specific protected content), generate signed URLs server-side with a 1-hour expiry and store them temporarily in the Flutter state — regenerate before they expire using a timer or on app foreground event.
- **FlutterFlow filter chips don't update the grid on deselect** — FlutterFlow's FilterChip onSelected action fires with a boolean value — true when selecting, false when deselecting. The common mistake is adding the Backend Query refetch action only to the 'true' branch. When a user taps the active chip to clear the filter, the action doesn't run, and the gallery stays filtered instead of showing all images. Fix: In the FlutterFlow Action Flow editor, add the Backend Query refetch action to both the true and false branches of the onSelected callback. Also update the active category App State variable in both branches — set it to the category slug on true, and clear it to an empty string on false. The Supabase query should omit the .eq() filter when the category variable is empty.
- **PhotoViewGallery crashes on large unfiltered image sets** — A common mistake is initializing PhotoViewGallery.builder with the full gallery_images list (potentially hundreds of items) when a user taps any image. On low-RAM Android devices (2–3 GB), loading this many CachedNetworkImage instances into a PageView simultaneously causes high memory pressure and crashes. The crash is device-dependent — it works fine on a modern flagship phone during development. Fix: Pass only the currently filtered and paginated list of images (the 20-item current page) to PhotoViewGallery. If the user swipes to the edge of the loaded set, load the next page lazily. Alternatively, limit the full-screen gallery to the visible on-screen items plus 5 on each side. This reduces peak memory usage from potentially hundreds of decoded images to under 15.
- **AnimatedSwitcher flickers when filter chips are tapped rapidly** — Tapping multiple filter chips quickly in succession queues multiple Supabase Backend Queries. When they return out of order — the 'Nature' query returns after the 'Architecture' query even though Architecture was tapped second — the grid briefly shows Nature content before flipping to Architecture. This looks like a race condition flicker. Fix: Track the active request with a requestId counter (an integer incremented on each query start). When a query returns, compare its requestId to the current counter; discard the result if the IDs don't match. In FlutterFlow, use a page state integer variable as the requestId and conditionally apply query results only if the returned id matches the current state.
- **Supabase .contains() on a text column returns no results silently** — If the tags column was created as a text type (storing comma-separated values like 'nature,outdoor,travel') rather than a proper text[] (array) type, the Supabase .contains('tags', ['nature']) query returns zero results without any error. The tags filter appears to work — no exception, no 400 — but every query returns an empty array. Fix: Check the tags column type in the Supabase Table Editor (it should show text[], not text). If it is incorrectly typed as text, use .ilike('tags', '%nature%') as an immediate workaround. To fix permanently, run ALTER TABLE gallery_images ADD COLUMN tags_array text[] GENERATED ALWAYS AS (string_to_array(tags, ',')) STORED to add a computed array column, then migrate queries to use the new column.

## Best practices

- Always store both thumbnail_url and image_url separately — loading full-resolution images in the grid burns 5–10x more bandwidth than thumbnails and makes the gallery feel slow even on fast connections
- Set the category variable in App State (not page state) so the active filter persists when the user navigates to a detail screen and presses back
- Wrap every grid thumbnail in a Hero widget with a unique tag (the image ID) — the shared-element transition to the full-screen viewer is one of the highest-impact polish details in a gallery app
- Implement infinite scroll with a bottom-of-list detector rather than a 'Load More' button — users expect continuous scroll in mobile galleries
- Show the empty state illustration with a specific message like 'No Architecture photos yet' rather than a generic 'Nothing here' — users need to understand whether their filter or search is too narrow
- Debounce the search query at 400 ms so the gallery doesn't fire a Supabase request on every keystroke — a fast typist otherwise generates 10–15 queries for a 5-character search
- Use the GIN index on the tags text[] column from the schema — without it, tag searches perform a full table scan and become noticeably slow past a few thousand images

## Frequently asked questions

### How do I add category filter tabs to a Flutter image gallery?

Fetch your categories from a Supabase gallery_categories table and display them as a horizontally scrollable Row of FilterChip widgets. Store the selected category slug in FlutterFlow App State or a page state variable. In each chip's onSelected callback, update the selected category and trigger a re-fetch of gallery_images with .eq('category', selectedCategory). To show 'All' images, use an empty string for the category value and omit the .eq() filter when it's empty.

### What Flutter package gives the best masonry grid layout?

flutter_staggered_grid_view (^0.7.x) is the standard choice. Use StaggeredGridView.countBuilder with crossAxisCount: 3 and crossAxisCellCount varying per item to create Pinterest-style variable-height columns. One caveat: this package requires a Custom Widget registration in FlutterFlow. If you don't specifically need masonry, GridView.builder with a fixed childAspectRatio is simpler, requires no Custom Widget in FlutterFlow, and handles most gallery use cases well.

### How do I implement a full-screen image viewer with pinch-to-zoom in Flutter?

Use the photo_view package (^0.15.x). For a single image: PhotoView(imageProvider: CachedNetworkImageProvider(imageUrl)). For swipe navigation between images: PhotoViewGallery.builder(itemCount: images.length, builder: (ctx, i) => PhotoViewGalleryPageOptions(imageProvider: CachedNetworkImageProvider(images[i].imageUrl))). Set minScale: PhotoViewComputedScale.contained and maxScale: 4.0 for reasonable zoom limits. In FlutterFlow, register photo_view as a Custom Widget.

### How do I lazy load images in a Flutter GridView?

Use CachedNetworkImage (^3.4.x) as the image widget inside each GridView cell. It fetches images on-demand as they scroll into view and caches them to disk so repeat scrolling doesn't re-fetch. Add a placeholder using the placeholder builder with a Shimmer.fromColors widget from the shimmer package — this shows an animated grey shimmer where each image will appear. GridView.builder is inherently lazy (it only builds visible items), so combining it with CachedNetworkImage gives full lazy loading with no additional setup.

### Can I filter a Supabase image gallery by multiple categories at once?

Yes. When multiple categories are selected, use Supabase's .in_('category', selectedCategoryList) instead of .eq(). In FlutterFlow, maintain a list variable of selected category slugs; add or remove slugs when chips are tapped; pass the list to the Backend Query's filter. For tag-based multi-filter (where an image can have multiple tags), use .overlaps('tags', selectedTagList) on a text[] column — this returns images that have at least one tag in common with the selected list.

### How do I add a search bar to a Flutter image gallery?

Add a Flutter SearchBar widget (Material 3) at the top of the page. In FlutterFlow, connect onChange to an App State variable that holds the search query. Use a Timer to debounce: start a 400 ms timer on each onChange; cancel and restart it if another change fires before it completes; when it fires, re-execute the Backend Query with .ilike('title', '%searchQuery%'). To search tags, add .or('tags.cs.{searchQuery}') to the query — but this requires the tags column to be type text[] and the GIN index to be present.

### What's the best way to handle pull-to-refresh in a Flutter grid?

Wrap the GridView in a RefreshIndicator widget. The onRefresh callback must be an async function that returns a Future — FlutterFlow's Backend Query refetch action satisfies this. For infinite scroll galleries, the onRefresh callback must also reset the page counter variable to 0 before calling refetch, otherwise the refresh loads page 2 instead of starting from the beginning. Show a LinearProgressIndicator at the top while refreshing to give users feedback on slower connections.

### How do I animate the transition from a thumbnail to a full-screen image in Flutter?

Use Flutter's Hero widget. Wrap the grid thumbnail in Hero(tag: image.id, child: Image(...)) and wrap the full-screen image in Hero(tag: image.id, child: PhotoView(...)) using the same tag. Flutter automatically animates between them when you push the full-screen route. The animation works best with Navigator.push using a MaterialPageRoute — the Hero animation respects the route transition timing. In FlutterFlow, set the Hero tag to the image's unique ID on both the grid item widget and the full-screen page image widget.

---

Source: https://www.rapidevelopers.com/app-features/dynamic-image-gallery-with-filters
© RapidDev — https://www.rapidevelopers.com/app-features/dynamic-image-gallery-with-filters
