محلی‌سازی و پشتیبانی RTL در .NET MAUI: راهنمای کامل فارسی‌سازی با تقویم شمسی

از resx و IStringLocalizer تا FlowDirection برای RTL، تقویم شمسی با PersianCalendar، فونت وزیرمتن، تبدیل اعداد و آینه‌سازی آیکون‌ها — راهنمای کامل و عملی فارسی‌سازی اپ .NET MAUI 9 با کد قابل کپی برای اندروید و iOS.

فارسی‌سازی .NET MAUI 9: RTL و تقویم شمسی ۲۰۲۶

اگر تا حالا یک اپ .NET MAUI نوشته باشید و بعد خواسته باشید فارسی‌اش کنید، احتمالاً ظرف چند ساعت اول متوجه شدید که قضیه فقط ترجمه‌ی چند رشته نیست. متن باید از راست به چپ بچرخد، اعداد باید (در نمایش) به فارسی شوند، تاریخ باید شمسی باشد، فلش بازگشت باید آینه شود و فونت پیش‌فرض هم — راستش را بخواهید — حروف فارسی را به آن قشنگی که انتظار دارید رندر نمی‌کند.

تجربه‌ی شخصی‌ام: اولین اپی که برای یک فروشگاه ایرانی نوشتم، ظاهرش فارسی بود اما حس بومی نمی‌داد. یک کاربر در نظرات نوشت: «انگار با Google Translate ساخته شده.» همان شب نشستم و این شش لایه را یاد گرفتم. در ادامه همان مسیر را با کد قابل کپی برای .NET MAUI 9 می‌رویم.

چرا فارسی‌سازی فقط ترجمه نیست؟

یک اپ واقعاً فارسی، چند ویژگی را هم‌زمان دارد:

  • محلی‌سازی (Localization): ترجمه‌ی رشته‌ها از طریق فایل‌های منبع.
  • جهت‌دهی (RTL): چینش لایه‌ها از راست به چپ با FlowDirection.
  • فرهنگ (Culture): فرمت‌گذاری اعداد، تاریخ و ارز با CultureInfo.
  • تقویم شمسی: نمایش و دریافت تاریخ بر اساس PersianCalendar.
  • فونت: فونت‌هایی مثل وزیرمتن یا IRANSans که حروف فارسی را درست رندر کنند.
  • آینه‌سازی آیکون‌ها: آیکون‌های جهت‌دار (مثل فلش بازگشت) در RTL برعکس شوند.

اگر هر کدام از این‌ها را نادیده بگیرید، اپ شما فارسی به نظر می‌رسد ولی حس بومی نمی‌دهد. خب، بریم سراغ پیاده‌سازی نهایی که هر شش لایه را پوشش می‌دهد.

گام اول: پیکربندی پروژه برای چندزبانگی

اول، فایل .csproj را به‌روزرسانی کنید تا فرهنگ‌های مورد نظر پشتیبانی شوند:

<PropertyGroup>
  <TargetFrameworks>net9.0-android;net9.0-ios</TargetFrameworks>
  <NeutralLanguage>en-US</NeutralLanguage>
</PropertyGroup>

<ItemGroup>
  <EmbeddedResource Update="Resources\Strings\AppResources.resx">
    <Generator>PublicResXFileCodeGenerator</Generator>
    <LastGenOutput>AppResources.Designer.cs</LastGenOutput>
  </EmbeddedResource>
</ItemGroup>

سپس در پوشه‌ی Resources/Strings سه فایل بسازید:

  • AppResources.resx — منبع پیش‌فرض (انگلیسی)
  • AppResources.fa.resx — ترجمه‌ی فارسی
  • AppResources.fa-IR.resx — تخصصی برای ایران در صورت نیاز

نمونه AppResources.fa.resx

<data name="WelcomeTitle" xml:space="preserve">
  <value>به اپلیکیشن خوش آمدید</value>
</data>
<data name="LoginButton" xml:space="preserve">
  <value>ورود</value>
</data>
<data name="EmailPlaceholder" xml:space="preserve">
  <value>ایمیل خود را وارد کنید</value>
</data>
<data name="OrderCount" xml:space="preserve">
  <value>شما {0} سفارش فعال دارید</value>
</data>

گام دوم: سرویس LocalizationService

