提督業プラグインを作ってみよう(4)ウィンドウ位置とサイズを保存・復元しよう

9/12追記 うっかり設定の保存・復元処理を追加し忘れていたのを修正しました。

ウィンドウ位置とサイズを保存・復元する機能自体はすでにMetroWindowに実装されています。 ただし、デフォルトの実装だと%UserProfile%\AppData\Local\ にファイルが作成されてしまうので、この連載ではプラグインdllと同じフォルダに保存するようにプログラムを改造していきます。

1.ウィンドウ位置とサイズを保存・復元するためのクラスを追加

Models\SettingsフォルダにSerializableSetting.cs、WindowSetting.csを追加します。 bool TopMostでウィンドウが最前面かどうかを、Nullable<WINDOWPLACEMENT>Placementでウィンドウ位置とサイズを保存します。

  • SerializableSetting.cs
/// <summary>
/// シリアライズする設定項目の基本クラス
/// </summary>
/// <seealso cref="System.ComponentModel.INotifyPropertyChanged" />
public abstract class SerializableSetting : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void RaisePropertyChanged([CallerMemberName]string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

    /// <summary>
    /// シリアライズするクラスを識別するための文字列(デフォルトはクラス名)
    /// </summary>
    public virtual string CategoryName => this.GetType().Name;
}
  • WindowSetting.cs
/// <summary>
/// ウインドウ設定用クラス
/// </summary>
/// <seealso cref="MetroRadiance.UI.Controls.IWindowSettings" />
public class WindowSetting : SerializableSetting, IWindowSettings
{
    /// <summary>
    /// ウィンドウを常に最前面に表示するかどうかを示す設定値
    /// </summary>
   #region TopMost変更通知プロパティ
    private bool _TopMost;

    public bool TopMost
    {
        get
        { return _TopMost; }
        set
        { 
            if (_TopMost == value)
                return;
            _TopMost = value;
            RaisePropertyChanged(nameof(TopMost));
        }
    }
   #endregion

    /// <summary>
    /// ウィンドウの位置・サイズ・状態を示す設定値
    /// </summary>
   #region Placementプロパティ
    private WINDOWPLACEMENT? _Placement;
    public WINDOWPLACEMENT? Placement
    {
        get
        { return _Placement; }
        set
        {
            if (_Placement.Equals(value))
                return;
            _Placement = value;
        }
    }
   #endregion

    /// <summary>
    /// シリアライズするクラスを識別するための文字列(ウィンドウごとに一意な文字列にすること)
    /// </summary>
    public override string CategoryName { get; }

    public WindowSetting() : this(null) { }

    public WindowSetting(string key)
    {
        this.CategoryName = key ?? this.GetType().Name;
    }

    /// <summary>
    /// IWindowSettings.Reload
    /// </summary>
    public virtual void Reload()
    {

    }

    /// <summary>
    /// IWindowSettings.Save
    /// </summary>
    public virtual void Save()
    {

    }
}

2.設定データをファイルへの読み書きするためのクラスを追加

Models\SettingsフォルダにSettingsRoot.cs、SettingsRoot.cs、SettingsHost.csを追加します。 SettingsHost.cached_instancesに入っている設定データをファイルへ保存・復元します。

  • SettingPath.cs
/// <summary>
/// パス、ファイル名等の定数を管理するクラス
/// </summary>
public static class SettingPath
{
    /// <summary>
    /// このプラグインのあるフォルダを取得
    /// </summary>
    public static string GetDllFolder() => Directory.GetParent(Assembly.GetExecutingAssembly().Location).FullName;

    /// <summary>
    /// アセンブリ名
    /// </summary>
    public static readonly string DLLName = Assembly.GetExecutingAssembly().GetName().Name;

    /// <summary>
    /// 設定ファイル名
    /// </summary>
    private static readonly string SettingFileName = $"{DLLName}.xml";

    /// <summary>
    /// ローカルApplication Dataフォルダに 
    /// \grabacr.net\KanColleViewer\Plugins\KanburaLike\アセンブリ名.xaml
    /// を繋げたもの(コンパイルオプションで使用可能)
    /// </summary>
    private static string LocalAppFilePath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "grabacr.net", "KanColleViewer", "Plugins", DLLName, SettingFileName);

    /// <summary>
    /// このプラグインがあるフォルダに
    /// \アセンブリ名.xaml
    /// を繋げたもの(デフォルトはこれ)
    /// </summary>
    private static string PluginPath { get; } = Path.Combine(Directory.GetParent(Assembly.GetExecutingAssembly().Location).FullName,
         SettingFileName);

#if SETTING_LOCALAPP
    public static string Current { get; } = LocalAppFilePath;
