Существуют разные способы локализации WPF-приложения. Самый простой и распространенный вариант — использование файла ресурсов Resx и автоматически сгенерированный к ним Designer-класс. Но этот способ не позволяет менять значения «на лету» при смене языка. Для этого необходимо открыть окно повторно, либо перезапустить приложение.
В этой статье я покажу вариант локализации WPF-приложения с мгновенной сменой культуры.
Постановка задачи
Обозначим задачи, которые должны быть решены:
- Возможность использования различных поставщиков локализованных строк (ресурсы, база данных и т.п.);
- Возможность указания ключа для локализации не только через строку, но и через привязку;
- Возможность указания аргументов (в том числе привязки аргументов), в случае если локализованное значение является форматируемой строкой;
- Мгновенное обновление всех локализованных объектов при смене культуры.
Реализация
Для осуществления возможности использования различных поставщиков локализации создадим интерфейс ILocalizationProvider:
public interface ILocalizationProvider
{
object Localize(string key);
IEnumerable<CultureInfo> Cultures { get; }
}
Интерфейс имеет метод, осуществляющий непосредственно локализацию по ключу и список доступных культур для данной реализации.
Реализация ResxLocalizationProvider этого интерфейса для ресурсов будет иметь следующий вид:
public class ResxLocalizationProvider : ILocalizationProvider
{
private IEnumerable<CultureInfo> _cultures;
public object Localize(string key)
{
return Strings.ResourceManager.GetObject(key);
}
public IEnumerable<CultureInfo> Cultures => _cultures ?? (_cultures = new List<CultureInfo>
{
new CultureInfo("ru-RU"),
new CultureInfo("en-US"),
});
}
Также создадим вспомогательный класс-одиночку LocalizationManager, через который будут происходить все манипуляции с культурой и текущим экземпляром поставщика локализованных строк:
public class LocalizationManager
{
private LocalizationManager()
{
}
private static LocalizationManager _localizationManager;
public static LocalizationManager Instance => _localizationManager ?? (_localizationManager = new LocalizationManager());
public event EventHandler CultureChanged;
public CultureInfo CurrentCulture
{
get { return Thread.CurrentThread.CurrentCulture; }
set
{
if (Equals(value, Thread.CurrentThread.CurrentUICulture))
return;
Thread.CurrentThread.CurrentCulture = value;
Thread.CurrentThread.CurrentUICulture = value;
CultureInfo.DefaultThreadCurrentCulture = value;
CultureInfo.DefaultThreadCurrentUICulture = value;
OnCultureChanged();
}
}
public IEnumerable<CultureInfo> Cultures => LocalizationProvider?.Cultures ?? Enumerable.Empty<CultureInfo>();
public ILocalizationProvider LocalizationProvider { get; set; }
private void OnCultureChanged()
{
CultureChanged?.Invoke(this, EventArgs.Empty);
}
public object Localize(string key)
{
if (string.IsNullOrEmpty(key))
return "[NULL]";
var localizedValue = LocalizationProvider?.Localize(key);
return localizedValue ?? $"[{key}]";
}
}
Также этот класс будет оповещать об изменении культуры через событие CultureChanged.
Реализацию ILocalizationProvider можно указать в App.xaml.cs в методе OnStartup:
LocalizationManager.Instance.LocalizationProvider = new ResxLocalizationProvider();
Рассмотрим, каким образом происходит обновление локализованных объектов после смены культуры.
Простейшим вариантом является использование привязки (Binding). Ведь если в привязке в свойстве UpdateSourceTrigger указать значение «PropertyChanged» и вызвать событие PropertyChanged интерфейса INotifyPropertyChanged, то и выражение привязки обновится. Источником данных (Source) для привязки послужит слушатель изменения культуры KeyLocalizationListener:
public class KeyLocalizationListener : INotifyPropertyChanged
{
public KeyLocalizationListener(string key, object[] args)
{
Key = key;
Args = args;
LocalizationManager.Instance.CultureChanged += OnCultureChanged;
}
private string Key { get; }
private object[] Args { get; }
public object Value
{
get
{
var value = LocalizationManager.Instance.Localize(Key);
if (value is string && Args != null)
value = string.Format((string)value, Args);
return value;
}
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnCultureChanged(object sender, EventArgs eventArgs)
{
// Уведомляем привязку об изменении строки
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
}
~KeyLocalizationListener()
{
LocalizationManager.Instance.CultureChanged -= OnCultureChanged;
}
}
Так как локализованное значение находится в свойстве Value, то и свойство Path привязки должно иметь значение «Value».
Но что если значение ключа не является постоянной величиной и заранее не известна? Тогда ключ можно получить только через привязку. В этом случае нам поможет мульти-привязка (MultiBinding), которая принимает список привязок, среди которых будет привязка для ключа. Использование такой привязки также удобно для передачи аргументов, в случае если локализованный объект является форматируемой строкой. Для обновления значения нужно вызвать метод UpdateTarget объекта типа MultiBindingExpression мульти-привязки. Этот объект MultiBindingExpression передается в слушателя BindingLocalizationListener:
public class BindingLocalizationListener
{
private BindingExpressionBase BindingExpression { get; set; }
public BindingLocalizationListener()
{
LocalizationManager.Instance.CultureChanged += OnCultureChanged;
}
public void SetBinding(BindingExpressionBase bindingExpression)
{
BindingExpression = bindingExpression;
}
private void OnCultureChanged(object sender, EventArgs eventArgs)
{
try
{
// Обновляем результат выражения привязки
// При этом конвертер вызывается повторно уже для новой культуры
BindingExpression?.UpdateTarget();
}
catch
{
// ignored
}
}
~BindingLocalizationListener()
{
LocalizationManager.Instance.CultureChanged -= OnCultureChanged;
}
}
Мульти-привязка при этом должна иметь конвертер, преобразующий ключ (и аргументы) в локализованное значение. Исходный код такого конвертера BindingLocalizationConverter:
public class BindingLocalizationConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values == null || values.Length < 2)
return null;
var key = System.Convert.ToString(values[1] ?? "");
var value = LocalizationManager.Instance.Localize(key);
if (value is string)
{
var args = (parameter as IEnumerable<object> ?? values.Skip(2)).ToArray();
if (args.Length == 1 && !(args[0] is string) && args[0] is IEnumerable)
args = ((IEnumerable) args[0]).Cast<object>().ToArray();
if (args.Any())
return string.Format(value.ToString(), args);
}
return value;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
Для использования локализации в XAML напишем расширение разметки (MarkupExtension) LocalizationExtension:
[ContentProperty(nameof(ArgumentBindings))]
public class LocalizationExtension : MarkupExtension
{
private Collection<BindingBase> _arguments;
public LocalizationExtension()
{
}
public LocalizationExtension(string key)
{
Key = key;
}
/// <summary>
/// Ключ локализованной строки
/// </summary>
public string Key { get; set; }
/// <summary>
/// Привязка для ключа локализованной строки
/// </summary>
public Binding KeyBinding { get; set; }
/// <summary>
/// Аргументы форматируемой локализованный строки
/// </summary>
public IEnumerable<object> Arguments { get; set; }
/// <summary>
/// Привязки аргументов форматируемой локализованный строки
/// </summary>
public Collection<BindingBase> ArgumentBindings
{
get { return _arguments ?? (_arguments = new Collection<BindingBase>()); }
set { _arguments = value; }
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
if (Key != null && KeyBinding != null)
throw new ArgumentException($"Нельзя одновременно задать {nameof(Key)} и {nameof(KeyBinding)}");
if (Key == null && KeyBinding == null)
throw new ArgumentException($"Необходимо задать {nameof(Key)} или {nameof(KeyBinding)}");
if (Arguments != null && ArgumentBindings.Any())
throw new ArgumentException($"Нельзя одновременно задать {nameof(Arguments)} и {nameof(ArgumentBindings)}");
var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));
if (target.TargetObject.GetType().FullName == "System.Windows.SharedDp")
return this;
// Если заданы привязка ключа или список привязок аргументов,
// то используем BindingLocalizationListener
if (KeyBinding != null || ArgumentBindings.Any())
{
var listener = new BindingLocalizationListener();
// Создаем привязку для слушателя
var listenerBinding = new Binding { Source = listener };
var keyBinding = KeyBinding ?? new Binding { Source = Key };
var multiBinding = new MultiBinding
{
Converter = new BindingLocalizationConverter(),
ConverterParameter = Arguments,
Bindings = { listenerBinding, keyBinding }
};
// Добавляем все переданные привязки аргументов
foreach (var binding in ArgumentBindings)
multiBinding.Bindings.Add(binding);
var value = multiBinding.ProvideValue(serviceProvider);
// Сохраняем выражение привязки в слушателе
listener.SetBinding(value as BindingExpressionBase);
return value;
}
// Если задан ключ, то используем KeyLocalizationListener
if (!string.IsNullOrEmpty(Key))
{
var listener = new KeyLocalizationListener(Key, Arguments?.ToArray());
// Если локализация навешана на DependencyProperty объекта DependencyObject
if (target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty)
{
var binding = new Binding(nameof(KeyLocalizationListener.Value))
{
Source = listener,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
return binding.ProvideValue(serviceProvider);
}
// Если локализация навешана на Binding, то возвращаем слушателя
var targetBinding = target.TargetObject as Binding;
if (targetBinding != null && target.TargetProperty != null &&
target.TargetProperty.GetType().FullName == "System.Reflection.RuntimePropertyInfo" &&
target.TargetProperty.ToString() == "System.Object Source")
{
targetBinding.Path = new PropertyPath(nameof(KeyLocalizationListener.Value));
targetBinding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
return listener;
}
// Иначе возвращаем локализованную строку
return listener.Value;
}
return null;
}
}
Обратите внимание, что при использовании мульти-привязки мы также создаем привязку для слушателя BindingLocalizationListener и кладем ее в Bindings мульти-привязки. Это сделано для того, чтобы сборщик мусора не удалил слушателя из памяти. Именно поэтому в конвертере BindingLocalizationConverter нулевой элемент values[0] игнорируется.
Также обратите внимание, что, при использовании ключа, привязку мы можем использовать только если объект назначения является свойством DependencyProperty объекта DependencyObject. В случае, если текущий экземпляр LocalizationExtension является источником (Source) привязки (а привязка не является объектом DependencyObject), то создавать новую привязку не нужно. Поэтому просто назначаем привязке Path и UpdateSourceTrigger и возвращаем слушателя KeyLocalizationListener.
Ниже приводятся варианты использования расширения LocalizationExtension в XAML.
Локализация по ключу:
<TextBlock Text="{l:Localization Key=SomeKey}" />
или
<TextBlock Text="{l:Localization SomeKey}" />
Локализация по привязке:
<TextBlock Text="{l:Localization KeyBinding={Binding SomeProperty}}" />
Есть множество сценариев использования локализации по привязке. Например, если необходимо в выпадающем списке вывести локализованные значения некоторого перечисления (Enum).
Локализация с использованием статических аргументов:
<TextBlock>
<TextBlock.Text>
<l:Localization Key="SomeKey" Arguments="{StaticResource SomeArray}" />
</TextBlock.Text>
</TextBlock>
Локализация с использованием привязок аргументов:
<TextBlock>
<TextBlock.Text>
<l:Localization Key="SomeKey">
<Binding Source="{l:Localization SomeKey2}" />
<Binding Path="SomeProperty" />
</l:Localization>
</TextBlock.Text>
</TextBlock>
Такой вариант локализации удобно использовать при выводе сообщений валидации (например, сообщение о минимальной длине поля ввода).
Исходники проекта можно взять на GitHub.
Автор: adeptuss