برای تغییر زبان در زمان اجرا — یعنی بدون ری‌استارت کامل اپ — به یک سرویس مرکزی نیاز داریم که INotifyPropertyChanged را پیاده‌سازی کند تا UI هم به تغییر زبان واکنش نشان دهد:

public class LocalizationService : INotifyPropertyChanged
{
    private static readonly Lazy<LocalizationService> _instance = new(() => new LocalizationService());
    public static LocalizationService Instance => _instance.Value;

    public event PropertyChangedEventHandler? PropertyChanged;

    public string this[string key] =>
        AppResources.ResourceManager.GetString(key, AppResources.Culture) ?? key;

    public void SetCulture(string cultureCode)
    {
        var culture = new CultureInfo(cultureCode);
        Thread.CurrentThread.CurrentCulture = culture;
        Thread.CurrentThread.CurrentUICulture = culture;
        CultureInfo.DefaultThreadCurrentCulture = culture;
        CultureInfo.DefaultThreadCurrentUICulture = culture;
        AppResources.Culture = culture;

        Preferences.Set("AppLanguage", cultureCode);
        UpdateFlowDirection(cultureCode);

        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
    }

    private void UpdateFlowDirection(string cultureCode)
    {
        var isRtl = cultureCode.StartsWith("fa") || cultureCode.StartsWith("ar") || cultureCode.StartsWith("he");
        if (Application.Current?.Windows.Count > 0)
        {
            Application.Current.Windows[0].FlowDirection = isRtl
                ? FlowDirection.RightToLeft
                : FlowDirection.LeftToRight;
        }
    }
}

یک نکته‌ی کوچک: آن دو خط PropertyChanged پایانی شبیه یک ترفند کثیف به‌نظر می‌رسند، اما در واقع روش رسمی برای رفرش همه‌ی Bindingهای ایندکسی هستند. بدون آن‌ها، تغییر زبان فقط روی متن‌های جدید کار می‌کند.

گام سوم: استفاده در XAML با Markup Extension

برای راحتی، یک MarkupExtension سفارشی می‌سازیم تا در XAML از {loc:Translate Key=WelcomeTitle} استفاده کنیم:

[ContentProperty(nameof(Key))]
public class TranslateExtension : IMarkupExtension<BindingBase>
{
    public string Key { get; set; } = string.Empty;

    public BindingBase ProvideValue(IServiceProvider serviceProvider)
    {
        return new Binding
        {
            Mode = BindingMode.OneWay,
            Path = $"[{Key}]",
            Source = LocalizationService.Instance
        };
    }

    object IMarkupExtension.ProvideValue(IServiceProvider serviceProvider) => ProvideValue(serviceProvider);
}

سپس در XAML:

<ContentPage xmlns:loc="clr-namespace:MyApp.Localization">
  <VerticalStackLayout Padding="20" Spacing="12">
    <Label Text="{loc:Translate WelcomeTitle}" FontSize="22" />
    <Entry Placeholder="{loc:Translate EmailPlaceholder}" />
    <Button Text="{loc:Translate LoginButton}" Clicked="OnLoginClicked" />
  </VerticalStackLayout>
</ContentPage>

چون مقدار از طریق Binding خوانده می‌شود، با هر بار SetCulture همه‌ی متن‌ها لحظه‌ای آپدیت می‌شوند — بدون ری‌استارت اپ. (اولین باری که این را دیدم واقعاً کیف کردم.)

گام چهارم: پشتیبانی RTL در سطح اپلیکیشن

در .NET MAUI، چینش راست‌به‌چپ از طریق ویژگی FlowDirection کنترل می‌شود. سه گزینه دارید:

  • FlowDirection.MatchParent: ارث‌بری از والد (پیش‌فرض اکثر کنترل‌ها).
  • FlowDirection.LeftToRight: اجبار LTR.
  • FlowDirection.RightToLeft: اجبار RTL.

بهترین کار، تنظیم آن روی Window یا Shell اپ است. این‌طوری همه‌ی صفحات پایین‌دستی هم RTL می‌شوند:

// App.xaml.cs
protected override Window CreateWindow(IActivationState? activationState)
{
    var window = base.CreateWindow(activationState);
    var lang = Preferences.Get("AppLanguage", "en-US");
    window.FlowDirection = lang.StartsWith("fa") ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
    return window;
}

نکته‌ی مهم درباره Padding و Margin