#else
    public static string Current { get; } = PluginPath;
#endif
}

SettingPath.Currentで設定ファイルのフルパスが取れます。

  • SettingsRoot.cs
/// <summary>
/// 設定用データのDictionaryをシリアライズするためのクラス
/// </summary>
[SerializableAttribute()]
[XmlType(AnonymousType = true)]
[XmlRoot(Namespace = "", IsNullable = false)]
[XmlInclude(typeof(WindowSetting))]
public class SettingsRoot
{
    [XmlArrayItem(IsNullable = false)]
    public string[] Keys { get; set; }

    [XmlArrayItem(IsNullable = false)]
    public SerializableSetting[] Values { get; set; }
}

シリアライズするクラスが増えたら、XmlIncludeに追加していきます。

  • SettingsHost.cs
/// <summary>
/// 設定データをまとめてセーブ、ロードするクラス
/// </summary>
public static class SettingsHost
{
    /// <summary>
    /// 設定データのインスタンスのキャッシュ
    /// </summary>
    private static Dictionary<string, SerializableSetting> cached_instances = new Dictionary<string, SerializableSetting>();
    private static readonly object _sync = new object();

    /// <summary>
    /// 現在のcached_instancesにキャッシュされている <see cref="{T}"/>
    /// を取得します。 キャッシュがない場合は <see cref="create"/> に従って生成します。
    /// </summary>
    /// <returns></returns>
    public static T Cache<T>(Func<string, T> create, string key) where T : SerializableSetting
    {
        if (cached_instances.TryGetValue(key, out SerializableSetting obj) && obj is T) return (T)obj;

        var instance = create(key);
        cached_instances[key] = instance;

        return instance;
    }

   #region Load / Save

    /// <summary>
    /// ファイルからロード
    /// </summary>
    public static void LoadFile()
    {
        try
        {
            if (File.Exists(SettingPath.Current))
            {
                using (var stream = new FileStream(SettingPath.Current, FileMode.Open, FileAccess.Read))
                {
                    lock (_sync)
                    {
                        // 読み込み
                        var serializer = new XmlSerializer(typeof(SettingsRoot));
                        var root = (SettingsRoot)serializer.Deserialize(stream);
                        var dic = root.Keys.Zip(root.Values, (k, v) => new KeyValuePair<string, SerializableSetting>(k, v))
                            .ToDictionary(kv => kv.Key, kv => kv.Value);

                        cached_instances = dic;
                    }
                }
            }
            else
            {
                lock (_sync)
                {
                    cached_instances = new Dictionary<string, SerializableSetting>();
                }
            }
        }
        catch (Exception)
        {
            File.Delete(SettingPath.Current);
            cached_instances = new Dictionary<string, SerializableSetting>();
        }
    }

    /// <summary>
    /// ファイルにセーブ
    /// </summary>
    public static void SaveFile()
    {
        try
        {
            if (cached_instances.Count == 0) return;

            lock (_sync)
            {
                using (var stream = new FileStream(SettingPath.Current, FileMode.Create, FileAccess.ReadWrite))
                using (var writer = new StreamWriter(stream, Encoding.UTF8))
                {
                    SettingsRoot root = new SettingsRoot
                    {
                        Keys = cached_instances.Keys.ToArray(),
                        Values = cached_instances.Values.ToArray()
                    };
                    var serializer = new XmlSerializer(typeof(SettingsRoot));
                    serializer.Serialize(writer, root);

                    writer.Flush();
                }
            }
        }
        catch (Exception)
        {
            //例外が出た
        }
    }
   #endregion
}

本家KanColleViewerの設定ファイル保存には Grabacr07.KanColleViewer.Models.Settings一式と MetroTrilithon.Desktop/Serialization/SerializableProperty.csと MetroTrilithon.Desktop/Serialization/FileSettingsProvider.csが使われていますが、構造体を保存できないので、この連載では独自にクラスを実装しました。

3.ViewModelから設定データを扱うクラスを追加

ViewModels\SettingsフォルダにWindowSettingViewModel.csを追加 ViewModelsフォルダにInformationWindowViewModel.csを追加

  • WindowSettingViewModel.cs
public abstract class WindowSettingViewModel : Livet.ViewModel
{
    /// <summary>
    /// MetoroWindowのWindowSettingsにバインドするとファイルに保存できる
    /// </summary>
    public WindowSetting Setting { get; }

    public WindowSettingViewModel(string key)
    {
        Setting = SettingsHost.Cache<WindowSetting>(_ => new WindowSetting(key), key);
    }
}
  • InformationWindowViewModel.cs
