تخطيطات Flutter والتصميم المتجاوب

Stack و Positioned

50 دقيقة الدرس 4 من 16

مقدمة إلى Stack

بينما يرتب Row و Column العناصر الفرعية بشكل خطي، يتيح لك عنصر Stack تراكب العناصر الفرعية فوق بعضها البعض. فكّر فيه كطبقات من العناصر مثل البطاقات على طاولة -- العنصر الأول في الأسفل، وكل عنصر لاحق يُرسم فوقه.

يُعدّ Stack أساسياً لبناء واجهات المستخدم التي تتطلب عناصر متراكبة: شارات على الأيقونات، نصوص متراكبة على الصور، أزرار عائمة، وتخطيطات بطاقات معقدة.

ملاحظة: يستخدم Stack نظام إحداثيات حيث (0, 0) هو الزاوية العلوية اليسرى افتراضياً. تُرسم العناصر الفرعية بالترتيب -- العنصر الأول في القائمة في الخلف، والعنصر الأخير في المقدمة.

الاستخدام الأساسي لـ Stack

يحدد Stack حجمه ليحتوي جميع عناصره الفرعية غير المحددة الموضع ثم يضع كل عنصر فرعي في الزاوية العلوية اليسرى افتراضياً.

مثال Stack بسيط

Stack(
  children: <Widget>[
    Container(
      width: 200,
      height: 200,
      color: Colors.blue,
    ),
    Container(
      width: 150,
      height: 150,
      color: Colors.red,
    ),
    Container(
      width: 100,
      height: 100,
      color: Colors.yellow,
    ),
  ],
)
// ثلاث مربعات متراكبة: أزرق (خلف)، أحمر (وسط)، أصفر (أمام)

تبدأ الحاويات الثلاث جميعها من الزاوية العلوية اليسرى للـ Stack، مما ينشئ تأثير طبقات. يتحدد حجم Stack بأكبر عنصر فرعي غير محدد الموضع (200x200 في هذه الحالة).

عنصر Positioned

يُستخدم Positioned داخل Stack للتحكم بدقة في مكان وضع العنصر الفرعي. تحدد المسافات من حواف Stack باستخدام خصائص top و bottom و left و right.

مثال عنصر Positioned

Stack(
  children: <Widget>[
    Container(
      width: 300,
      height: 200,
      color: Colors.grey[300],
    ),
    Positioned(
      top: 10,
      left: 10,
      child: Container(
        width: 80,
        height: 80,
        color: Colors.blue,
        child: Center(child: Text('أع', style: TextStyle(color: Colors.white))),
      ),
    ),
    Positioned(
      bottom: 10,
      right: 10,
      child: Container(
        width: 80,
        height: 80,
        color: Colors.red,
        child: Center(child: Text('أس', style: TextStyle(color: Colors.white))),
      ),
    ),
  ],
)

الحاوية الزرقاء موضوعة على بعد 10 بكسل من الحافتين العلوية واليسرى. الحاوية الحمراء موضوعة على بعد 10 بكسل من الحافتين السفلية واليمنى. الحاوية الرمادية غير محددة الموضع وتحدد حجم Stack.

نصيحة: يمكنك تحديد حواف متقابلة في نفس الوقت (مثلاً left و right معاً) لتمديد عنصر Positioned عبر Stack. يتحدد حجم العنصر الفرعي حينها بالمسافة بين الحواف.

Positioned.fill

حاجة شائعة هي جعل عنصر Positioned يملأ Stack بالكامل. Positioned.fill هو مُنشئ مريح يعيّن جميع الحواف الأربع إلى 0:

مثال Positioned.fill

Stack(
  children: <Widget>[
    // صورة الخلفية تملأ Stack بالكامل
    Positioned.fill(
      child: Image.network(
        'https://example.com/photo.jpg',
        fit: BoxFit.cover,
      ),
    ),
    // طبقة داكنة
    Positioned.fill(
      child: Container(color: Colors.black.withOpacity(0.4)),
    ),
    // نص في الأعلى
    Center(
      child: Text(
        'مرحباً',
        style: TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
      ),
    ),
  ],
)

المحاذاة في Stack

تتحكم خاصية alignment في مكان وضع العناصر الفرعية غير محددة الموضع داخل Stack. القيمة الافتراضية هي AlignmentDirectional.topStart.

محاذاة Stack

// توسيط جميع العناصر الفرعية غير محددة الموضع
Stack(
  alignment: Alignment.center,
  children: <Widget>[
    Container(width: 200, height: 200, color: Colors.blue),
    Container(width: 100, height: 100, color: Colors.red),
    // الأحمر موسّط على الأزرق
  ],
)