وقتی FlowDirection روی RTL تنظیم می‌شود، MAUI به‌صورت خودکار مقدار Padding="10,0,30,0" را آینه می‌کند — یعنی ۱۰ به سمت راست و ۳۰ به سمت چپ می‌رود. اگر می‌خواهید این رفتار اتفاق نیفتد (مثلاً برای آیکون‌هایی که همیشه باید LTR بمانند)، فقط آن کنترل خاص را با FlowDirection="LeftToRight" مقیدش کنید. همین.

گام پنجم: پیکربندی پلتفرم‌ها

اندروید — AndroidManifest.xml

<application
    android:supportsRtl="true"
    android:label="@string/app_name">
</application>

همچنین در فایل Platforms/Android/Resources/values/strings.xml و values-fa/strings.xml نام اپ را به فارسی ترجمه کنید تا روی آیکون لانچر هم به فارسی نمایش داده شود (این یک جزئیات کوچک است که حس بومی بودن را خیلی بالا می‌برد).

iOS — Info.plist

<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleLocalizations</key>
<array>
  <string>en</string>
  <string>fa</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>

گام ششم: ثبت فونت فارسی

صادقانه بگویم، فونت پیش‌فرض سیستم برای فارسی روی Android و حتی iOS قدیمی‌تر نتیجه‌ی خوبی نمی‌دهد. وزیرمتن (Vazirmatn) یا IRANSans را داخل پوشه‌ی Resources/Fonts قرار دهید و در MauiProgram.cs ثبتشان کنید:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("Vazirmatn-Regular.ttf", "VazirRegular");
            fonts.AddFont("Vazirmatn-Bold.ttf", "VazirBold");
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        });

    return builder.Build();
}

سپس در Resources/Styles/Styles.xaml فونت پیش‌فرض را به‌صورت شرطی تنظیم کنید — یا اگر اپ‌تان فقط فارسی است، آن را به‌طور سراسری به وزیر تغییر دهید:

<Style TargetType="Label">
  <Setter Property="FontFamily" Value="VazirRegular" />
</Style>
<Style TargetType="Entry">
  <Setter Property="FontFamily" Value="VazirRegular" />
</Style>
<Style TargetType="Button">
  <Setter Property="FontFamily" Value="VazirBold" />
</Style>

گام هفتم: تقویم شمسی و فرمت تاریخ

کلاس PersianCalendar از System.Globalization سال‌هاست در .NET موجود است (از .NET Framework 1.0 — جدی!). کافی است یک CultureInfo اختصاصی بسازید که از این تقویم استفاده کند:

public static class PersianCultureHelper
{
    public static CultureInfo CreatePersianCulture()
    {
        var culture = (CultureInfo)CultureInfo.GetCultureInfo("fa-IR").Clone();
        culture.DateTimeFormat.Calendar = new PersianCalendar();
        culture.DateTimeFormat.ShortDatePattern = "yyyy/MM/dd";
        culture.DateTimeFormat.LongDatePattern = "dddd، d MMMM yyyy";
        return culture;
    }

    public static string ToPersianDate(this DateTime date)
    {
        var pc = new PersianCalendar();
        return $"{pc.GetYear(date):D4}/{pc.GetMonth(date):D2}/{pc.GetDayOfMonth(date):D2}";
    }

    public static string ToPersianLongDate(this DateTime date)
    {
        var months = new[] {
            "فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور",
            "مهر","آبان","آذر","دی","بهمن","اسفند"
        };
        var pc = new PersianCalendar();
        return $"{pc.GetDayOfMonth(date)} {months[pc.GetMonth(date) - 1]} {pc.GetYear(date)}";
    }
}

یک Converter ساده برای استفاده در XAML:

public class PersianDateConverter : IValueConverter
{
    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is DateTime dt)
        {
            return parameter?.ToString() == "long"
                ? dt.ToPersianLongDate()
                : dt.ToPersianDate();
        }
        return string.Empty;
    }

    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
        => throw new NotImplementedException();
}
<Label Text="{Binding OrderDate, Converter={StaticResource PersianDateConverter}, ConverterParameter=long}" />
<!-- خروجی: 19 اردیبهشت 1405 -->

گام هشتم: تبدیل اعداد لاتین به فارسی

کاربران ایرانی معمولاً انتظار دارند اعداد در رابط کاربری به‌صورت ۰-۹ فارسی نمایش داده شوند. این مورد هم یکی از همان جزئیات کوچکی است که حس بومی بودن را تضمین می‌کند:

public static class PersianNumber
{
    private static readonly char[] PersianDigits = { '۰','۱','۲','۳','۴','۵','۶','۷','۸','۹' };

    public static string ToPersianDigits(this string input)
    {
        if (string.IsNullOrEmpty(input)) return input;
        var sb = new StringBuilder(input.Length);
        foreach (var c in input)
            sb.Append(c is >= '0' and <= '9' ? PersianDigits[c - '0'] : c);
        return sb.ToString();
    }

    public static string ToPersianDigits(this int number) => number.ToString().ToPersianDigits();
    public static string ToPersianDigits(this long number) => number.ToString().ToPersianDigits();
}

برای قیمت‌ها هم می‌توانید از فرهنگ fa-IR به همراه جداکننده‌ی هزار استفاده کنید:

var price = 1250000m;
var formatted = price.ToString("N0", new CultureInfo("fa-IR")).ToPersianDigits() + " تومان";
// خروجی: ۱٬۲۵۰٬۰۰۰ تومان

گام نهم: آینه‌سازی آیکون‌های جهت‌دار

آیکون فلش بازگشت، فلش‌های Pagination و کاروسل، باید در RTL آینه شوند. اما لوگو، تصاویر محصول و عکس‌های واقعی نباید آینه شوند. (اشتباه رایجی که بارها دیده‌ام: کل پوشه‌ی Images را در RTL آینه می‌کنند و بعد لوگو هم برعکس می‌شود.)

راه‌حل: تنظیم صریح روی هر Image:

<!-- آینه شود -->
<Image Source="back_arrow.png" FlowDirection="MatchParent" />

<!-- آینه نشود (لوگو) -->
<Image Source="logo.png" FlowDirection="LeftToRight" />

برای آیکون‌های مبتنی بر فونت (FontImageSource) مثل MaterialIcons، یک Behavior ساده اضافه کنید که در RTL یک ScaleX="-1" اعمال کند. ساده، اما کار راه‌انداز.

گام دهم: تغییر زبان زنده در صفحه‌ی تنظیمات

public partial class SettingsViewModel : ObservableObject
{
    [ObservableProperty]
    private string selectedLanguage = "fa-IR";

    public List<LanguageOption> Languages { get; } = new()
    {
        new("English", "en-US"),
        new("فارسی", "fa-IR"),
        new("العربية", "ar-SA")
    };

    [RelayCommand]
    private void ChangeLanguage(string code)
    {
        SelectedLanguage = code;
        LocalizationService.Instance.SetCulture(code);
    }
}

public record LanguageOption(string Display, string Code);

تله‌های رایج و راه‌حل آن‌ها

۱. متن مختلط فارسی و انگلیسی به‌هم می‌ریزد

وقتی در یک Label هم متن فارسی و هم URL یا کلمه‌ی انگلیسی دارید، ترتیب بصری به‌هم می‌ریزد. راه‌حل: کلمه‌ی انگلیسی را با کاراکترهای جهت‌دهی Unicode محصور کنید:

const string LRM = "‎"; // Left-to-Right Mark
var text = $"وب‌سایت ما {LRM}example.com{LRM} است";

۲. Entry اعداد فارسی را قبول نمی‌کند

وقتی Keyboard="Numeric" روی iOS فعال است، کیبورد ارقام لاتین می‌دهد. اگر می‌خواهید کاربر اعداد فارسی هم وارد کند، در ViewModel هنگام Validation اعداد فارسی (و حتی عربی) را به لاتین تبدیل کنید:

public static string ToLatinDigits(this string s)
{
    return new string(s.Select(c => c switch
    {
        >= '۰' and <= '۹' => (char)(c - '۰' + '0'),
        >= '٠' and <= '٩' => (char)(c - '٠' + '0'),
        _ => c
    }).ToArray());
}

۳. CollectionView در RTL کند می‌شود

این یک باگ قدیمی بود. در نسخه‌های ابتدایی MAUI گزارش شده بود و در .NET MAUI 9 برطرف شده، اما اگر هنوز با آن مواجه شدید، ItemsLayout را به‌جای HorizontalGrid روی VerticalGrid بگذارید و فقط FlowDirection را تغییر دهید.

۴. DatePicker تاریخ میلادی نشان می‌دهد

