أساسيات ودجات Flutter

ودجت الصور والأيقونات

45 دقيقة الدرس 4 من 18

نظرة عامة على ودجت Image

يوفر Flutter عدة مُنشئات لودجت Image، كل منها مصمم لتحميل الصور من مصادر مختلفة. فهم أي مُنشئ تستخدم وكيفية التعامل مع حالات التحميل والأخطاء أمر أساسي لبناء تطبيقات مصقولة.

Image.asset — الصور المجمّعة

استخدم Image.asset لعرض الصور المجمّعة مع تطبيقك. هذه صور مخزنة في مجلد assets في مشروعك ومُعلنة في pubspec.yaml.

تحميل صور الأصول

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

// صورة أصول أساسية
Image.asset(
  'assets/images/logo.png',
  width: 200,
  height: 200,
)

// مع fit ومحاذاة
Image.asset(
  'assets/images/banner.jpg',
  width: double.infinity,
  height: 200,
  fit: BoxFit.cover,
  alignment: Alignment.topCenter,
)

// أصول واعية للدقة
// ضع الصور في مجلدات:
//   assets/images/logo.png       (1x)
//   assets/images/2.0x/logo.png  (2x)
//   assets/images/3.0x/logo.png  (3x)
// يختار Flutter الدقة المناسبة تلقائياً
ملاحظة: أعلن دائماً عن مسارات الأصول في pubspec.yaml. إذا نسيت، سيطرح Flutter خطأ Unable to load asset في وقت التشغيل. استخدم إعلانات على مستوى المجلد مثل assets/images/ لتضمين جميع الملفات في مجلد.

Image.network — الصور عن بُعد

Image.network يحمّل الصور من عنوان URL. يتعامل مع طلب HTTP وفك التشفير تلقائياً. لتطبيقات الإنتاج، فكر في استخدام صور الشبكة المخزنة مؤقتاً لأداء أفضل.

تحميل صور الشبكة

// صورة شبكة أساسية
Image.network(
  'https://picsum.photos/400/300',
  width: 400,
  height: 300,
  fit: BoxFit.cover,
)

// مع مؤشر تحميل ومعالجة أخطاء
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('فشل تحميل الصورة',
            style: TextStyle(color: Colors.grey)),
        ],
      ),
    );
  },
)

Image.file و Image.memory

Image.file يحمّل الصور من نظام ملفات الجهاز (مفيد بعد التقاط صورة أو اختيار ملف). Image.memory يحمّل من بايتات خام في الذاكرة.

صور الملفات والذاكرة

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

// من ملف على الجهاز
Image.file(
  File('/path/to/photo.jpg'),
  width: 300,
  height: 300,
  fit: BoxFit.cover,
)

// من بايتات في الذاكرة (مثلاً مُفككة من base64)
Image.memory(
  Uint8List.fromList(imageBytes),
  width: 200,
  height: 200,
  fit: BoxFit.contain,
)

// عملي: عرض صورة من الكاميرا
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 — تحجيم الصور

خاصية fit تتحكم في كيفية نقش الصورة في المساحة المخصصة. فهم كل قيمة BoxFit أمر حاسم لعرض الصور بشكل صحيح.

أمثلة على BoxFit

// BoxFit.cover - يملأ الصندوق، قد يقص
// الأفضل لـ: الخلفيات، البطاقات، الصور الرمزية
Image.network(url, fit: BoxFit.cover)

// BoxFit.contain - يُلائم الصورة بالكامل، قد يترك أشرطة
// الأفضل لـ: صور المنتجات، الشعارات
Image.network(url, fit: BoxFit.contain)

// BoxFit.fill - يمتد ليملأ بالضبط، قد يشوّه
// الأفضل لـ: الخلفيات حيث التشويه مقبول
Image.network(url, fit: BoxFit.fill)

// BoxFit.fitWidth - يُلائم العرض، قد يتجاوز الارتفاع
Image.network(url, fit: BoxFit.fitWidth)

// BoxFit.fitHeight - يُلائم الارتفاع، قد يتجاوز العرض
Image.network(url, fit: BoxFit.fitHeight)

// BoxFit.none - بدون تحجيم، في الوسط
// الأفضل لـ: صور بدقة مثالية بالحجم الأصلي
Image.network(url, fit: BoxFit.none)

// BoxFit.scaleDown - مثل contain لكن لا يُكبّر أبداً
// الأفضل لـ: صور صغيرة لا يجب تكبيرها
Image.network(url, fit: BoxFit.scaleDown)

// ودجت مقارنة بصرية
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

لتطبيقات الإنتاج، استخدم حزمة cached_network_image. تخزّن الصور المحمّلة على القرص مؤقتاً مما يقلل استخدام عرض النطاق ويُحسّن أوقات التحميل للمشاهدات المتكررة.

استخدام CachedNetworkImage

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

import 'package:cached_network_image/cached_network_image.dart';

// صورة مخزنة مؤقتاً أساسية
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),
)

// متقدم مع رسوم متحركة تلاشي
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),
)
نصيحة: CachedNetworkImage يستخدم flutter_cache_manager في الخلفية ويخزّن الصور على تخزين الجهاز. هذا ذو قيمة خاصة لعروض القوائم والشبكات حيث يتم تمرير الصور للداخل والخارج بشكل متكرر.

