Flutter: الرسم المخصص وCustomPainter

عندما لا تكفي الويدجتات القياسية، تصبح اللوحة Canvas صفحتك البيضاء — أي واجهة يمكن رسمها ضربة بضربة.

📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ

1. ما ستتعلمه


2. قصة حقيقية عن متطلب مخطط

(1) المشكلة: مكتبات المخططات الخارجية تفتقر للتخصيص

تطبيق ShopApp لبوب يحتاج مخططًا خطيًا لاتجاه المبيعات مع: تعبئة متدرجة، والنقر لعرض التفاصيل على نقاط البيانات، ورسم متحرك. مكتبات المخططات الخارجية إما لا تدعم التدرجات، أو لها استدعاءات نقر غير مرنة، أو حركات غير قابلة للتحكم. وصل بوب لـ 80% مع fl_chart لكنه لم يستطع تعديل الكود المصدري للـ 20% المتبقية.

(2) حل CustomPainter

CustomPainter يمنحك تحكمًا كاملاً في الرسم — كل ضربة، كل بكسل تحت سيطرتك.

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - data: List<double> type, representing sales data points

class SalesChartPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Full control: draw exactly what you need
    final linePaint = Paint()..color = Colors.blue..strokeWidth = 2..style = PaintingStyle.stroke;
    final fillPaint = Paint()..shader = LinearGradient(colors: [Colors.blue.withOpacity(0.3), Colors.transparent])
        .createShader(Rect.fromLTWH(0, 0, size.width, size.height));
    // Draw path, fill, dots, labels...
  }

  @override
  bool shouldRepaint(covariant SalesChartPainter old) => old.data != data;
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: تخصيص 100% + صفر تبعيات خارجية

نفذ بوب المتطلب الكامل بـ CustomPainter في 200 سطر فقط، مع صفر تبعيات خارجية وحركات قابلة للتحكم بالكامل.


3. نظام CustomPainter

100%
graph LR
    CP[CustomPaint] --> Painter[CustomPainter]
    Painter --> Canvas[Canvas API]
    Canvas --> DL[drawLine]
    Canvas --> DR2[drawRect]
    Canvas --> DC[drawCircle]
    Canvas --> DP2[drawPath]
    Painter --> |shouldRepaint| RP[Repaint Check]
    CP --> RB[RepaintBoundary]
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(1) دورة حياة CustomPainter

الطريقة متى تُطلق الغرض
paint() الرسم الأول أو عندما يُرجع shouldRepaint القيمة true تنفيذ أوامر الرسم
shouldRepaint() مقارنة Painter القديم بالجديد تحديد ما إذا كان يجب إعادة الرسم
repaint() إخبار CustomPaint بضرورة إعادة الرسم يعمل مع AnimationController

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: CustomPainter أساسي

DART
import 'package:flutter/material.dart';

class PriceIndicatorPainter extends CustomPainter {
  final double price;
  final double maxPrice;
  final Color color;

  PriceIndicatorPainter({
    required this.price,
    required this.maxPrice,
    this.color = Colors.green,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final fillPaint = Paint()
      ..color = color.withOpacity(0.2)
      ..style = PaintingStyle.fill;

    // Calculate bar height
    final barHeight = (price / maxPrice) * size.height;
    final rect = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);

    // Draw filled bar
    canvas.drawRect(rect, fillPaint);
    // Draw border
    canvas.drawRect(rect, paint);
    // Draw price text
    final textSpan = TextSpan(text: '\$${price.toStringAsFixed(0)}',
      style: TextStyle(color: color, fontSize: 12));
    final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
    tp.layout();
    tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, size.height - barHeight - 16));
  }

  @override
  bool shouldRepaint(covariant PriceIndicatorPainter old) =>
      old.price != price || old.maxPrice != maxPrice;
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. تعمق في Canvas API

(1) طرق الرسم الشائعة

الطريقة الوصف المعاملات
drawLine رسم خط p1, p2, paint
drawRect رسم مستطيل rect, paint
drawRRect رسم مستطيل دائري rrect, paint
drawCircle رسم دائرة center, radius, paint
drawOval رسم قطع ناقص rect, paint
drawArc رسم قوس rect, startAngle, sweepAngle, useCenter, paint
drawPath رسم مسار path, paint
drawShadow رسم ظل path, color, elevation, transparentOccluder
drawParagraph رسم نص paragraph, offset

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: رسم مخطط خطي متدرج

