Flutter Widgets Fundamentals

Image & Icon Widgets

45 min Lesson 4 of 18

Image Widget Overview

Flutter provides several constructors for the Image widget, each designed for loading images from different sources. Understanding which constructor to use and how to handle loading states and errors is essential for building polished apps.

Image.asset — Bundled Images

Use Image.asset to display images bundled with your app. These are images stored in your project’s assets folder and declared in pubspec.yaml.

Loading Asset Images

// pubspec.yaml:
// flutter:
//   assets:
//     - assets/images/

// Basic asset image
Image.asset(
  'assets/images/logo.png',
  width: 200,
  height: 200,
)

// With fit and alignment
Image.asset(
  'assets/images/banner.jpg',
  width: double.infinity,
  height: 200,
  fit: BoxFit.cover,
  alignment: Alignment.topCenter,
)

// Resolution-aware assets
// Place images in folders:
//   assets/images/logo.png       (1x)
//   assets/images/2.0x/logo.png  (2x)
//   assets/images/3.0x/logo.png  (3x)
// Flutter automatically picks the right resolution
Note: Always declare your asset paths in pubspec.yaml. If you forget, Flutter will throw an Unable to load asset error at runtime. Use directory-level declarations like assets/images/ to include all files in a folder.

Image.network — Remote Images

Image.network loads images from a URL. It handles the HTTP request and decoding automatically. For production apps, consider using cached network images for better performance.

Loading Network Images

// Basic network image
Image.network(
  'https://picsum.photos/400/300',
  width: 400,
  height: 300,
  fit: BoxFit.cover,
)

// With loading indicator and error handling
Image.network(
  'https://example.com/photo.jpg',
  width: double.infinity,
  height: 250,
  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) {
    return Container(
      width: double.infinity,
      height: 250,
      color: Colors.grey.shade200,
      child: const Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.broken_image, size: 48, color: Colors.grey),
          SizedBox(height: 8),
          Text('Failed to load image',
            style: TextStyle(color: Colors.grey)),
        ],
      ),
    );
  },
)

Image.file & Image.memory

Image.file loads images from the device file system (useful after taking a photo or picking a file). Image.memory loads from raw bytes in memory.

File and Memory Images

import 'dart:io';
import 'dart:typed_data';

// From a file on the device
Image.file(
  File('/path/to/photo.jpg'),
  width: 300,
  height: 300,
  fit: BoxFit.cover,
)

// From bytes in memory (e.g., decoded from base64)
Image.memory(
  Uint8List.fromList(imageBytes),
  width: 200,
  height: 200,
  fit: BoxFit.contain,
)

// Practical: Display image from camera
class CameraPreview extends StatelessWidget {
  final File? imageFile;
  const CameraPreview({super.key, this.imageFile});

  @override
  Widget build(BuildContext context) {
    if (imageFile == null) {
      return Container(
        width: 300,
        height: 300,
        color: Colors.grey.shade100,
        child: const Icon(Icons.camera_alt, size: 64, color: Colors.grey),
      );
    }
    return ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Image.file(
        imageFile!,
        width: 300,
        height: 300,
        fit: BoxFit.cover,
      ),
    );
  }
}

BoxFit — Image Sizing

The fit property controls how the image is inscribed into the allocated space. Understanding each BoxFit value is crucial for proper image display.

BoxFit Examples

// BoxFit.cover - Fills the box, may crop
// Best for: backgrounds, cards, avatars
Image.network(url, fit: BoxFit.cover)

// BoxFit.contain - Fits entire image, may have letterboxing
// Best for: product images, logos
Image.network(url, fit: BoxFit.contain)

// BoxFit.fill - Stretches to fill exactly, may distort
// Best for: backgrounds where distortion is acceptable
Image.network(url, fit: BoxFit.fill)

// BoxFit.fitWidth - Fits width, may overflow height
Image.network(url, fit: BoxFit.fitWidth)

// BoxFit.fitHeight - Fits height, may overflow width
Image.network(url, fit: BoxFit.fitHeight)

// BoxFit.none - No scaling, centered
// Best for: pixel-perfect images at native resolution
Image.network(url, fit: BoxFit.none)

// BoxFit.scaleDown - Like contain but never scales up
// Best for: small images that should not be enlarged
Image.network(url, fit: BoxFit.scaleDown)

// Visual comparison widget
class BoxFitShowcase extends StatelessWidget {
  const BoxFitShowcase({super.key});

  @override
  Widget build(BuildContext context) {
    const fits = [
      BoxFit.cover, BoxFit.contain, BoxFit.fill,
      BoxFit.fitWidth, BoxFit.fitHeight, BoxFit.scaleDown,
    ];

    return Wrap(
      spacing: 12,
      runSpacing: 12,
      children: fits.map((fit) => Column(
        children: [
          Container(
            width: 120,
            height: 120,
            decoration: BoxDecoration(
              border: Border.all(color: Colors.grey),
            ),
            child: Image.network(
              'https://picsum.photos/200/300',
              fit: fit,
            ),
          ),
          const SizedBox(height: 4),
          Text(fit.toString().split('.').last,
            style: const TextStyle(fontSize: 12)),
        ],
      )).toList(),
    );
  }
}

