image

10 Feb 2026

9K

35K

Building a Widget Portfolio Detail Page with Image Zoom in Flutter

A portfolio is a crucial tool for showcasing work, and for digital projects, a well-designed portfolio page can make all the difference. In Flutter, creating a rich and interactive portfolio detail page, complete with image zoom capabilities, enhances user experience by allowing visitors to examine project visuals in detail. This article will guide you through the process of building such a widget, focusing on a clean architecture and effective use of Flutter's UI toolkit.

Understanding the Core Components

To construct our portfolio detail page, we'll need several key components:

  • Data Model: To structure the information for each portfolio item.
  • Portfolio Detail Page Widget: The main screen displaying the item's details and a gallery of images.
  • Zoomable Image Viewer: A dedicated widget to handle full-screen image display with zoom and pan functionalities.
  • Smooth Transitions: Using Flutter's Hero animations for a delightful user experience.

1. Defining the Data Model

First, let's create a simple data model to represent a portfolio item and its associated images. This will make it easier to manage and display our content.


// lib/models/portfolio_item.dart
class PortfolioImage {
  final String imageUrl;
  final String? caption; // Optional caption for the image

  PortfolioImage({required this.imageUrl, this.caption});
}

class PortfolioItem {
  final String id; // Unique ID for Hero animation tagging
  final String title;
  final String description;
  final List<PortfolioImage> images;

  PortfolioItem({
    required this.id,
    required this.title,
    required this.description,
    required this.images,
  });
}

2. The Portfolio Detail Page Widget

This widget will be responsible for displaying the portfolio item's title, description, and a grid of its images. Each image in the grid will be a tappable thumbnail.


// lib/widgets/portfolio_detail_page.dart
import 'package:flutter/material.dart';
import 'package:your_app_name/models/portfolio_item.dart';
import 'package:your_app_name/widgets/image_zoom_page.dart'; // We will create this next

class PortfolioDetailPage extends StatelessWidget {
  final PortfolioItem item;