کنترل بومی DatePicker ویندوز، اندروید و iOS از PersianCalendar پشتیبانی نمی‌کند — متأسفانه. راه‌حل: یا یک کنترل سفارشی بنویسید، یا از کتابخانه‌هایی مانند Plugin.Maui.PersianDatePicker استفاده کنید. تجربه‌ی شخصی من؟ ساخت یک Popup سفارشی با سه Picker (سال/ماه/روز) معمولاً پایدارتر و کم‌دردسرتر است.

چک‌لیست انتشار اپ فارسی

  1. همه‌ی رشته‌های UI به AppResources.fa.resx منتقل شده‌اند (هیچ رشته‌ی hardcoded در XAML باقی نمانده).
  2. FlowDirection روی Window یا Shell به‌درستی RTL می‌شود.
  3. فونت Vazirmatn یا IRANSans ثبت و به‌عنوان پیش‌فرض اعمال شده.
  4. تاریخ‌ها با تقویم شمسی نمایش داده می‌شوند.
  5. اعداد در نمایش به فارسی، در ورودی به لاتین تبدیل می‌شوند.
  6. آیکون‌های جهت‌دار آینه می‌شوند، آیکون‌های ثابت نمی‌شوند.
  7. نام اپ روی لانچر اندروید (values-fa) به فارسی است.
  8. CFBundleLocalizations در Info.plist شامل fa است.
  9. تست شده روی Android API 21+ و iOS 15+.

سؤالات متداول (FAQ)

آیا .NET MAUI 9 از RTL به‌صورت کامل پشتیبانی می‌کند؟

بله. در نسخه‌ی ۹ بسیاری از باگ‌های قدیمی Xamarin.Forms برطرف شده‌اند، به‌ویژه در CollectionView، Shell و TabbedPage. هنوز چند مورد لبه‌ای وجود دارد (مثل Animation و Translation) که باید به‌صورت دستی برای RTL تنظیمشان کنید.

چطور بدون ری‌استارت کردن اپ زبان را عوض کنم؟

با استفاده از یک LocalizationService مبتنی بر INotifyPropertyChanged به‌همراه یک TranslateExtension که به‌جای مقدار ثابت، یک Binding برمی‌گرداند. وقتی SetCulture فراخوانی می‌شود، Bindingها خودکار رفرش می‌شوند. تنها چیزی که نیاز به ری‌استارت دارد، عنوان Window روی iOS است.

آیا تقویم شمسی در .NET بومی است یا به کتابخانه نیاز دارد؟

کاملاً بومی است. کلاس System.Globalization.PersianCalendar از .NET Framework 1.0 موجود است و در .NET 9 هم بدون نیاز به پکیج اضافه قابل استفاده است.

بهترین فونت رایگان برای اپ موبایل فارسی چیست؟

وزیرمتن (Vazirmatn) با لایسنس Open Font License گزینه‌ی استاندارد است؛ حروف کشیده، اعداد متناسب و وزن‌های متعدد دارد. IRANSans هم پرطرفدار است اما لایسنس آن برای استفاده‌ی تجاری محدودیت دارد. پس برای پروژه‌های تجاری، Vazirmatn انتخاب امن‌تری است.

چطور تست کنم که UI من واقعاً RTL است؟

سه روش: (۱) در تنظیمات اندروید/iOS زبان را به فارسی یا عربی تغییر دهید. (۲) در توسعه، Pseudo-localization را روی فرهنگ qps-ploc فعال کنید تا متن‌های طولانی شبیه‌سازی شوند. (۳) در Developer Options اندروید گزینه‌ی «Force RTL layout direction» را روشن کنید — این روش هر چینش غلط را به‌سرعت آشکار می‌کند. شخصاً همیشه از روش سوم شروع می‌کنم.

جمع‌بندی

فارسی‌سازی یک اپ .NET MAUI شامل شش لایه است: محلی‌سازی رشته‌ها، RTL، فرهنگ، تقویم شمسی، فونت و آینه‌سازی آیکون‌ها. اگر این شش لایه را از روز اول جدی بگیرید، اپ شما حس بومی می‌دهد و نرخ نگه‌داشت کاربران ایرانی و عرب‌زبان به‌طور قابل توجهی بالاتر می‌رود. کد کامل این راهنما بر بستر .NET MAUI 9 تست شده و آماده‌ی تولید است — موفق باشید!

درباره نویسنده Editorial Team

Our team of expert writers and editors.