Flutter: الرسم المخصص وCustomPainter
عندما لا تكفي الويدجتات القياسية، تصبح اللوحة Canvas صفحتك البيضاء — أي واجهة يمكن رسمها ضربة بضربة.
📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ
- الدرس 15: نظام الحركة
1. ما ستتعلمه
- CustomPaint + CustomPainter: دورة حياة paint() / shouldRepaint()
- Canvas API: drawLine, drawRect, drawCircle, drawPath, drawParagraph
- تكامل الإيماءات: GestureDetector + CustomPainter للوحة توقيع
- تحسين الأداء: فحوصات shouldRepaint دقيقة + عزل RepaintBoundary
- ShopApp: مخطط خطي لبيانات المبيعات + مكون توقيع يدوي
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
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).
📖 ملخص
- CustomPainter + paint() يمنح تحكمًا كاملاً في الرسم؛ shouldRepaint يتحكم في إعادة الرسم
- Canvas API غني: خطوط/مستطيلات/دوائر/مسارات/نصوص/ظلال/تدرجات
- أحداث Listener/Pointer تُمكن الرسم القائم على الإيماءات (لوحة التوقيع)
- RepaintBoundary يعزل نطاق إعادة الرسم؛ فحوصات shouldRepaint الدقيقة تتجنب الهدر
- المخططات الخطية هي التطبيق الواقعي الأكثر نموذجية لـ CustomPainter
📝 تمارين
- أساسي (الصعوبة ⭐): استخدم CustomPainter لرسم شريط تقدم دائري (0-100%) بتدرج لوني من الأخضر إلى الأحمر.
- متوسط (الصعوبة ⭐⭐): نفذ مخطط خطي للمبيعات مع خطوط شبكة، تعبئة متدرجة، علامات نقاط البيانات، وتلميح يعرض التفاصيل عند النقر على نقطة بيانات.
- متقدم (الصعوبة ⭐⭐⭐): نفذ مكون توقيع يدوي كامل: رسم باللمس + مسح + حفظ كصورة + عرض خط يتفاوت مع السرعة.