CachedNetworkImage

For production apps, use the cached_network_image package. It caches downloaded images on disk, reducing bandwidth usage and improving load times for repeated views.

Using CachedNetworkImage

// pubspec.yaml:
// dependencies:
//   cached_network_image: ^3.3.1

import 'package:cached_network_image/cached_network_image.dart';

// Basic cached image
CachedNetworkImage(
  imageUrl: 'https://example.com/photo.jpg',
  width: double.infinity,
  height: 200,
  fit: BoxFit.cover,
  placeholder: (context, url) => const Center(
    child: CircularProgressIndicator(),
  ),
  errorWidget: (context, url, error) => const Icon(Icons.error),
)

// Advanced with fade animation
CachedNetworkImage(
  imageUrl: 'https://example.com/photo.jpg',
  imageBuilder: (context, imageProvider) => Container(
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(16),
      image: DecorationImage(
        image: imageProvider,
        fit: BoxFit.cover,
      ),
    ),
  ),
  placeholder: (context, url) => Container(
    color: Colors.grey.shade200,
    child: const Center(
      child: CircularProgressIndicator(strokeWidth: 2),
    ),
  ),
  errorWidget: (context, url, error) => Container(
    color: Colors.grey.shade100,
    child: const Icon(Icons.broken_image, color: Colors.grey),
  ),
  fadeInDuration: const Duration(milliseconds: 300),
  fadeOutDuration: const Duration(milliseconds: 300),
)
Tip: CachedNetworkImage uses flutter_cache_manager under the hood and stores images on device storage. This is especially valuable for list views and grids where images are scrolled in and out frequently.

Icon Widget

The Icon widget displays a glyph from a font-based icon set. Flutter comes with the Material Icons set built in. Icons are vector-based, so they scale cleanly at any size.

Using Icons

// Basic icons
const Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    Icon(Icons.home, size: 32),
    Icon(Icons.favorite, size: 32, color: Colors.red),
    Icon(Icons.settings, size: 32, color: Colors.grey),
    Icon(Icons.search, size: 32, color: Colors.blue),
  ],
)

// Icon sizes
const Column(
  children: [
    Icon(Icons.star, size: 16),  // Small
    Icon(Icons.star, size: 24),  // Default
    Icon(Icons.star, size: 32),  // Medium
    Icon(Icons.star, size: 48),  // Large
    Icon(Icons.star, size: 64),  // Extra large
  ],
)

// Outlined vs filled variants
const Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    Icon(Icons.bookmark),           // Filled (default)
    Icon(Icons.bookmark_outline),   // Outlined
    Icon(Icons.bookmark_border),    // Border variant
    Icon(Icons.bookmark_add),       // Add variant
  ],
)

IconButton Widget

IconButton wraps an icon in a tappable area with an ink splash effect. It is the standard way to create interactive icons in Material Design apps.

IconButton Examples

class IconButtonDemo extends StatefulWidget {
  const IconButtonDemo({super.key});

  @override
  State<IconButtonDemo> createState() => _IconButtonDemoState();
}

class _IconButtonDemoState extends State<IconButtonDemo> {
  bool _isFavorite = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // Toggle favorite
        IconButton(
          icon: Icon(
            _isFavorite ? Icons.favorite : Icons.favorite_border,
            color: _isFavorite ? Colors.red : Colors.grey,
          ),
          iconSize: 32,
          onPressed: () => setState(() => _isFavorite = !_isFavorite),
          tooltip: 'Toggle Favorite',
        ),

        // Filled icon button (Material 3)
        IconButton.filled(
          icon: const Icon(Icons.add),
          onPressed: () {},
        ),

        // Outlined icon button
        IconButton.outlined(
          icon: const Icon(Icons.share),
          onPressed: () {},
        ),

        // Disabled icon button
        const IconButton(
          icon: Icon(Icons.delete),
          onPressed: null, // null makes it disabled
        ),
      ],
    );
  }
}

ImageIcon & Custom Icons

ImageIcon creates an icon from an image asset, which is useful when the Material Icons set does not have what you need. For custom icon fonts, use packages like flutter_launcher_icons.

Custom Icon Approaches

// ImageIcon from asset
const ImageIcon(
  AssetImage('assets/icons/custom_icon.png'),
  size: 32,
  color: Colors.blue,
)

// Using a custom icon font
// After generating your font with FlutterIcon or similar:
class CustomIcons {
  static const IconData myCustomIcon = IconData(
    0xe900,
    fontFamily: 'CustomIcons',
    fontPackage: null,
  );
}

