📜 ⬆️ ⬇️

Localization of WPF applications and instant culture change

There are various ways to localize a WPF application. The simplest and most common option is to use the Resx resource file and the Designer class automatically generated for them. But this method does not allow changing the values ​​"on the fly" when changing the language. To do this, you must open the window again, or restart the application.
In this article, I will show a localization option for a WPF application with an instant change of culture.

Formulation of the problem


Denote the tasks that need to be solved:
  1. Ability to use different providers of localized strings (resources, database, etc.);
  2. The ability to specify a key for localization not only through a string, but also through a binding;
  3. The ability to specify arguments (including argument bindings), if the localized value is a formatted string;
  4. Instant update of all localized objects when changing culture.

Implementation


To enable the use of different localization providers, create the ILocalizationProvider interface:

public interface ILocalizationProvider { object Localize(string key); IEnumerable<CultureInfo> Cultures { get; } } 

The interface has a method that directly performs localization by key and a list of available cultures for a given implementation.
The ResxLocalizationProvider implementation of this interface for resources will look like this:

 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"), }); } 

We will also create an auxiliary class, LocalizationManager , through which all the manipulations with the culture and the current instance of the localized strings provider will occur:
')
 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}]"; } } 

Also this class will notify about a culture change through the CultureChanged event.
The implementation of ILocalizationProvider can be specified in App.xaml.cs in the OnStartup method:

 LocalizationManager.Instance.LocalizationProvider = new ResxLocalizationProvider(); 

Consider how the localized objects are updated after a culture change.
The simplest option is to use binding. After all, if in the binding in the UpdateSourceTrigger property you specify the value “PropertyChanged” and raise the PropertyChanged event of the INotifyPropertyChanged interface, then the binding expression will be updated. The source of data (Source) for binding will be the listener of the culture change 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; } } 

Since the localized value is in the Value property, the Path property of the binding must also have the value “Value”.

But what if the key value is not constant and not known in advance? Then the key can be obtained only through the binding. In this case, we will be helped by a multi-binding ( MultiBinding ), which accepts a list of bindings, among which will be a binding for a key. Using such a binding is also convenient for passing arguments, in case the localized object is a formatted string. To update the value, call the UpdateTarget method of the Multi-binding MultiBindingExpression object. This MultiBindingExpression object is passed to the BindingLocalizationListener listener:

 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; } } 

In this case, the multi-binding should have a converter that converts the key (and arguments) to a localized value. The source code of such a BindingLocalizationConverter converter is :

 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(); } } 

To use localization in XAML, we write a markup extension (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   Setter if ((target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty) || target.TargetObject is Setter) { 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; } } 

Note that when using multi-bindings, we also create a binding for the BindingLocalizationListener listener and put it in the multi-binding Bindings. This is done so that the garbage collector does not remove the listener from memory. That is why in the BindingLocalizationConverter converter, the zero element [0] is ignored.
Also note that when using the key, we can only use the binding if the destination object is a DependencyProperty property of the DependencyObject object. UPDATE: such a binding can also be used in styles, so the target can be a Setter.
If the current LocalizationExtension instance is the source (Source) of the binding (and the binding is not a DependencyObject), then you do not need to create a new binding. Therefore, we simply assign the Path and UpdateSourceTrigger bindings and return the KeyLocalizationListener listener.

The following are options for using the LocalizationExtension extension in XAML.
Key localization:
 <TextBlock Text="{l:Localization Key=SomeKey}" /> 
or
 <TextBlock Text="{l:Localization SomeKey}" /> 

Localization by binding:
 <TextBlock Text="{l:Localization KeyBinding={Binding SomeProperty}}" /> 
There are many scenarios for using localization by binding. For example, if it is necessary to display localized values ​​of some enumeration (Enum) in the drop-down list.

Localization using static arguments:
 <TextBlock> <TextBlock.Text> <l:Localization Key="SomeKey" Arguments="{StaticResource SomeArray}" /> </TextBlock.Text> </TextBlock> 

Localization using argument bindings:
 <TextBlock> <TextBlock.Text> <l:Localization Key="SomeKey"> <Binding Source="{l:Localization SomeKey2}" /> <Binding Path="SomeProperty" /> </l:Localization> </TextBlock.Text> </TextBlock> 
This localization option is useful when displaying validation messages (for example, a message about the minimum length of the input field).

Project sources can be downloaded from GitHub .

Source: https://habr.com/ru/post/274477/


All Articles