DART
import 'package:flutter/material.dart';

class LineChartPainter extends CustomPainter {
  final List<double> dataPoints;
  final Color lineColor;
  final double maxValue;

  LineChartPainter({
    required this.dataPoints,
    this.lineColor = Colors.blue,
    required this.maxValue,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (dataPoints.isEmpty) return;

    final linePaint = Paint()
      ..color = lineColor
      ..strokeWidth = 2.5
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    final dotPaint = Paint()
      ..color = lineColor
      ..style = PaintingStyle.fill;

    final gridPaint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 0.5;

    // Draw grid lines
    for (int i = 0; i <= 4; i++) {
      final y = size.height * i / 4;
      canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
    }

    // Calculate points
    final points = <Offset>[];
    for (int i = 0; i < dataPoints.length; i++) {
      final x = (i / (dataPoints.length - 1)) * size.width;
      final y = size.height - (dataPoints[i] / maxValue) * size.height;
      points.add(Offset(x, y));
    }

    // Draw gradient fill
    final fillPath = Path()
      ..moveTo(points.first.dx, size.height)
      ..lineTo(points.first.dx, points.first.dy);
    for (int i = 1; i < points.length; i++) {
      fillPath.lineTo(points[i].dx, points[i].dy);
    }
    fillPath.lineTo(points.last.dx, size.height);
    fillPath.close();

    final fillPaint = Paint()
      ..shader = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.05)],
      ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
    canvas.drawPath(fillPath, fillPaint);

    // Draw line
    final linePath = Path()..moveTo(points.first.dx, points.first.dy);
    for (int i = 1; i < points.length; i++) {
      linePath.lineTo(points[i].dx, points[i].dy);
    }
    canvas.drawPath(linePath, linePaint);

    // Draw dots
    for (final point in points) {
      canvas.drawCircle(point, 4, dotPaint);
    }
  }

  @override
  bool shouldRepaint(covariant LineChartPainter old) =>
      old.dataPoints != dataPoints || old.maxValue != maxValue;
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. تكامل الإيماءات: لوحة التوقيع

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: مكون SignaturePad

DART
import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class SignaturePad extends StatefulWidget {
  final ValueChanged<ui.Image>? onSignatureComplete;
  const SignaturePad({super.key, this.onSignatureComplete});

  @override
  State<SignaturePad> createState() => _SignaturePadState();
}

class _SignaturePadState extends State<SignaturePad> {
  final List<Offset?> _points = [];
  final _painterKey = GlobalKey();

  void _onPointerDown(PointerDownEvent event) {
    final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
    setState(() => _points.add(event.localPosition));
  }

  void _onPointerMove(PointerMoveEvent event) {
    final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
    setState(() => _points.add(event.localPosition));
  }

  void _onPointerUp(PointerUpEvent event) {
    setState(() => _points.add(null)); // null = line break
  }

  void _clear() => setState(() => _points.clear());

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: 200,
        decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Listener(
            onPointerDown: _onPointerDown,
            onPointerMove: _onPointerMove,
            onPointerUp: _onPointerUp,
            child: CustomPaint(key: _painterKey, size: Size.infinite, painter: SignaturePainter(_points)),
          ),
        ),
      ),
      const SizedBox(height: 8),
      Row(mainAxisAlignment: MainAxisAlignment.end, children: [
        TextButton(onPressed: _clear, child: const Text('Clear')),
        const SizedBox(width: 8),
        FilledButton(onPressed: () {
          // Capture signature as image
          widget.onSignatureComplete?.call(null); // Simplified
        }, child: const Text('Confirm')),
      ]),
    ]);
  }
}