// Usage
const Icon(CustomIcons.myCustomIcon, size: 24)

// Cupertino icons (iOS style)
import 'package:flutter/cupertino.dart';

const Row(
  children: [
    Icon(CupertinoIcons.heart, size: 28),
    SizedBox(width: 16),
    Icon(CupertinoIcons.gear, size: 28),
    SizedBox(width: 16),
    Icon(CupertinoIcons.search, size: 28),
  ],
)

Practical Example: Photo Gallery Grid

Let’s build a responsive photo gallery that demonstrates image loading with placeholders and error handling:

Photo Gallery Widget

class PhotoGallery extends StatelessWidget {
  final List<String> imageUrls;
  final int crossAxisCount;

  const PhotoGallery({
    super.key,
    required this.imageUrls,
    this.crossAxisCount = 3,
  });

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: crossAxisCount,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemCount: imageUrls.length,
      itemBuilder: (context, index) => GalleryTile(
        imageUrl: imageUrls[index],
        index: index,
      ),
    );
  }
}

class GalleryTile extends StatelessWidget {
  final String imageUrl;
  final int index;

  const GalleryTile({
    super.key,
    required this.imageUrl,
    required this.index,
  });

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: Image.network(
        imageUrl,
        fit: BoxFit.cover,
        loadingBuilder: (context, child, loadingProgress) {
          if (loadingProgress == null) return child;
          return Container(
            color: Colors.grey.shade100,
            child: Center(
              child: CircularProgressIndicator(
                strokeWidth: 2,
                value: loadingProgress.expectedTotalBytes != null
                    ? loadingProgress.cumulativeBytesLoaded /
                        loadingProgress.expectedTotalBytes!
                    : null,
              ),
            ),
          );
        },
        errorBuilder: (context, error, stackTrace) => Container(
          color: Colors.grey.shade200,
          child: const Icon(Icons.broken_image, color: Colors.grey),
        ),
      ),
    );
  }
}

// Usage
PhotoGallery(
  imageUrls: List.generate(
    9,
    (i) => 'https://picsum.photos/seed/\${i + 1}/400/400',
  ),
)

Practical Example: Avatar with Fallback

A common pattern in apps is showing a user avatar with graceful fallbacks when the image is unavailable:

Avatar Widget with Fallback

class UserAvatar extends StatelessWidget {
  final String? imageUrl;
  final String name;
  final double size;
  final Color? backgroundColor;

  const UserAvatar({
    super.key,
    this.imageUrl,
    required this.name,
    this.size = 48,
    this.backgroundColor,
  });

  String get _initials {
    final parts = name.trim().split(RegExp(r'\s+'));
    if (parts.length >= 2) {
      return '\${parts.first[0]}\${parts.last[0]}'.toUpperCase();
    }
    return name.isNotEmpty ? name[0].toUpperCase() : '?';
  }

  Color get _fallbackColor {
    final hash = name.hashCode;
    final colors = [
      Colors.blue, Colors.red, Colors.green, Colors.purple,
      Colors.orange, Colors.teal, Colors.pink, Colors.indigo,
    ];
    return backgroundColor ?? colors[hash.abs() % colors.length];
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: _fallbackColor,
      ),
      clipBehavior: Clip.antiAlias,
      child: imageUrl != null && imageUrl!.isNotEmpty
          ? Image.network(
              imageUrl!,
              fit: BoxFit.cover,
              errorBuilder: (_, __, ___) => _buildInitials(),
            )
          : _buildInitials(),
    );
  }

  Widget _buildInitials() {
    return Center(
      child: Text(
        _initials,
        style: TextStyle(
          color: Colors.white,
          fontSize: size * 0.38,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

// Usage examples
const Column(
  children: [
    UserAvatar(name: 'John Doe', imageUrl: 'https://example.com/photo.jpg'),
    SizedBox(height: 8),
    UserAvatar(name: 'Ahmed Ali', size: 64), // Shows initials "AA"
    SizedBox(height: 8),
    UserAvatar(name: 'Sara', size: 40),        // Shows initial "S"
  ],
)

Summary

In this lesson, you learned:

  • Image.asset loads bundled images; Image.network loads remote images with HTTP
  • Image.file loads from device storage; Image.memory loads from raw bytes
  • BoxFit controls how images scale: cover, contain, fill, fitWidth, fitHeight, scaleDown, none
  • loadingBuilder and errorBuilder provide loading indicators and error fallbacks
  • CachedNetworkImage caches images on disk for better performance
  • Icon displays Material icons; IconButton makes them interactive
  • ImageIcon and custom icon fonts extend beyond the built-in icon set
What’s Next: In the next lesson, we will explore Button widgets in Flutter, covering all the Material button types, their styling options, and how to build interactive button sets.