العربي والـ RTL في Flutter: دليل عملي
يوسف محمد23 دقيقة قراءة
Flutter بيعمل إيه للعربي، وإيه اللي عليك أنت
أنا ببني تطبيقات Flutter لعملاء في مصر والخليج، وتقريبًا كلها بتنزل بالعربي والإنجليزي. الخبر الحلو إن Flutter بيدعم الاتجاه من اليمين للشمال كويس: أول ما اللغة تبقى عربي، الـ Row بيتقلب، والنص بيتحاذى يمين، وسهم الرجوع بيلف للناحية التانية، والـ Drawer بيفتح من اليمين. بس كل ده بيحصل مع الكود اللي اتكتب وهو عامل حساب الاتجاه.
أغلب مشاكل الـ RTL اللي بصلّحها في تطبيقات جاهزة جاية من كام عادة: مسافات مكتوبة بـ left و right، وأيقونات مرسومة يدوي، وأرقام تليفونات محطوطة جوه جمل عربي، وتواريخ متجمعة كنصوص. ولا واحدة فيهم بتبان وأنت بتجرب بالإنجليزي.
الدليل ده هو نفس الإعداد اللي بستخدمه في مشاريع حقيقية، من أول سطر في pubspec.yaml لحد الاختبارات اللي بتشتغل قبل كل إصدار. الكود مكتوب بـ Dart 3، وراجعت كل مثال على آخر إصدار stable من Flutter.
جهّز flutter_localizations و gen-l10n
ترجمة Flutter نفسه للزراير والـ date picker وقارئ الشاشة موجودة في flutter_localizations، وده جاي مع الـ SDK. أما النصوص بتاعتك فبتتكتب في ملفات ARB، وأداة gen-l10n بتحوّلها لكلاس Dart جاهز. ضيف الباكدجات بـ flutter pub add flutter_localizations --sdk=flutter و flutter pub add intl:any، وفعّل الـ generate، واعمل ملف l10n.yaml جنب pubspec.yaml.
الإعداد nullable-getter: false بيخلي AppLocalizations.of(context) مش null، فمش هتحتاج علامة تعجب في كل شاشة. و untranslated-messages-file بيكتب ملف JSON بالمفاتيح الناقصة في الملف العربي كل مرة الكود يتولّد، ودي أسرع طريقة تمسك بيها نص ناسيه قبل الإصدار.
الشروحات القديمة بتعمل import من package:flutter_gen/gen_l10n/app_localizations.dart، والباكدج ده اتشال خلاص. الملفات المتولّدة بقت جنب ملفات ARB بشكل افتراضي، وبتعملها import زي أي ملف في lib.
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
flutter:
generate: true
# l10n.yaml (project root)
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
untranslated-messages-file: untranslated.jsonملف ARB الإنجليزي هو الأساس
الملف الإنجليزي هو المرجع. فيه كل المفاتيح، ووصف لكل نص بيقول للمترجم النص ده بيظهر فين، ونوع كل placeholder. الجمع والمتغيرات بيتكتبوا بصيغة ICU جوه النص نفسه، فالمنطق ده بيفضل بره الـ widgets.
سمّي المفاتيح على حسب معناها مش على حسب الكلام الإنجليزي: مفتاح زي itemsInCart بيفضل منطقي حتى لو الصياغة اتغيرت.
{
"@@locale": "en",
"appTitle": "Orders",
"@appTitle": {
"description": "App name shown in the task switcher"
},
"welcome": "Welcome back, {name}",
"@welcome": {
"placeholders": {
"name": { "type": "String", "example": "Sara" }
}
},
"itemsInCart": "{count, plural, =0{Your cart is empty} =1{1 item in your cart} other{{count} items in your cart}}",
"@itemsInCart": {
"placeholders": {
"count": { "type": "int" }
}
}
}ضيف ملف ARB العربي
الملف العربي محتاج المفاتيح وترجمتها بس، والأنواع والوصف بييجوا من الملف الإنجليزي. العربي فيه ست حالات جمع (zero و one و two و few و many و other)، فادّي كل حالة صياغتها بدل ما تنقل تقسيمة الإنجليزي اللي فيها حالتين بس. اتكلمت عن الجمع بتفصيل أكتر في مقال لوحده عن الجمع العربي مع gen-l10n.
التوليد بيحصل لوحده مع flutter run و flutter build. ولو عايز الـ getters الجديدة تظهر في الـ editor على طول، شغّل flutter gen-l10n بنفسك.
{
"@@locale": "ar",
"appTitle": "الطلبات",
"welcome": "أهلًا بعودتك يا {name}",
"itemsInCart": "{count, plural, =0{السلة فارغة} =1{منتج واحد في السلة} =2{منتجان في السلة} few{{count} منتجات في السلة} many{{count} منتجًا في السلة} other{{count} منتج في السلة}}"
}اربط MaterialApp وخلّي الاتجاه يمشي ورا اللغة
القائمة AppLocalizations.localizationsDelegates فيها الـ delegate المتولّد بتاعك ومعاه GlobalMaterialLocalizations و GlobalCupertinoLocalizations و GlobalWidgetsLocalizations. الأخير ده هو اللي بيقول لـ Flutter إن العربي من اليمين للشمال، ولو نسيته في قايمة عاملها بنفسك هيطلعلك كلام عربي على تصميم من الشمال لليمين.
تحت MaterialApp فيه widget اسمه Localizations، وده بيلف التطبيق كله في Directionality مبني على اللغة الحالية. وأي widget ليه بداية ونهاية بيقرا القيمة دي بـ Directionality.of(context). في العربي، البداية (start) هي اليمين والنهاية (end) هي الشمال.
خد بالك من ترتيب supportedLocales. لو الموبايل على لغة أنت مش داعمها، زي الفرنساوي، Flutter بيرجع لأول عنصر في القايمة، والقايمة المتولّدة AppLocalizations.supportedLocales مترتبة أبجديًا فالعربي بييجي الأول. لو عايز الإنجليزي هو اللغة الاحتياطية، اكتب القايمة بنفسك والإنجليزي في أولها. والمطابقة بكود اللغة، فموبايل على عربي (مصر) أو عربي (السعودية) هيتحوّل لـ Locale('ar').
import 'package:flutter/material.dart';
import 'l10n/app_localizations.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
localizationsDelegates: AppLocalizations.localizationsDelegates,
// The first entry is the fallback for languages you don't support.
supportedLocales: const [Locale('en'), Locale('ar')],
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Center(child: Text(l10n.itemsInCart(3))),
);
}
}تغيير اللغة من جوه التطبيق وحفظ الاختيار
أغلب التطبيقات محتاجة اختيار لغة جوه التطبيق نفسه، مش بس لغة الموبايل. أبسط حل ينفع مع أي state management هو ChangeNotifier شايل اللغة المختارة، وبيحفظها بـ shared_preferences، وبيعيد بناء MaterialApp لما تتغير. وممكن تحط نفس القيمة في Bloc أو Riverpod أو Provider.
لو اللغة null يبقى التطبيق ماشي ورا لغة الموبايل، وده اختيار كويس لأول تشغيل. حمّل الاختيار المحفوظ قبل runApp، عشان أول frame يطلع باللغة والاتجاه الصح. وتغيير اللغة مش بيمسح الـ navigation stack ولا حالة الـ widgets، فالمستخدم بيفضل في شاشة الإعدادات والتطبيق بيتقلب حواليه.
اكتب اسم كل لغة بلغتها هي: English و العربية. كده اللي فتح التطبيق بلغة مش فاهمها يعرف يرجع.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'l10n/app_localizations.dart';
class LocaleController extends ChangeNotifier {
LocaleController._(this._prefs, this._locale);
static const _key = 'app_locale';
final SharedPreferencesAsync _prefs;
Locale? _locale;
/// null means: follow the phone's language.
Locale? get locale => _locale;
static Future<LocaleController> load() async {
final prefs = SharedPreferencesAsync();
final code = await prefs.getString(_key);
return LocaleController._(prefs, code == null ? null : Locale(code));
}
Future<void> setLocale(Locale? locale) async {
if (locale == _locale) return;
_locale = locale;
notifyListeners();
if (locale == null) {
await _prefs.remove(_key);
} else {
await _prefs.setString(_key, locale.languageCode);
}
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final localeController = await LocaleController.load();
runApp(App(localeController: localeController));
}
class App extends StatelessWidget {
const App({super.key, required this.localeController});
final LocaleController localeController;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: localeController,
builder: (context, _) => MaterialApp(
locale: localeController.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: const [Locale('en'), Locale('ar')],
home: HomeScreen(localeController: localeController),
),
);
}
}
class LanguageButton extends StatelessWidget {
const LanguageButton({super.key, required this.controller});
final LocaleController controller;
@override
Widget build(BuildContext context) {
final isArabic = Localizations.localeOf(context).languageCode == 'ar';
return TextButton(
onPressed: () => controller.setLocale(Locale(isArabic ? 'en' : 'ar')),
// Each language name is written in its own language.
child: Text(isArabic ? 'English' : 'العربية'),
);
}
}استخدم start و end بدل left و right
أي حاجة مكتوبة بـ left أو right بتفضل مكانها لما التطبيق يتحوّل عربي، ودي أكتر مشكلة RTL بلاقيها لما براجع كود Flutter. كل API ثابت ليه نسخة بتمشي مع الاتجاه:
- EdgeInsets.only(left: 16) تبقى EdgeInsetsDirectional.only(start: 16)، و EdgeInsets.fromLTRB تبقى EdgeInsetsDirectional.fromSTEB
- Alignment.centerLeft تبقى AlignmentDirectional.centerStart، وده كمان في الـ gradients وفي محاذاة الـ Stack
- Positioned(left: 0) تبقى PositionedDirectional(start: 0)
- BorderRadius.only(topLeft: ...) تبقى BorderRadiusDirectional.only(topStart: ...)
- Border(left: ...) تبقى BorderDirectional(start: ...)
- TextAlign.left تبقى TextAlign.start، ودي أصلًا القيمة الافتراضية، فغالبًا تقدر تمسحها خالص
- الـ Row و Wrap و ListTile و CrossAxisAlignment.start في الـ Column بيمشوا مع الاتجاه لوحدهم، فسيبهم زي ما هم
class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
margin: const EdgeInsetsDirectional.only(start: 16, end: 8, top: 8),
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 8, 12),
decoration: const BoxDecoration(
borderRadius: BorderRadiusDirectional.only(
topStart: Radius.circular(16),
bottomStart: Radius.circular(16),
),
gradient: LinearGradient(
begin: AlignmentDirectional.centerStart,
end: AlignmentDirectional.centerEnd,
colors: [Color(0xFFE3F2FD), Color(0xFFF5F5F5)],
),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, textAlign: TextAlign.start),
const SizedBox(height: 4),
Text(subtitle, textAlign: TextAlign.start),
],
),
),
const Icon(Icons.chevron_right), // mirrors on its own
],
),
),
const PositionedDirectional(
top: 0,
end: 0,
child: CircleAvatar(radius: 6, backgroundColor: Color(0xFFE53935)),
),
],
);
}
}إيه اللي بيتقلب لوحده، والأيقونات اللي لازم تقلبها
الـ Row والـ ListView الأفقي والـ PageView والـ TabBar بيعكسوا ترتيبهم في العربي. الـ drawer في الـ Scaffold بيفتح من اليمين والـ endDrawer من الشمال. والـ Slider والـ Dismissible وزرار الرجوع بيمشوا مع الاتجاه برضه. وأيقونات Material اللي بتشاور على اتجاه، زي arrow_back و arrow_forward و chevron_left و chevron_right و send و reply و undo و redo، متعرّفة بـ matchTextDirection: true وبتتقلب من غير ما تعمل حاجة.
الصور مش بتتقلب إلا لو طلبت. حط matchTextDirection: true على Image.asset أو Image.network لأي صورة ليها اتجاه، زي سهم مرسوم أو إيد بتشاور للسحب. الأيقونات اللي من خط أيقونات خاص بيك محتاجة matchTextDirection: true على الـ IconData. وأي حاجة تانية، زي animation أو شكل مبني من widgets، حطها جوه Transform.flip وقرر بـ Directionality.of(context).
فيه حاجات ماينفعش تتقلب: زرار التشغيل، والساعة، واللوجو، وعلامة الصح بيفضلوا زي ما هم في العربي. وكمان سهم مكتوب كنص زي → بيفضل يشاور يمين، لأن Unicode مش بيعكسه، فاستخدم أيقونة لأي سهم معناه قدام أو رجوع.
// An icon from your own icon font that should mirror.
const IconData appForwardIcon = IconData(
0xe901,
fontFamily: 'AppIcons',
matchTextDirection: true,
);
/// Flips any child horizontally when the layout is right to left.
class MirrorInRtl extends StatelessWidget {
const MirrorInRtl({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Transform.flip(flipX: isRtl, child: child);
}
}
class NextStepHint extends StatelessWidget {
const NextStepHint({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.arrow_forward), // Material: mirrors on its own
const Icon(appForwardIcon), // mirrors because of matchTextDirection
Image.asset('assets/images/swipe_hand.png', matchTextDirection: true),
const MirrorInRtl(child: SwipeAnimation()), // your own widget
const Icon(Icons.play_arrow), // media icon: does not mirror
],
);
}
}نص عربي وإنجليزي في نفس السطر
جوه الـ Text الواحد، Flutter بيشغّل خوارزمية الاتجاه الثنائي بتاعة Unicode. وهي بتتعامل كويس مع كلمة إنجليزي عادية جوه جملة عربي، لكن بتتلخبط مع حاجة فيها أرقام ومسافات ورموز. رقم تليفون زي +20 100 000 0000 جوه نص عربي كتير بيطلع ومجموعات الأرقام بالعكس، وعلامة + في الناحية الغلط.
الحل هو علامات العزل في Unicode. العلامة U+2066 بتبدأ جزء من الشمال لليمين، و U+2067 جزء من اليمين للشمال، و U+2068 جزء بياخد اتجاهه من أول حرف واضح فيه، و U+2069 بتقفل أي واحد فيهم. اعزل القيمة قبل ما تبعتها للنص المترجم، عشان ملفات ARB تفضل نضيفة.
ولو القيمة في سطر لوحدها، زي إيميل أو كود خصم أو IBAN، حط textDirection: TextDirection.ltr على الـ Text، أو لف جزء أكبر في Directionality خاص بيه. أما النصوص اللي بيكتبها المستخدمين، زي رسايل الشات أو التقييمات، فالدالة Bidi.startsWithRtl من intl بتبص على أول حرف واضح، زي dir=auto في الويب.
اعمل import لـ intl ببادئة. الباكدج ده فيه كلاس TextDirection خاص بيه، والـ import من غير بادئة بيبوّظ كل TextDirection.ltr في الملف بأخطاء ملهاش معنى.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
/// Keeps a left-to-right value (phone number, order code, English name)
/// in one piece inside right-to-left text.
String isolateLtr(String text) => '\u2066$text\u2069';
/// The same for an Arabic value inside English text.
String isolateRtl(String text) => '\u2067$text\u2069';
class ContactLine extends StatelessWidget {
const ContactLine({super.key, required this.phone, required this.note});
final String phone; // '+20 100 000 0000'
final String note; // typed by a customer, any language
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Without the isolate, the digit groups come out in reverse order.
Text('اتصل بنا على ${isolateLtr(phone)}'),
// A value that is always left to right.
const Text('[email protected]', textDirection: TextDirection.ltr),
// Follow the first strong letter, like dir="auto" on the web.
Text(
note,
textDirection: intl.Bidi.startsWithRtl(note)
? TextDirection.rtl
: TextDirection.ltr,
),
],
);
}
}الأرقام والتليفونات والتواريخ والعملة
استخدم intl لأي حاجة المستخدم بيقراها كرقم أو تاريخ أو مبلغ، وابعت اللغة الحالية من Localizations.localeOf(context). التاريخ اللي بتجمعه بإيدك بيطلع بترتيب غلط وأسماء شهور غلط في العربي.
حدد شكل الأرقام من بدري. فيه عملاء عايزين الأرقام الهندية (٠١٢٣) وفيه اللي عايز الأرقام الإنجليزي العادية (0123)، وده قرار يخص المنتج. والمشكلة إن intl مش متفق مع نفسه جوه تطبيق Flutter: DateFormat للعربي بيستخدم بيانات التواريخ اللي جاية مع flutter_localizations وبيطبع أرقام هندية، و NumberFormat للعربي بيطبع أرقام عادية. نادي DateFormat.useNativeDigitsByDefaultFor('ar', false) في البداية لو عايز أرقام عادية في كل حتة، أو استخدم ar_EG مع الأرقام لو عايز الهندية في كل حتة.
وفي الفلوس، استخدم NumberFormat.currency وابعت كود العملة والرمز الاتنين. لأن NumberFormat.simpleCurrency مع لغة عربي ومن غير اسم عملة بيرجع لرمز الجنيه المصري، وده غلط لمتجر سعودي أو إماراتي. والمبلغ المتنسق بيبدأ بعلامة اتجاه مخفية (U+200F): سيبها في العرض، وماتقارنش نصوص متنسقة في الـ logic أبدًا.
خزّن أرقام التليفونات بالصيغة الدولية، واعرضها جوه عزل من الشمال لليمين زي الجزء اللي فات.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
void main() {
// Flutter's Arabic date data prints ٠١٢٣, NumberFormat('ar') prints 0123.
// Pick one style for the whole app. Here: Western digits everywhere.
intl.DateFormat.useNativeDigitsByDefaultFor('ar', false);
runApp(const App());
}
class OrderSummary extends StatelessWidget {
const OrderSummary({super.key, required this.total, required this.placedAt});
final double total;
final DateTime placedAt;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toString(); // 'ar' or 'en'
final isArabic = locale.startsWith('ar');
final date = intl.DateFormat.yMMMMd(locale).format(placedAt);
final time = intl.DateFormat.jm(locale).format(placedAt);
final amount = intl.NumberFormat.currency(
locale: locale,
name: 'SAR',
symbol: isArabic ? 'ر.س' : 'SAR ',
decimalDigits: 2,
).format(total);
// ar: 20 أكتوبر 2026 · 2:05 م · 1,250.50 ر.س
// en: October 20, 2026 · 2:05 PM · SAR 1,250.50
return Text('$date · $time · $amount');
}
}الخطوط العربي وارتفاع السطر و letterSpacing
لو مانزّلتش خط مع التطبيق، Flutter هيستخدم خط العربي بتاع النظام، وشكله مختلف بين Android و iOS. عشان الشكل يبقى ثابت، حط خط عربي رخصته تسمح باستخدامه في تطبيقات، وعرّفه تحت flutter: fonts: في pubspec.yaml. ولو مافيهوش حروف لاتيني، خلّي خط لاتيني هو الـ fontFamily وحط العربي في fontFamilyFallback، أو بدّل الخط حسب اللغة زي الكود اللي تحت.
الحروف العربي فيها نقط وتشكيل فوق وتحت السطر، فنفس حجم الخط محتاج مساحة رأسية أكتر من الإنجليزي. ابعد عن الارتفاعات الثابتة للصناديق اللي فيها نص، وادّي العربي ارتفاع سطر أكبر شوية، وجرّب بكلمة متشكلة زي تَجْرِبَة لو المحتوى بتاعك فيه تشكيل. Flutter أصلًا بيبدّل مقاسات النص في Material للمجموعة الطويلة (tall) مع العربي، لكن الخط اللي بتضيفه هو اللي بيحدد المساحة اللي محتاجها فعلًا.
ماتحطش letterSpacing على العربي أبدًا: الحروف متوصّلة ببعض، وأي مسافة زيادة بتفتح فراغات بينها. والـ styles الافتراضية في Material 3 للـ body والـ label فيها letterSpacing صغير موجب بيتطبق على العربي برضه، فأنا بصفّره في الـ theme العربي. وكمان العربي مافيهوش حروف كابيتال، فالتصميم اللي معتمد على uppercase محتاج طريقة تانية للتمييز، غالبًا التخانة أو الحجم.
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: const [Locale('en'), Locale('ar')],
theme: ThemeData(fontFamily: 'Inter'),
builder: (context, child) {
final theme = Theme.of(context);
final isArabic = Localizations.localeOf(context).languageCode == 'ar';
return Theme(
data: isArabic
? theme.copyWith(
textTheme: theme.textTheme.apply(
fontFamily: 'NotoSansArabic',
letterSpacingFactor: 0, // no tracking between joined letters
heightFactor: 1.1, // room for dots and diacritics
),
)
: theme,
child: child!,
);
},
home: const HomeScreen(),
);
}
}حقول الإدخال في واجهة عربي
الـ TextField بياخد اتجاهه من الـ Directionality اللي حواليه، فالمؤشر في العربي بيبدأ من اليمين والنص بيتحاذى يمين. ده اللي أنت عايزه للأسامي والعناوين والرسايل، والكتابة المخلوطة زي اسم شارع عربي مع اسم برج إنجليزي بتظهر صح برضه.
الإيميل والروابط وكلمات السر وأرقام التليفونات والأكواد بتتقري من الشمال لليمين، فحط textDirection: TextDirection.ltr على الحقول دي. وبعدها حط hintTextDirection على الـ InputDecoration بنفس القيمة، وإلا الـ hint هيفضل ماشي مع اتجاه الشاشة ويقعد في الناحية العكس من النص اللي المستخدم بيكتبه. أما الـ label وأيقونات الـ prefix والـ suffix فبيفضلوا ماشيين مع الشاشة، وده الصح.
اختار الـ keyboardType المناسب: TextInputType.emailAddress أو TextInputType.phone أو TextInputType.number أو TextInputType.numberWithOptions(decimal: true). وعلى حسب الموبايل والكيبورد، اللي بيستخدم العربي ممكن يكتب أرقام هندية. و FilteringTextInputFormatter.digitsOnly بيسمح بـ 0 لـ 9 بس، فالأرقام دي بتختفي من غير أي رسالة، و int.tryParse بيرجّع null معاها. حوّلها الأول، قبل ما أي فلتر أو parser يشوف النص.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Turns Arabic-Indic (٠-٩) and Persian (۰-۹) digits into 0-9.
String toWesternDigits(String input) {
final buffer = StringBuffer();
for (final rune in input.runes) {
if (rune >= 0x0660 && rune <= 0x0669) {
buffer.writeCharCode(rune - 0x0660 + 0x30);
} else if (rune >= 0x06F0 && rune <= 0x06F9) {
buffer.writeCharCode(rune - 0x06F0 + 0x30);
} else {
buffer.writeCharCode(rune);
}
}
return buffer.toString();
}
class WesternDigitsFormatter extends TextInputFormatter {
const WesternDigitsFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// Same length as before, so the cursor position stays valid.
return newValue.copyWith(text: toWesternDigits(newValue.text));
}
}
class PhoneField extends StatelessWidget {
const PhoneField({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return TextField(
keyboardType: TextInputType.phone,
textDirection: TextDirection.ltr,
inputFormatters: [
const WesternDigitsFormatter(),
FilteringTextInputFormatter.allow(RegExp(r'[0-9+ ]')),
],
decoration: InputDecoration(
labelText: label,
hintText: '+20 100 000 0000',
hintTextDirection: TextDirection.ltr,
),
);
}
}الانتقال بين الصفحات والسحب بالصباع
الـ routes الجاهزة في Flutter بتتعامل مع الاتجاه لوحدها. مع انتقال Cupertino، الصفحة الجديدة بتدخل من الشمال في العربي، وسحبة الرجوع بتبدأ من الحافة اليمين. والـ PageView والـ TabBarView والقوايم الأفقية بيتعكسوا لوحدهم، فماتحطش reverse: true عشان العربي، وإلا هتقلبهم مرتين.
الـ animations والإيماءات اللي بتكتبها بنفسك محتاجة نفس الاهتمام. الـ Offset دايمًا بمقاييس الشاشة، فـ Offset(1, 0) معناها من اليمين في أي لغة. الـ SlideTransition بياخد textDirection وبيعكس قيمة x بداله. وفي الـ GestureDetector، السرعة الأفقية الموجبة معناها دايمًا سحب ناحية اليمين، فحدد يعني إيه التالي من الـ Directionality قبل ما تتصرف.
الـ Dismissible بيسمي اتجاهاته startToEnd و endToStart، وهم ماشيين مع اتجاه القراءة. فالأكشن اللي بيظهر مع السحب للشمال في الإنجليزي بيظهر مع السحب لليمين في العربي، وده غالبًا اللي أنت عايزه. بس اتأكد إن ألوان الخلفية والأيقونات مظبوطة على كل ناحية.
/// A page that slides in from the end side: the right in English,
/// the left in Arabic.
Route<T> slideFromEnd<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final position = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation);
return SlideTransition(
position: position,
textDirection: Directionality.of(context), // flips x in RTL
child: child,
);
},
);
}
/// Swipe toward the start side for "next", whatever the language.
class SwipeNavigator extends StatelessWidget {
const SwipeNavigator({
super.key,
required this.onNext,
required this.onPrevious,
required this.child,
});
final VoidCallback onNext;
final VoidCallback onPrevious;
final Widget child;
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return GestureDetector(
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 300) return;
// Positive velocity is a drag to the right on screen.
final towardStart = isRtl ? velocity > 0 : velocity < 0;
towardStart ? onNext() : onPrevious();
},
child: child,
);
}
}الـ CustomPainter والرسم على الـ canvas
الـ canvas مايعرفش حاجة عن اتجاه النص. النقطة (0, 0) هي الركن الشمال اللي فوق في كل اللغات، فأي progress bar أو chart أو مؤشر خطوات بترسمه بنفسك هيطلع زي ما هو في العربي إلا لو اتعاملت معاه. ابعت الاتجاه للـ painter من Directionality.of(context)، واستخدمه وأنت بتحسب مكان x، وحطه في shouldRepaint عشان تغيير اللغة يعيد الرسم.
ولو الرسمة فيها أشكال كتير، أسهل إنك تعكس الـ canvas كله: canvas.save() وبعدها canvas.translate(size.width, 0) و canvas.scale(-1, 1)، ارسم الأشكال، وبعدين canvas.restore(). ارسم النص بعد الـ restore، مش جوه الجزء المعكوس، وإلا هيطلع زي صورته في المراية. والـ TextPainter أصلًا محتاج textDirection، فادّيله نفس القيمة اللي بعتها للـ painter.
class ProgressBarPainter extends CustomPainter {
ProgressBarPainter({
required this.progress,
required this.color,
required this.textDirection,
});
final double progress;
final Color color;
final TextDirection textDirection;
@override
void paint(Canvas canvas, Size size) {
final radius = Radius.circular(size.height / 2);
final track = Paint()..color = color.withValues(alpha: 0.2);
final fill = Paint()..color = color;
canvas.drawRRect(RRect.fromRectAndRadius(Offset.zero & size, radius), track);
// The canvas has no direction: x = 0 is always the left edge.
final width = size.width * progress.clamp(0.0, 1.0);
final left = textDirection == TextDirection.rtl ? size.width - width : 0.0;
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(left, 0, width, size.height), radius),
fill,
);
}
@override
bool shouldRepaint(ProgressBarPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.textDirection != textDirection;
}
class UploadProgress extends StatelessWidget {
const UploadProgress({super.key, required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 8,
width: double.infinity,
child: CustomPaint(
painter: ProgressBarPainter(
progress: progress,
color: Theme.of(context).colorScheme.primary,
textDirection: Directionality.of(context),
),
),
);
}
}اختبر الاتجاهين
أنا بشغّل الشاشات المهمة باللغتين في widget tests. الـ ValueVariant بيشغّل نفس الاختبار مرة لكل لغة، ولما تعمل pump لنفس إعداد الترجمة الحقيقي واللغة ar، بتاخد نفس الـ Directionality اللي التطبيق بيستخدمه. وأخطاء الـ overflow بتفشّل الاختبار لوحدها، ودي بتمسك النص العربي اللي أطول أو أعلى من الإنجليزي.
الـ golden tests بتحوّل التصميم لصورة تقدر تراجعها. اعمل الصور المرجعية بـ flutter test --update-goldens، وبص عليها، واعملها commit. بشكل افتراضي بيئة الاختبار بترسم كل حرف وكل أيقونة كمربع، فالـ goldens بتوريك التصميم والاتجاه مش الحروف العربي الحقيقية. ولو محتاج تشوف الحروف، حمّل الخط بتاعك بـ FontLoader في إعداد الاختبار.
عشان تختبر اللي بيحصل لما الموبايل نفسه يبقى عربي، استخدم tester.platformDispatcher.localesTestValue وامسحها بـ addTearDown(tester.platformDispatcher.clearLocalesTestValue). بس الاختبارات مش بديل عن الجهاز الحقيقي: قبل كل إصدار ببص بنفسي على موبايل Android و iPhone لغتهم عربي من إعدادات النظام.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/widgets/order_card.dart';
Widget localized(Widget child, Locale locale) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
);
}
void main() {
final locales = ValueVariant<Locale>({const Locale('en'), const Locale('ar')});
testWidgets('OrderCard in both directions', (tester) async {
final locale = locales.currentValue!;
await tester.pumpWidget(
localized(const OrderCard(title: 'Order 1042', subtitle: 'On the way'), locale),
);
await tester.pumpAndSettle();
final title = tester.getCenter(find.text('Order 1042'));
final chevron = tester.getCenter(find.byIcon(Icons.chevron_right));
if (locale.languageCode == 'ar') {
expect(chevron.dx, lessThan(title.dx)); // the end side is the left
} else {
expect(chevron.dx, greaterThan(title.dx));
}
await expectLater(
find.byType(OrderCard),
matchesGoldenFile('goldens/order_card_${locale.languageCode}.png'),
);
}, variant: locales);
}قايمة المراجعة قبل الإصدار، وتعمل إيه بعد كده
- الـ GlobalWidgetsLocalizations موجود في الـ delegates، واللغة الاحتياطية في أول supportedLocales
- مافيش padding أو alignment أو Positioned أو زوايا أو TextAlign مكتوبين بـ left و right، إلا لو الناحية ثابتة فعلًا
- الأسهم والـ progress bars بتشاور صح في العربي، وزرار التشغيل واللوجو والساعة مش بيتقلبوا
- أرقام التليفونات والإيميلات والأكواد والأسعار بتتقري صح جوه الجمل العربي
- التواريخ والأرقام والعملة بتعدي على intl، وشكل الأرقام واحد في كل الشاشات
- مافيش letterSpacing على النص العربي، ومافيش نقط أو تشكيل مقطوع في الزراير والـ chips والـ app bars
- حقول الإيميل وكلمة السر والتليفون من الشمال لليمين والـ hint ماشي معاها، والأرقام الهندية مقبولة
- الانتقالات والسحب والـ widgets المرسومة يدوي اتراجعوا في العربي
- اختيار اللغة بيفضل محفوظ بعد ما التطبيق يتقفل، وتغييرها بيسيب المستخدم في نفس الشاشة
- الـ widget tests والـ goldens بتعدي في اللغتين، والملف untranslated.json فاضي
- اتراجع بالإيد على موبايل Android و iPhone حقيقيين لغتهم عربي
لو بتبدأ تطبيق جديد، جهّز gen-l10n والـ widgets اللي بتمشي مع الاتجاه من أول يوم، لأن إضافة الـ RTL بعدين معناها إنك تلف على كل شاشة. ولو عندك تطبيق شغال وشكله غلط في العربي، ابدأ بالـ delegates، ودوّر في الكود على left و right و centerLeft و TextAlign.left، وبعدين امشي على القايمة دي شاشة شاشة.
ولو محتاج مساعدة في برمجة تطبيق Flutter بلغتين، أو تصليح النسخة العربي من تطبيق موجود عندك، تقدر تكلمني من الموقع هنا. بشتغل مع عملاء في مصر والسعودية والإمارات وأوروبا.
أسئلة شائعة
- إزاي أخلّي تطبيق Flutter من اليمين للشمال؟
- ضيف flutter_localizations، وحط العربي في supportedLocales، وخلّي GlobalWidgetsLocalizations موجود في الـ delegates. لما اللغة الحالية تبقى عربي، Flutter بيلف التطبيق في Directionality من اليمين للشمال وبيعكس الـ rows والقوايم وأيقونات Material والتنقل. وبعدها الكود بتاعك محتاج start و end بدل left و right عشان يمشي معاه.
- ليه تطبيق Flutter مش بيتقلب RTL لما أختار العربي؟
- غالبًا GlobalWidgetsLocalizations.delegate ناقص، أو العربي مش موجود في supportedLocales فـ Flutter بيرجع للغة تانية. اطبع Localizations.localeOf(context) و Directionality.of(context) عشان تعرف التطبيق شغال بإيه فعلًا. ولو الاتنين صح وفيه أجزاء لسه من الشمال لليمين، يبقى الـ widgets دي فيها left و right ثابتين.
- إزاي أغيّر لغة تطبيق Flutter من غير ما أعيد تشغيله؟
- خلّي الـ Locale المختار في ChangeNotifier أو ValueNotifier أو أي state management بتستخدمه، وابعته لـ locale في MaterialApp جوه builder بيسمع للتغيير. أول ما القيمة تتغير، التطبيق بيتبني تاني باللغة والاتجاه الجديد على طول، والمستخدم بيفضل في نفس الشاشة. احفظ الاختيار بـ shared_preferences وحمّله قبل runApp.
- إزاي أعرض الأرقام الهندية زي ٠١٢٣ في Flutter؟
- نسّق الأرقام بـ NumberFormat من intl مع لغة بتستخدمها، زي ar_EG. وخد بالك إن DateFormat للعربي جوه تطبيق Flutter بيطبع أرقام هندية أصلًا، و NumberFormat للعربي بيطبع أرقام عادية، فاختار شكل واحد وطبّقه في كل حتة. ولو اخترت الأرقام العادية، DateFormat.useNativeDigitsByDefaultFor بيحوّل التواريخ ليها.
- ليه رقم التليفون بيظهر بالعكس جوه النص العربي؟
- جوه نص من اليمين للشمال، خوارزمية Unicode بتتعامل مع كل مجموعة أرقام كجزء لوحده، فالمجموعات ممكن تطلع بترتيب معكوس. اعزل الرقم في جزء من الشمال لليمين، بـ U+2066 قبله و U+2069 بعده، أو اعرضه في Text لوحده عليه textDirection: ltr.
- أستخدم Directionality ولا اللغة عشان أخلّي التطبيق RTL؟
- استخدم اللغة للتطبيق كله، لأنها بتظبط الاتجاه والترجمة وتنسيق التواريخ والأرقام مع بعض. واستخدم Directionality بس لما تحتاج تجبر جزء من الشاشة على اتجاه معين، زي حقل إيميل أو كود. لو حطيت Directionality من اليمين للشمال فوق لغة إنجليزي، هتاخد تصميم معكوس بنصوص وتنسيقات إنجليزي.
- أستخدم أنهي خط عربي في Flutter؟
- أي خط عربي معمول كويس، فيه الأوزان اللي محتاجها، ورخصته تسمح بالاستخدام في تطبيق. نزّله مع التطبيق عشان الشكل يبقى واحد على Android و iOS، وضيف معاه خط لاتيني لو مافيهوش حروف لاتيني، وجرّبه في أحجام صغيرة بكلمات طويلة ومتشكلة.