  const PortfolioDetailPage({Key? key, required this.item}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(item.title),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              item.title,
              style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 16.0),
            Text(
              item.description,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            const SizedBox(height: 24.0),
            Text(
              'Gallery',
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 16.0),
            GridView.builder(
              shrinkWrap: true, // Important for nested scrollables
              physics: const NeverScrollableScrollPhysics(), // Disable GridView's own scrolling
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 10.0,
                mainAxisSpacing: 10.0,
                childAspectRatio: 1.0,
              ),
              itemCount: item.images.length,
              itemBuilder: (context, index) {
                final portfolioImage = item.images[index];
                return GestureDetector(
                  onTap: () {
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (context) => ImageZoomPage(
                          initialIndex: index,
                          images: item.images,
                          heroTagPrefix: item.id, // Unique prefix for Hero tags
                        ),
                      ),
                    );
                  },
                  child: Hero(
                    tag: '${item.id}_$index', // Unique tag for Hero animation
                    child: Image.network(
                      portfolioImage.imageUrl,
                      fit: BoxFit.cover,
                      loadingBuilder: (context, child, loadingProgress) {
                        if (loadingProgress == null) return child;
                        return Center(
                          child: CircularProgressIndicator(
                            value: loadingProgress.expectedTotalBytes != null
                                ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
                                : null,
                          ),
                        );
                      },
                      errorBuilder: (context, error, stackTrace) =>
                          const Icon(Icons.broken_image, size: 50),
                    ),
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

3. The Zoomable Image Viewer Widget

This widget will handle displaying images in full-screen, allowing users to zoom and pan using InteractiveViewer. We'll also integrate PageView for swiping between multiple images and use Hero animations for a smooth transition from the thumbnail.


// lib/widgets/image_zoom_page.dart
import 'package:flutter/material.dart';
import 'package:your_app_name/models/portfolio_item.dart';

class ImageZoomPage extends StatefulWidget {
  final int initialIndex;
  final List<PortfolioImage> images;
  final String heroTagPrefix; // Used to reconstruct unique Hero tags

  const ImageZoomPage({
    Key? key,
    required this.initialIndex,
    required this.images,
    required this.heroTagPrefix,
  }) : super(key: key);

  @override
  _ImageZoomPageState createState() => _ImageZoomPageState();
}

class _ImageZoomPageState extends State<ImageZoomPage> {
  late PageController _pageController;
  late int _currentIndex;

  @override
  void initState() {
    super.initState();
    _currentIndex = widget.initialIndex;
    _pageController = PageController(initialPage: widget.initialIndex);
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black, // Dark background for images
      body: Stack(
        children: [
          PageView.builder(
            controller: _pageController,
            itemCount: widget.images.length,
            onPageChanged: (index) {
              setState(() {
                _currentIndex = index;
              });
            },
            itemBuilder: (context, index) {
              final portfolioImage = widget.images[index];
              return Center(
                child: Hero(
                  tag: '${widget.heroTagPrefix}_$index', // Reconstruct Hero tag
                  child: InteractiveViewer(
                    maxScale: 5.0, // Maximum zoom level
                    minScale: 0.5, // Minimum zoom level
                    child: Image.network(
                      portfolioImage.imageUrl,
                      fit: BoxFit.contain, // Fit mode for full-screen image
                      loadingBuilder: (context, child, loadingProgress) {
                        if (loadingProgress == null) return child;
                        return Center(
                          child: CircularProgressIndicator(
                            value: loadingProgress.expectedTotalBytes != null
                                ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
                                : null,
                            color: Colors.white,
                          ),
                        );
                      },
                      errorBuilder: (context, error, stackTrace) =>
                          const Icon(Icons.broken_image, size: 100, color: Colors.white),
                    ),
                  ),
                ),
              );
            },
          ),
          Positioned(
            top: 40,
            left: 10,
            child: IconButton(
              icon: const Icon(Icons.close, color: Colors.white, size: 30),
              onPressed: () => Navigator.of(context).pop(),
            ),
          ),
          if (widget.images[_currentIndex].caption != null)
            Positioned(
              bottom: 40,
              left: 16,
              right: 16,
              child: Text(
                widget.images[_currentIndex].caption!,
                textAlign: TextAlign.center,
                style: const TextStyle(color: Colors.white, fontSize: 16),
              ),
            ),
          Positioned(
            bottom: 10,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: List.generate(widget.images.length, (index) {
                return Container(
                  margin: const EdgeInsets.symmetric(horizontal: 4.0),
                  width: 8.0,
                  height: 8.0,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    color: _currentIndex == index ? Colors.white : Colors.grey,
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }
}

4. Integrating into Your App

Finally, you can integrate this into your main application, for example, from a list of portfolio items.


// lib/main.dart (Example usage)
import 'package:flutter/material.dart';
import 'package:your_app_name/models/portfolio_item.dart';
import 'package:your_app_name/widgets/portfolio_detail_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Portfolio App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: PortfolioListScreen(),
    );
  }
}

class PortfolioListScreen extends StatelessWidget {
  final List<PortfolioItem> portfolioItems = [
    PortfolioItem(
      id: 'app_design_1',
      title: 'Mobile App UI Design',
      description: 'A modern and intuitive UI/UX design for a fictional e-commerce mobile application, focusing on user-centric design principles and clean aesthetics.',
      images: [
        PortfolioImage(imageUrl: 'https://via.placeholder.com/600/FF0000/FFFFFF?text=App+Screen+1'),
        PortfolioImage(imageUrl: 'https://via.placeholder.com/600/00FF00/FFFFFF?text=App+Screen+2'),
        PortfolioImage(imageUrl: 'https://via.placeholder.com/600/0000FF/FFFFFF?text=App+Screen+3', caption: 'User Login Screen'),
      ],
    ),
    PortfolioItem(
      id: 'web_dev_2',
      title: 'Responsive Web Platform',
      description: 'Development of a responsive web platform for project management, built with Flutter web, featuring real-time updates and collaborative tools.',
      images: [
        PortfolioImage(imageUrl: 'https://via.placeholder.com/600/FFFF00/000000?text=Web+Dashboard'),
        PortfolioImage(imageUrl: 'https://via.placeholder.com/600/FF00FF/000000?text=Web+Analytics'),
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Portfolio'),
      ),
      body: ListView.builder(
        itemCount: portfolioItems.length,
        itemBuilder: (context, index) {
          final item = portfolioItems[index];
          return Card(
            margin: const EdgeInsets.all(8.0),
            child: ListTile(
              leading: item.images.isNotEmpty
                  ? Image.network(
                      item.images.first.imageUrl,
                      width: 50,
                      height: 50,
                      fit: BoxFit.cover,
                    )
                  : null,
              title: Text(item.title),
              subtitle: Text(item.description.length > 100
                  ? '${item.description.substring(0, 100)}...'
                  : item.description),
              onTap: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => PortfolioDetailPage(item: item),
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

Conclusion

By combining Flutter's flexible widget system with powerful features like InteractiveViewer and Hero animations, you can create a highly engaging and professional portfolio detail page. This modular approach allows for easy maintenance and scalability, ensuring your projects are showcased in the best possible light. With these techniques, you're well-equipped to build compelling visual experiences in your Flutter applications.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is