ودجت Icon

ودجت Icon تعرض رمزاً من مجموعة أيقونات مبنية على الخطوط. يأتي Flutter مع مجموعة Material 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),
  ],
)

// أحجام الأيقونات
const Column(
  children: [
    Icon(Icons.star, size: 16),  // صغير
    Icon(Icons.star, size: 24),  // افتراضي
    Icon(Icons.star, size: 32),  // متوسط
    Icon(Icons.star, size: 48),  // كبير
    Icon(Icons.star, size: 64),  // كبير جداً
  ],
)

// متغيرات مخططة ومملوءة
const Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    Icon(Icons.bookmark),           // مملوء (افتراضي)
    Icon(Icons.bookmark_outline),   // مخطط
    Icon(Icons.bookmark_border),    // متغير الحدود
    Icon(Icons.bookmark_add),       // متغير الإضافة
  ],
)

ودجت IconButton

IconButton يغلف أيقونة في منطقة قابلة للنقر مع تأثير رش الحبر. هو الطريقة القياسية لإنشاء أيقونات تفاعلية في تطبيقات Material Design.

أمثلة على IconButton

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: [
        // تبديل المفضلة
        IconButton(
          icon: Icon(
            _isFavorite ? Icons.favorite : Icons.favorite_border,
            color: _isFavorite ? Colors.red : Colors.grey,
          ),
          iconSize: 32,
          onPressed: () => setState(() => _isFavorite = !_isFavorite),
          tooltip: 'تبديل المفضلة',
        ),

        // زر أيقونة مملوء (Material 3)
        IconButton.filled(
          icon: const Icon(Icons.add),
          onPressed: () {},
        ),

        // زر أيقونة مخطط
        IconButton.outlined(
          icon: const Icon(Icons.share),
          onPressed: () {},
        ),

        // زر أيقونة معطل
        const IconButton(
          icon: Icon(Icons.delete),
          onPressed: null, // null يجعله معطلاً
        ),
      ],
    );
  }
}

ImageIcon والأيقونات المخصصة

ImageIcon ينشئ أيقونة من أصل صورة، وهو مفيد عندما لا تحتوي مجموعة Material Icons على ما تحتاجه. لخطوط أيقونات مخصصة، استخدم حزماً مثل flutter_launcher_icons.

مناهج الأيقونات المخصصة

// ImageIcon من أصل
const ImageIcon(
  AssetImage('assets/icons/custom_icon.png'),
  size: 32,
  color: Colors.blue,
)

// استخدام خط أيقونات مخصص
// بعد إنشاء خطك مع FlutterIcon أو مشابه:
class CustomIcons {
  static const IconData myCustomIcon = IconData(
    0xe900,
    fontFamily: 'CustomIcons',
    fontPackage: null,
  );
}

// الاستخدام
const Icon(CustomIcons.myCustomIcon, size: 24)

// أيقونات Cupertino (نمط iOS)
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),
  ],
)

مثال عملي: شبكة معرض صور

لنبني معرض صور استجابي يوضح تحميل الصور مع عناصر نائبة ومعالجة أخطاء:

ودجت معرض الصور

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),
        ),
      ),
    );
  }
}

// الاستخدام
PhotoGallery(
  imageUrls: List.generate(
    9,
    (i) => 'https://picsum.photos/seed/\${i + 1}/400/400',
  ),
)

مثال عملي: صورة رمزية مع بديل

نمط شائع في التطبيقات هو إظهار صورة رمزية للمستخدم مع بدائل أنيقة عندما تكون الصورة غير متاحة:

ودجت صورة رمزية مع بديل

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,
        ),
      ),
    );
  }
}

// أمثلة على الاستخدام
const Column(
  children: [
    UserAvatar(name: 'أحمد علي', imageUrl: 'https://example.com/photo.jpg'),
    SizedBox(height: 8),
    UserAvatar(name: 'سارة محمد', size: 64), // يعرض الأحرف الأولى "سم"
    SizedBox(height: 8),
    UserAvatar(name: 'خالد', size: 40),        // يعرض الحرف الأول "خ"
  ],
)

الملخص

في هذا الدرس، تعلمت:

  • Image.asset يحمّل الصور المجمّعة؛ Image.network يحمّل الصور عن بُعد عبر HTTP
  • Image.file يحمّل من تخزين الجهاز؛ Image.memory يحمّل من بايتات خام
  • BoxFit يتحكم في كيفية تحجيم الصور: cover وcontain وfill وfitWidth وfitHeight وscaleDown وnone
  • loadingBuilder وerrorBuilder يوفران مؤشرات تحميل وبدائل خطأ
  • CachedNetworkImage يخزّن الصور على القرص مؤقتاً لأداء أفضل
  • Icon يعرض أيقونات Material؛ IconButton يجعلها تفاعلية
  • ImageIcon وخطوط الأيقونات المخصصة تتجاوز مجموعة الأيقونات المدمجة
ما التالي: في الدرس التالي، سنستكشف ودجت الأزرار في Flutter، مغطين جميع أنواع أزرار Material وخيارات تنسيقها وكيفية بناء مجموعات أزرار تفاعلية.