WPF 多國語系 -- 使用 CSV 檔案 續集

  • 304
  • 0
  • 2022-08-08

前篇 WPF 多國語系 -- 使用 CSV 檔案 之後,有個朋友提了一個進階的問題:「這樣的方式如何應用在 ComboBox 搭配 ObjectDataProvider + Enum 型別?」。我認真想了一下,還是有解的,只是手續多了點。

解題重點

ObjectDataProvider 有一個 DataChanged 事件 (繼承自 DataSourceProvider )用來通知資料內容改變 (類似 INotifyPropertyChanged.PropertyChanged 事件的作用 ),當 Combobox 繫結到 ObjectDataProvider 時會藉由發布 DataChanged 事件來通知 Combobox 重新繫結來源。

然而,我們要如何引發這個 DataChanged 事件?一種當然是 ObjectDataPrivider 的來源真的改變了, 如果來源沒有改,只是語系改變的話,那就要藉由 Refresh 方法  (繼承自 DataSourceProvider ) 強迫引發。

根據以上的線索,我們要進行的事情就是當語系改變的時候,能夠通知 ObjectDataProvider 呼叫自己的 Refresh 方法,就能夠達成需求。

語系改變通知

沿用前一篇的程式碼,先來實作語系改變通知,因為不想改變原來的 LanguageProcess,所以就多套一層類別來處理這件事情好了,為此設計了一個 LanguageProxy 的類別 (只是名字叫 Proxy,跟代理者模式關係不大):

    public class LanguageProxy
    {
        public event EventHandler<LanguageChangedEventArgs> LanguageChanged;

        public LanguageProxy()
        {
            _currentLanguage = LanguageCode.Default;
            SetCurrentLanguage();
        }

        private LanguageCode _currentLanguage;

        public LanguageCode CurrentLanguage
        {
            get => _currentLanguage;
            set
            {
                if (_currentLanguage != value)
                {
                    _currentLanguage = value;
                    SetCurrentLanguage();
                    LanguageChanged?.Invoke(this, new LanguageChangedEventArgs(value));
                }
            }
        }

        private void SetCurrentLanguage()
        {
            var field = typeof(LanguageCode).GetField(CurrentLanguage.ToString());
            if (field != null)
            {
                var attribute = field.GetCustomAttribute<DescriptionAttribute>();
                if (attribute != null)
                {
                    LanguageProcess.SetLanguage(attribute.Description);
                }
            }
        }
    }
    
    public enum LanguageCode
    {
        [Description("Default.txt")]
        Default,
        [Description("zh-tw.txt")]
        zhtw
    }

    public class LanguageChangedEventArgs : EventArgs
    {
        public LanguageChangedEventArgs(LanguageCode languageCode)
        {
            LanguageCode = languageCode;
        }
        public LanguageCode LanguageCode { get; private set; }
    }
    

這邊加上了一些新的東西,在 LanguageCode enum 中的欄位加上了 DescriptionAttribute,利用這個 Attribute 來標示語系對應的檔案。外部的呼叫者只要改變 LanguageProxy.CurreentLanguage 屬性就可以改變語系,同時會引發 LanguageChanged 事件。

在 App 類別也做了一些改變,加上了一個新的 interface,藉以彰顯這個 App Class 具有改變語系的能力:

    public partial class App : Application, IMultilanguage
    {
        public LanguageProxy LanguageProxy { get; private set; }

        public App()
        {
            LanguageProxy = new LanguageProxy();
            this.Resources.MergedDictionaries.Add(LanguageProcess.Resources);
        }
    }

    public interface IMultilanguage
    {
        LanguageProxy LanguageProxy { get; }
    }
讓 ObjectDataProvider 可以接收語系改變通知

因為我們的方式是將 ObjectDataProvider 直接設定在 Window.Resources 裡,在 MVVM 的狀況下如果要能夠掛上 LanguageProxy.LanguageChanged 事件的其中一種方式就是把 ObjectDataProvider 傳進 ViewModel 裡面,但是我覺得這樣的作法有點無趣,而且一旦這種形式的 ObjectDataProvider 很多的時候就顯得有點麻煩。

所以我改採另外一種作法,乾脆讓 ObjectDataProvider 自己就會掛上 LanguageProxy.LanguageChanged 事件,因此就繼承 ObjectDataProvider 做一個新的吧。

    public class MultilanguageObjectDataProvider : ObjectDataProvider
    {
        public MultilanguageObjectDataProvider()
        {
            var app = Application.Current as IMultilanguage;
            if (app != null)
            {
                EventHandler<LanguageChangedEventArgs> handler = new EventHandler<LanguageChangedEventArgs>(LanguageProxy_LanguageChanged);
                WeakEventManager<LanguageProxy, LanguageChangedEventArgs>.AddHandler(app.LanguageProxy, "LanguageChanged", handler);
            }
        }    

        private void LanguageProxy_LanguageChanged(object sender, LanguageChangedEventArgs e)
        {
            this.Refresh();
        }
    }