// محاذاة أسفل-يمين
Stack(
  alignment: Alignment.bottomRight,
  children: <Widget>[
    Container(width: 200, height: 200, color: Colors.blue),
    Container(width: 100, height: 100, color: Colors.red),
    // الأحمر في أسفل-يمين الأزرق
  ],
)
ملاحظة: خاصية alignment تؤثر فقط على العناصر الفرعية غير محددة الموضع. العناصر المغلّفة بـ Positioned توضع وفقاً لقيم top/bottom/left/right الصريحة وتتجاهل محاذاة Stack.

خاصية fit

تتحكم خاصية fit في كيفية تحديد حجم العناصر الفرعية غير محددة الموضع نسبةً إلى Stack:

  • StackFit.loose (الافتراضي) -- يمكن للعناصر الفرعية غير محددة الموضع أن تكون بأي حجم حتى حجم Stack. يفرض Stack قيوداً قصوى من عنصره الأب لكنه يسمح للعناصر الفرعية بأن تكون أصغر.
  • StackFit.expand -- تُجبر العناصر الفرعية غير محددة الموضع على ملء Stack بالكامل. تُعيّن القيود لتكون بالضبط بحجم Stack.
  • StackFit.passthrough -- تُمرر القيود من عنصر Stack الأب مباشرةً إلى العناصر الفرعية غير محددة الموضع بدون تعديل.

أمثلة StackFit

// يمكن للعناصر الفرعية أن تكون بأي حجم (الافتراضي)
SizedBox(
  width: 300,
  height: 200,
  child: Stack(
    fit: StackFit.loose,
    children: [
      Container(width: 100, height: 100, color: Colors.blue),
      // الأزرق بحجم 100x100 داخل Stack بحجم 300x200
    ],
  ),
)

// تُجبر العناصر الفرعية على ملء Stack
SizedBox(
  width: 300,
  height: 200,
  child: Stack(
    fit: StackFit.expand,
    children: [
      Container(color: Colors.blue),
      // الأزرق يملأ Stack بالكامل 300x200
    ],
  ),
)

clipBehavior

تتحكم خاصية clipBehavior فيما يحدث عندما تمتد عناصر Positioned الفرعية خارج حدود Stack:

  • Clip.hardEdge (الافتراضي) -- تُقطع العناصر الفرعية التي تتجاوز حدود Stack.
  • Clip.none -- يمكن للعناصر الفرعية الامتداد بصرياً خارج حدود Stack. مفيد للظلال والتلميحات والعناصر الزخرفية.
  • Clip.antiAlias -- قطع مع تنعيم الحواف لحواف أنعم.

مثال clipBehavior

Stack(
  clipBehavior: Clip.none,
  children: <Widget>[
    Container(width: 200, height: 200, color: Colors.blue),
    Positioned(
      top: -20,
      right: -20,
      child: Container(
        width: 60,
        height: 60,
        decoration: BoxDecoration(
          color: Colors.red,
          shape: BoxShape.circle,
        ),
        child: Center(child: Text('3', style: TextStyle(color: Colors.white))),
      ),
    ),
  ],
)
// الدائرة الحمراء تمتد 20 بكسل خارج الزاوية العلوية اليمنى للـ Stack
تحذير: عند استخدام Clip.none، تُرسم العناصر الفرعية المتجاوزة لكنها لا تتلقى أحداث اختبار النقر خارج حدود Stack. هذا يعني أن المستخدمين لا يمكنهم النقر على الجزء المتجاوز من زر أو عنصر تفاعلي.

IndexedStack

IndexedStack هو نوع مختلف من Stack يعرض عنصراً فرعياً واحداً فقط في كل مرة بناءً على فهرس. جميع العناصر الفرعية تُبنى وتحتفظ بحالتها، لكن فقط العنصر الفرعي عند الفهرس المحدد يكون مرئياً.

مثال IndexedStack

class TabView extends StatefulWidget {
  @override
  State<TabView> createState() => _TabViewState();
}

class _TabViewState extends State<TabView> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: IndexedStack(
            index: _selectedIndex,
            children: <Widget>[
              Center(child: Text('الصفحة الرئيسية', style: TextStyle(fontSize: 24))),
              Center(child: Text('صفحة البحث', style: TextStyle(fontSize: 24))),
              Center(child: Text('صفحة الملف الشخصي', style: TextStyle(fontSize: 24))),
            ],
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            IconButton(icon: Icon(Icons.home), onPressed: () => setState(() => _selectedIndex = 0)),
            IconButton(icon: Icon(Icons.search), onPressed: () => setState(() => _selectedIndex = 1)),
            IconButton(icon: Icon(Icons.person), onPressed: () => setState(() => _selectedIndex = 2)),
          ],
        ),
      ],
    );
  }
}
نصيحة: يحافظ IndexedStack على حالة جميع العناصر الفرعية، مما يجعله مثالياً للتنقل القائم على التبويبات حيث تريد الحفاظ على موضع التمرير أو بيانات النموذج عند التبديل بين التبويبات. ومع ذلك، كن واعياً أن جميع العناصر الفرعية تُبنى في الذاكرة، لذا تجنب استخدامه مع عناصر ثقيلة كثيرة.