public class InformationWindowViewModel : WindowSettingViewModel
{
    public InformationViewModel Info { get; } = new InformationViewModel();

    public InformationWindowViewModel(string key) : base(key)
    {

    }
}

4.Viewと設定データをバインディングする

IsRestoringWindowPlacementをtrueに WindowSettingsにInformationWindowViewModel.Settingをバインドすることによってウィンドウ位置とサイズを保存・復元できます。 Topmost、ピン止めボタンのIsCheckedをInformationWindowViewModel.TopMostとバインドすることでウィンドウが最前面かどうかを保存・復元できます。

艦隊情報表示ユーザーコントロールのDataContextに明示的にInformationViewModelをバインドします。

  • InformationWindow.xaml
<metro:MetroWindow
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:metro="http://schemes.grabacr.net/winfx/2014/controls"
    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:プロジェクト名.Views"
    xmlns:ViewModels="clr-namespace:プロジェクト名.ViewModels" 
    x:Class="プロジェクト名.Views.InformationWindow"
    mc:Ignorable="d"
    Background="{DynamicResource ThemeBrushKey}"
    Foreground="{DynamicResource ActiveForegroundBrushKey}"
    Title="InformationWindow" Height="400" Width="300" 
    d:DataContext="{d:DesignInstance {x:Type ViewModels:InformationWindowViewModel}}" 
    Topmost="{Binding Setting.TopMost, Mode=TwoWay}" 
    WindowSettings="{Binding Setting, Mode=OneWay}" 
    IsRestoringWindowPlacement="True">
    
    <metro:MetroWindow.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="KanColleResource.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </metro:MetroWindow.Resources>

    <DockPanel>
        <!-- キャプションバー ここから -->
        <DockPanel metro:MetroWindow.IsCaptionBar="True" DockPanel.Dock="Top">
            <Border DockPanel.Dock="Bottom"
                    Height="4" />
            <StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
                <metro:CaptionButton 
                    Style="{DynamicResource PinButtonStyleKey}" 
                    VerticalAlignment="Top" 
                    IsChecked="{Binding Setting.TopMost, Mode=TwoWay}" />

                <metro:CaptionButton WindowAction="Minimize" VerticalAlignment="Top" >
                    <Path Style="{DynamicResource MinimizeIconKey}"/>
                </metro:CaptionButton>
            </StackPanel>

            <TextBlock Text="艦隊情報 - プラグインテスト" HorizontalAlignment="Left"
                       Style="{DynamicResource CaptionTextStyleKey}"
                       Margin="2,0,8,0" />
        </DockPanel>
        <!-- キャプションバー ここまで -->
        <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
            <local:Information DataContext="{Binding Info, Mode=OneWay}"/>
        </ScrollViewer>
    </DockPanel>
</metro:MetroWindow>

5.プラグイン本体を修正

メインのViewModelをInformationWindowViewModelに差し替えて、 InformationWindowViewModelの識別キーはメインのViewのクラス名にしています。

class Plugin : IPlugin, IDisposable
{
    private Views.InformationWindow info;  //メインのView
    private ViewModels.InformationWindowViewModel infovm;    //メインのViewModel

    public Plugin()
    {
    }

    //プラグインが読み込まれたときに呼ばれる
    public void Initialize()
    {
        SettingsHost.LoadFile();    //設定をロード
        infovm = new ViewModels.InformationWindowViewModel(nameof(Views.InformationWindow));
        info = new Views.InformationWindow
        {
            DataContext = infovm
        };
        info.Show();
    }

    public void Dispose()
    {
        //ここに終了時の処理を書く
        SettingsHost.SaveFile();    //設定をセーブ
    }
}

ちょっと複雑ですが、これで設定情報をXMLファイルに保存・復元できるようになります。 保存するファイル名やパスはSettingPath.cs、XMLのRootElement名はSettingsRoot.cs、XMLのElement名はSerializableSettingを継承したクラスをカスタマイズすることで変更できます。

カスタマイズ方法は以下のサイトを参考にしてください(順不同)

特殊ディレクトリーを取得する

現在実行しているDLLのパスを取得する。(GetExecutingAssembly ) - tekkの日記 C#,VB.NET

[C#]属性とテキストで構成されるXMLタグをデシリアライズする為のクラス設計 | Zero Configuration

XmlRootAttribute クラス (System.Xml.Serialization)

属性を使用した XML シリアル化の制御

参考サイト

WPF でウィンドウ位置とサイズを保存・復元しよう | grabacr.nét

KanColleViewer/source/Grabacr07.KanColleViewer/Models/Settings at develop · Grabacr07/KanColleViewer · GitHub