class SignaturePainter extends CustomPainter {
  final List<Offset?> points;
  SignaturePainter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black
      ..strokeWidth = 2.0
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..style = PaintingStyle.stroke;

    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null) {
        canvas.drawLine(points[i]!, points[i + 1]!, paint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant SignaturePainter old) => old.points != points;
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. تحسين الأداء

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: عزل إعادة الرسم بـ RepaintBoundary

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - ProductHeader/ProductDescription: custom Widgets
// - LineChartPainter: see Section 4 in this lesson
// - salesData: List<double> type

// Without RepaintBoundary: entire page repaints on chart update
Column(children: [
  ProductHeader(product: product),
  RepaintBoundary(  // Only chart repaints
    child: CustomPaint(painter: LineChartPainter(data: salesData)),
  ),
  ProductDescription(product: product),  // NOT repainted
])
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
تقنية التحسين التأثير
فحص shouldRepaint الدقيق تجنب إعادة الرسم غير الضرورية
RepaintBoundary عزل نطاق إعادة الرسم
تخزين PictureRecorder مؤقت تخزين الرسومات المعقدة كـ Image
تجنب إنشاء كائنات في paint تقليل ضغط GC

7. مثال كامل: مكون مخطط مبيعات ShopApp

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - LineChartPainter: see Section 4 in this lesson
// - SalesData: see definition below

class SalesChart extends StatelessWidget {
  final List<SalesData> data;
  final double width;
  final double height;

  const SalesChart({super.key, required this.data, this.width = 350, this.height = 200});

  @override
  Widget build(BuildContext context) {
    final maxRevenue = data.map((d) => d.revenue).reduce((a, b) => a > b ? a : b);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('Monthly Revenue (USD)', style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 16),
          SizedBox(width: width, height: height,
            child: RepaintBoundary(child: CustomPaint(
              painter: LineChartPainter(
                dataPoints: data.map((d) => d.revenue).toList(),
                maxValue: maxRevenue * 1.1,
                lineColor: Theme.of(context).colorScheme.primary,
              ),
              size: Size(width, height),
            )),
          ),
          const SizedBox(height: 8),
          // Legend
          Wrap(spacing: 16, children: data.take(6).map((d) => Text(
            '${d.month}: \$${(d.revenue / 1000).toStringAsFixed(0)}K',
            style: const TextStyle(fontSize: 11),
          )).toList()),
        ]),
      ),
    );
  }
}

class SalesData {
  final String month;
  final double revenue;
  const SalesData({required this.month, required this.revenue});
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س متى يحتاج CustomPainter لإعادة الرسم؟
ج عندما تتغير خصائص Painter. يجب تنفيذ shouldRepaint بشكل صحيح: قارن الخصائص القديمة والجديدة، أرجع true إذا تغيرت.
س كيف يُحدد حجم CustomPaint؟
ج إذا كان لديه ويدجت فرعي، يتبع CustomPaint حجمه. بدون ويدجت فرعي، استخدم خاصية size أو قيده بـ SizedBox في الأب.
س كيف أرسم مستطيلًا دائريًا على Canvas؟
ج استخدم drawRRect(RRect.fromRectAndRadius(rect, radius), paint).
س كيف أحفظ لوحة التوقيع كصورة؟
ج استخدم ui.PictureRecorder() لتسجيل رسم Canvas، ثم endRecording().toImage() للتحويل إلى ui.Image.
س ما المشكلة في إرجاع true دائمًا في shouldRepaint؟
ج كل build يُطلق إعادة رسم، حتى عندما لا تتغير البيانات. مقبول للمكونات البسيطة، لكن يُسبب تلعثمًا في المخططات المعقدة.
س لماذا أستخدم drawParagraph بدلاً من Text في paint؟
ج Canvas لا يستطيع رسم الويدجتات مباشرة. يجب تخطيط النص عبر TextPainter/Paragraph ثم رسمه بـ paint(canvas, offset).

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): استخدم CustomPainter لرسم شريط تقدم دائري (0-100%) بتدرج لوني من الأخضر إلى الأحمر.
  2. متوسط (الصعوبة ⭐⭐): نفذ مخطط خطي للمبيعات مع خطوط شبكة، تعبئة متدرجة، علامات نقاط البيانات، وتلميح يعرض التفاصيل عند النقر على نقطة بيانات.
  3. متقدم (الصعوبة ⭐⭐⭐): نفذ مكون توقيع يدوي كامل: رسم باللمس + مسح + حفظ كصورة + عرض خط يتفاوت مع السرعة.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%