在這程式碼用上了 WeakEventManager 來避免 memory leak 的問題,這樣就不用擔心這個執行個體會一直掛在 LanguageProxy 身上。

列舉的多國語做法

Enum 欄位的多國語會透過 TypeConverter 來達成,先繼承 EnumConverter 做一個多國語列舉轉換器:

    public class GenderTypeConverter : EnumConverter
    {
        public GenderTypeConverter(Type type) : base(type)
        {
        }

        public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
        {
            if (value != null)
            {
                return Application.Current.Resources[value.ToString()];
            }
            else
            { return string.Empty; }
        }
    }

將這個轉換器套用到列舉型別上

    [TypeConverter(typeof(GenderTypeConverter))]
    public enum Gender
    {        
        Male,       
        Female
    }
測試成果

這一篇我就不用事件模型,而是用大家習慣的 MVVM 來示範,先建立一個 MainWindow 和對應的 MainViewModel  – 因為我實在懶得再寫上層的抽象類別,所以直接實作介面了。

<Window x:Class="WpfMultilanguageByCsvSample2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfMultilanguageByCsvSample2"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext >
        <local:MainViewModel />
    </Window.DataContext>
    <Window.Resources >
        <local:MultilanguageObjectDataProvider x:Key="genderEnum" MethodName="GetValues"
                            ObjectType="{x:Type sys:Enum}">
            <ObjectDataProvider.MethodParameters>
                <x:Type TypeName="local:Gender"/>
            </ObjectDataProvider.MethodParameters>
        </local:MultilanguageObjectDataProvider>
    </Window.Resources>
    <WrapPanel Orientation="Horizontal"  >
        <WrapPanel.Resources >
            <Style TargetType="Button" >
                <Setter Property="Width" Value="120"/>
                <Setter Property="Height" Value="36"/>
                <Setter Property="Margin" Value="12"/>
            </Style>
        </WrapPanel.Resources>
        <Button Content="{DynamicResource Add}" />
        <Button Content="{DynamicResource Delete}"/>
        <Button Content="{DynamicResource ChangeLanguage}" Command="{Binding ChangeLanguage}"/>
        <ComboBox x:Name="combobox" ItemsSource="{Binding Source={StaticResource genderEnum}}"                  
                  VerticalAlignment="Top" Margin="12" Height="36" Width="72">
        </ComboBox>
    </WrapPanel>
</Window>
    public class MainViewModel : INotifyPropertyChanged
    {
        private Gender _selectedItem;

        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public Gender SelectedItem
        {
            get => _selectedItem;
            set
            {
                if (_selectedItem != value)
                {
                    _selectedItem = value;
                    OnPropertyChanged(nameof(SelectedItem));
                    Debug.WriteLine($"selected item is {_selectedItem }");
                }
            }

        }

        public ICommand ChangeLanguage
        {
            get { return new ChangedLangeageCommand(); }
        }
    }

    public class ChangedLangeageCommand : ICommand
    {
        public event EventHandler CanExecuteChanged;

        private void OnCanExecuteChanged()
        {
            CanExecuteChanged?.Invoke(this, EventArgs.Empty);
        }

        public bool CanExecute(object parameter)
        {
            return true;
        }

        public void Execute(object parameter)
        {
            var app = Application.Current as IMultilanguage;
            if (app == null ) { return; }
            var proxy = app.LanguageProxy;
            if (proxy.CurrentLanguage == LanguageCode.Default)
            {
                proxy.CurrentLanguage = LanguageCode.zhtw;
            }
            else
            {
                proxy.CurrentLanguage = LanguageCode.Default;
            }
        }
    }

來測試看看成果如何:

剛開始看起來好像不錯,改變語系的時候,ComboBox 的選單也同時改變了,但眼尖的你應該發現了一個問題,被選擇的選項並沒有做到應有的改變。那就讓我們在 ComboBox 上動點手腳:

    public class MyComboBox : ComboBox
    {
        protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
        {
            base.OnItemsSourceChanged(oldValue, newValue);
            if (oldValue == null ) { return; }
            if (newValue == null) { return; }
            if (oldValue.GetType() != newValue.GetType()) { return; }            
            if (this.SelectedItem != null)
            {
                var old = SelectedItem;                
                SetValue(Selector.SelectedItemProperty, null);
                SetValue(Selector.SelectedItemProperty, old);
            }
        }
    }

然後把 MainWindow.xaml 裡使用 ComboBox 的地方改換成 MyComboBox 

<local:MyComboBox x:Name="combobox" ItemsSource="{Binding Source={StaticResource genderEnum}}"
          VerticalAlignment="Top" Margin="12" Height="36" Width="72">
</local:MyComboBox>

大功告成,相關範例可以在我的 github 上下載。如果覺得範例還不錯,你也用得上,那就賞顆星星吧。