مثال عملي: شارة على أيقونة

شارة إشعار على أيقونة هي أحد أكثر أنماط Stack شيوعاً:

شارة الإشعار

Stack(
  clipBehavior: Clip.none,
  children: <Widget>[
    Icon(Icons.notifications, size: 32, color: Colors.grey[700]),
    Positioned(
      top: -5,
      right: -5,
      child: Container(
        padding: EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: Colors.red,
          shape: BoxShape.circle,
        ),
        constraints: BoxConstraints(minWidth: 20, minHeight: 20),
        child: Text(
          '5',
          style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
          textAlign: TextAlign.center,
        ),
      ),
    ),
  ],
)

مثال عملي: نص متراكب على صورة

صورة رئيسية مع نص متراكب، تُستخدم بشكل شائع في البطاقات والشعارات:

صورة مع نص متراكب

ClipRRect(
  borderRadius: BorderRadius.circular(12),
  child: Stack(
    children: <Widget>[
      // صورة الخلفية
      Image.network(
        'https://example.com/city.jpg',
        width: double.infinity,
        height: 200,
        fit: BoxFit.cover,
      ),
      // طبقة تدرج لوني
      Positioned.fill(
        child: Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
            ),
          ),
        ),
      ),
      // محتوى النص
      Positioned(
        bottom: 16,
        left: 16,
        right: 16,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('مدينة نيويورك', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
            SizedBox(height: 4),
            Text('استكشف المدينة التي لا تنام', style: TextStyle(color: Colors.white70, fontSize: 14)),
          ],
        ),
      ),
    ],
  ),
)

مثال عملي: عناوين عائمة

حقل نصي مع عنوان عائم مخصص موضوع خارج حدود الحقل:

عنوان عائم مخصص

Stack(
  clipBehavior: Clip.none,
  children: <Widget>[
    Container(
      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue, width: 2),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        children: [
          Icon(Icons.email, color: Colors.blue),
          SizedBox(width: 8),
          Expanded(
            child: Text('user@example.com', style: TextStyle(fontSize: 16)),
          ),
        ],
      ),
    ),
    Positioned(
      top: -10,
      left: 12,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 8),
        color: Colors.white,
        child: Text('البريد الإلكتروني', style: TextStyle(color: Colors.blue, fontSize: 12)),
      ),
    ),
  ],
)

مثال عملي: بطاقة مخصصة مع طبقات

بطاقة منتج مع شارة خصم وزر مفضلة ومحتوى متراكب:

بطاقة منتج مع طبقات

Card(
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  child: Stack(
    children: <Widget>[
      // صورة المنتج
      Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Image.network(
            'https://example.com/product.jpg',
            width: double.infinity,
            height: 180,
            fit: BoxFit.cover,
          ),
          Padding(
            padding: EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('سماعات فاخرة', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                SizedBox(height: 4),
                Text('\$99.99', style: TextStyle(fontSize: 18, color: Colors.green, fontWeight: FontWeight.bold)),
              ],
            ),
          ),
        ],
      ),
      // شارة الخصم (أعلى-يسار)
      Positioned(
        top: 8,
        left: 8,
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(
            color: Colors.red,
            borderRadius: BorderRadius.circular(4),
          ),
          child: Text('-30%', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
        ),
      ),
      // زر المفضلة (أعلى-يمين)
      Positioned(
        top: 8,
        right: 8,
        child: CircleAvatar(
          backgroundColor: Colors.white,
          radius: 18,
          child: Icon(Icons.favorite_border, color: Colors.red, size: 20),
        ),
      ),
    ],
  ),
)

الملخص

  • Stack يتراكب العناصر الفرعية فوق بعضها -- العنصر الأول في الخلف، الأخير في المقدمة.
  • Positioned يضع عنصراً فرعياً في إحداثيات دقيقة داخل Stack باستخدام top و bottom و left و right.
  • Positioned.fill يمدد عنصراً فرعياً لتغطية Stack بالكامل.
  • خاصية alignment تتحكم في مكان وضع العناصر الفرعية غير محددة الموضع.
  • StackFit يتحكم في التحجيم: loose (العناصر تختار حجمها)، expand (العناصر تملأ Stack)، passthrough (قيود الأب تُمرر).
  • clipBehavior يتحكم في التجاوز: Clip.none يسمح بتجاوز مرئي لكنه يمنع اختبار النقر خارج الحدود.
  • IndexedStack يعرض عنصراً فرعياً واحداً فقط في كل مرة مع الحفاظ على حالة جميع العناصر الفرعية.
  • الأنماط الشائعة: الشارات، طبقات الصور، العناوين العائمة، وتخطيطات البطاقات المعقدة.