LoginSignup
5
4

More than 1 year has passed since last update.

WPFでエクセルアドインの設定ダイアログを作成する (VSTO)

Posted at

はじめに

エクセルのアドインを初めて作成したところ、思いのほか利用シーンが多かったため備忘録的に記事にします。

セットアップ

セットアップの方法については@rheneさんによる以下の記事通りに行うのがおすすめです
Visual Studio 2019 によるExcelアドインの作成
こちらはVS2022でも同じ手順です

上記の手順4が終わってデバッグできることを前提とします

設定ダイアログの作成

ここではWPFでダイアログを作成します

ソリューションから新しいプロジェクトを追加します
image.png
WPF カスタムコントロールライブラリを選びます
エクセルが関係する部分はどうしても.Net Frameworkでの作成になるので注意してください(.netstandard 2.0とかで作っても確かうまくいかなかったはず)
image.png
追加が終わったら、今追加したプロジェクトをアドインから参照するようにします
アドイン側のプロジェクトの[参照]を右クリック→[参照の追加]
image.png
プロジェクトタブで、追加したソリューションにチェックを入れてOKを押します
image.png
ここから実際にファイルを編集していきます
カスタムコントロール側のプロジェクトの中にあるファイルを一度削除します(気にならなければ消さなくても構いませんが、説明のノイズになるので消しました)
その後、今回は以下のようなフォルダ構成にしました
image.png
最初に設定Windowそのものを定義します
Viewフォルダを選択して、[新しい項目]から[ウィンドウ(WPF)]を選択して追加します
image.png
ここではBaseWindowという名前で作成しました
image.png
このWindowは共通要素として使うつもりのため、内部にContentControlだけ配置して、実際の要素はそれぞれのダイアログに任せることにします

BaseWindow.xaml
<Window
    x:Class="WpfCustomControlLibrary2.View.BaseWindow"
    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:local="clr-namespace:WpfCustomControlLibrary2.View"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="BaseWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">
    <Grid>
        <ContentControl
            Name="_content"
            Margin="0,5,0,10"
            Content="{Binding View}" />
    </Grid>
</Window>

続けて、実際に中身を作成するため、UserControlの追加を行います
ここでは名前はTestDialogとしました
image.png
例えば以下のような構成を作ります

TestDialog.xaml
<UserControl
    x:Class="WpfCustomControlLibrary2.View.TestDialog"
    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"
    mc:Ignorable="d">
    <UserControl.Resources>
        <Style TargetType="TextBox">
            <Setter Property="Height" Value="24" />
            <Setter Property="VerticalContentAlignment" Value="Center" />
            <Setter Property="Padding" Value="5,0,0,0" />
        </Style>

        <Style TargetType="Button">
            <Setter Property="Height" Value="24" />
            <Setter Property="Width" Value="100" />
            <Setter Property="Margin" Value="10,0" />
            <Setter Property="Background" Value="#FFFFFF" />
        </Style>
    </UserControl.Resources>

    <StackPanel>
        <Grid Margin="20,0,20,0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="120" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="40" />
            </Grid.RowDefinitions>
            <TextBlock VerticalAlignment="Center" Text="作成者" />
            <TextBox
                Grid.Column="1"
                Width="100"
                HorizontalAlignment="Left"
                Text="{Binding 作成者, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
        </Grid>

        <!--  ボタン  -->
        <StackPanel
            Margin="10,20,10,10"
            HorizontalAlignment="Right"
            Orientation="Horizontal">
            <Button Command="{Binding OkCommand}" Content="OK" />
            <Button Command="{Binding CancelCommand}" Content="Cancel" />
        </StackPanel>
    </StackPanel>
</UserControl>

アドイン側からの呼び出し

とりあえず見た目ができたので実際に呼び出してみたいと思います

[ViewModelの作成]
呼び出すために、まずはWindow自体のViewModelを作ります
ViewModelフォルダに新しくクラスを追加します。名前はDialogViewModelBaseとします
image.png
とりあえず、最低限のコードを記述します
実際に使う場合は適当なライブラリのクラスを継承することになるでしょう

コード的には、このViewに任意のUserControlを代入することで、Windowに表示できるというわけです

DialogViewModelBase.cs
using System.Windows;

namespace WpfCustomControlLibrary2.ViewModel
{
    public class DialogViewModelBase
    {
        public FrameworkElement View { get; set; }
    }
}

[呼び出しの作成]
次に、セットアップの記事よりリボンを追加していると思いますので、ボタンを配置してそのボタンを押したら、ダイアログを表示するようにします

ボタン2を新たに配置します
image.png
配置したボタンをダブルクリックしてコードを表示します
image.png

ここで先にヘルパーメソッドを作ってしまいましょう
ダイアログを呼び出す場合は、すべてこのメソッドを経由するようにします
(参照の追加などが必要になると思いますが、VisualStudioの提案に従って入れてください)

Ribbon1.cs
internal static void ShowDialog<TView>(DialogViewModelBase vm) where TView : FrameworkElement, new()
{
    vm.View = new TView();

    var window = new WpfCustomControlLibrary2.View.BaseWindow();
    window.DataContext = vm;

    window.ShowDialog();
}

そして、ボタンを押したときにこのメソッドを呼ぶように変更します

Ribbon1.cs
private void button2_Click(object sender, RibbonControlEventArgs e)
{
    var vm = new DialogViewModelBase(); //実際はDialogViewModelBaseを継承した独自ViewModelにする
    ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);
}

全体は以下のようになります
image.png

[実行]
では、デバッグ実行してみましょう
実行してみると、プロパティを作っていないのでいくつかバインドエラーが出ますが、画面はちゃんと表示されました
image.png

機能を拡張する

とりあえず動くものにはなりましたが、この時点でいくつか気になる点があると思います

  • OKボタンを押したときはアドイン側で何かしたい
  • せっかくのWPFなので、大きさは自動計算してほしい(高さがめちゃくちゃ)

[ライブラリの追加]
DialogViewModelBase クラス を拡張することにします
後で必要になるのが確定しているのでINotifyPropertyChangedを実装したクラスを継承しておきます
なんでもいいですが、今回は例として[CommunityToolkit.Mvvm]を導入します

カスタムコントロールのプロジェクトを選んで、右クリックから[Nuget パッケージの管理]を選びます
image.png
選んだら、上のタブがおそらくデフォルトだと[インストール済み]が選ばれていると思うので[参照]タブを選び、次に検索窓に[CommunityToolkit.Mvvm]と入力すると対象のライブラリが出てくるのでインストールします

最近のビジュアルスタジオはなぜか2番目の項目が一番上に出ていたりするので、必ず一番上までスクロールして名前を確認してからインストールを選びます
image.png
インストールが終わったら、DialogViewModelBase クラスにCommunityToolkit.Mvvm.ComponentModel.ObservableObjectを継承させます
image.png

制御の返し方

シンプルにbool値のプロパティを追加します
OKボタンを押したときはtrue、それ以外の場合はfalseを呼び出し元が読み取れるようにします
これは画面が読み取る必要はないので自動実装プロパティにしておきます

DialogViewModelBase.cs
public bool IsSuccess { get; set; }

[ボタンの実装]
次にボタンの実装を追加します
OK / キャンセルボタンはある程度は共通にして、残りを継承クラスでやらせることにします

画面側ではあらかじめ以下のような名前のコマンドを想定していたので、こちらに合わせます

TestDialog.xaml
<Button Command="{Binding OkCommand}" Content="OK" />
<Button Command="{Binding CancelCommand}" Content="Cancel" />

まずベースクラス側の実装ですが、コマンドのプロパティ及びメソッドを作ってコンストラクタで代入するようにします
メソッドの中身としては「WindowをCloseする処理を呼ぶ」としておきます
そしてOKボタンを押した場合は、先ほど定義したIsSuccessプロパティをtrueに立てるものとします

DialogViewModelBase.cs
public RelayCommand OkCommand { get; set; }
public RelayCommand CancelCommand { get; set; }

public DialogViewModelBase()
{
    OkCommand = new RelayCommand(OnOkCommand, IsOkCommand);
    CancelCommand = new RelayCommand(OnCancelCommand, IsCancelCommand);
}

protected virtual bool IsOkCommand() => true;
protected virtual void OnOkCommand()
{
    IsSuccess = true;
    OnClose();
}

protected virtual bool IsCancelCommand() => true;
protected virtual void OnCancelCommand()
{
    OnClose();
}

OnClose()でやりたいことはダイアログを閉じるということですが、ViewModelはViewのことを知らないのでイベントで保持して呼ぶようにします
DialogViewModelBaseに以下のようなフィールドとメソッドを追加します

DialogViewModelBase.cs
public event Action OnCloseAction;

protected void OnClose()
{
    if (OnCloseAction != null)
    {
        OnCloseAction();
        OnCloseAction = null;
    }
}

このベースクラス全体は以下のようになります
image.png

イベントの登録は、ViewとViewModelの両方を知っている・・・つまりは呼び元のアドインにやらせます
ダイアログを呼び出すメソッドに、コードを追加します
これでOKボタンとキャンセルボタンを押したときに、Windowが閉じるようになります

Ribbon1.cs
internal static void ShowDialog<TView>(DialogViewModelBase vm) where TView : FrameworkElement, new()
{
    vm.View = new TView();

    var window = new WpfCustomControlLibrary2.View.BaseWindow();
    window.DataContext = vm;

+   vm.OnCloseAction += window.Close;

    window.ShowDialog();
}

呼び元で結果を受け取るコードを書いてみましょう
ダイアログを閉じた後にプロパティの値を読みます

Ribbon1.cs
private void button2_Click(object sender, RibbonControlEventArgs e)
{
    var vm = new DialogViewModelBase(); //実際はDialogViewModelBaseを継承した独自ViewModelにする
    ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);

+    if (vm.IsSuccess)
+    {

+    }
}

一度実行してみてOKボタンを押します
もちろんプロパティを他にも追加すれば、呼び元で値が取得できるため、画面で設定した内容を元にしてアドインを動作させることができます
image.png

[派生クラスの追加]
TestDialogには「作成者」という独自の項目があります

TestDialog.xaml
<TextBlock VerticalAlignment="Center" Text="作成者" />
<TextBox
    Grid.Column="1"
    Width="100"
    HorizontalAlignment="Left"
    Text="{Binding 作成者, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

この内容に合わせてViewModelを作成しましょう
ViewModelフォルダにTestDialogViewModelを追加します
このクラスは先ほどまで編集していたDialogViewModelBaseを継承させます
image.png
作成者というプロパティを作り、今回はOKボタンをoverrideして、入力していなければOKを押せない実装にします

TestDialogViewModel.cs
namespace WpfCustomControlLibrary2.ViewModel
{
    public partial class TestDialogViewModel : DialogViewModelBase
    {
        private string m_作成者 = "";

        public string 作成者
        {
            get => m_作成者;
            set
            {
                if (SetProperty(ref m_作成者, value))
                {
                    OkCommand.NotifyCanExecuteChanged();
                }
            }
        }

        protected override bool IsOkCommand()
        {
            return !string.IsNullOrEmpty(作成者);
        }
    }
}

呼び元ではこのクラスを利用して、ダイアログを作るように変更しましょう

Ribbon1.cs
private void button2_Click(object sender, RibbonControlEventArgs e)
{
-   var vm = new DialogViewModelBase();
+   var vm = new TestDialogViewModel();
    ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);

    if (vm.IsSuccess)
    {

    }
}

作成者のテキストボックスに任意の名前を入れるまでボタンが有効にならないこと
image.png

テキストボックスに値を入力すると、その値が呼び元で受け取れること
image.png
以上2つが実現できました

Windowの大きさをいい感じにする

今までWindowの大きさが無茶苦茶になっていたのは、単にWindowにWidthとHeightが設定されていたからです
取っ払った後にSizeToContentを設定します

BaseWindow.xaml
<Window
    x:Class="WpfCustomControlLibrary2.View.BaseWindow"
    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:local="clr-namespace:WpfCustomControlLibrary2.View"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="BaseWindow"
-   Width="800"
-   Height="450"
+   SizeToContent="WidthAndHeight"
    mc:Ignorable="d">
    <Grid>
        <ContentControl
            Name="_content"
            Margin="0,5,0,10"
            Content="{Binding View}" />
    </Grid>
</Window>

image.png
少なくとも縦幅はいい感じになりましたが、横幅は微妙ですね
これは横幅については中のコンテンツの必要最小値が使われているためです
中のコンテンツ(TestDialog)に横幅を設定してやることで解決できます

TestDialog.xaml
<UserControl
    x:Class="WpfCustomControlLibrary2.View.TestDialog"
    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"
+   Width="300"
    mc:Ignorable="d">

横幅の調整ができることはわかりました
(※TextBoxの位置がおかしいのはHorizontalAlignment="Left"になっているせいなので、Rightにすると右に揃います。あるいはStretchにした後、Width=100が設定されているのでこれを外せばいっぱいに広がります)
image.png
もしも、ダイアログの大きさをユーザーは自由に変更できないという前提であれば、最後にBaseWindowにプロパティを一つ足せば画面は完成します

BaseWindow.xaml
<Window
    x:Class="WpfCustomControlLibrary2.View.BaseWindow"
    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:local="clr-namespace:WpfCustomControlLibrary2.View"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="BaseWindow"
+   ResizeMode="CanMinimize"
    SizeToContent="WidthAndHeight"
    mc:Ignorable="d">
</Window>

しかしそれがどうしても納得いかない場合は、さらなる実装が必要です
以下は右下をドラッグして、ユーザーがダイアログの大きさを変更した場合の例です
中のコンテンツの大きさをWindowに追従させないと、とてもではないですが不格好でしょう
image.png
しかし、先ほどTestDialogには横幅を直接設定してしまいました
直接設定した値が最優先されてしまうのでこの方法ではこの先には進めそうにありません
(Stretchなどを設定しても直接指定した横幅自体は変わらないので無駄です)

[イベントの設定]
ここから先はデバッグが甘いため、実際に使用する場合は十分にテストしてください

直接指定した値が最優先されるのであれば、値を自分で計算して代入すれば解決しますいきなり力業ですが

理屈としては以下を実装します

  • 画面を表示したタイミングではSizeToContent="WidthAndHeight"を利用してWPFに計算させる
  • 画面を表示した時のWindowと中のコンテンツの大きさの差分を取る
  • ユーザーが手で大きさを操作した後は自分でコンテンツのサイズを計算する

[コンテンツの大きさ確定時]
WindowにContentRenderedイベントを追加します

BaseWindow.xaml
<Window
    x:Class="WpfCustomControlLibrary2.View.BaseWindow"
    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:local="clr-namespace:WpfCustomControlLibrary2.View"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+   ContentRendered="Window_ContentRendered"
    Title="BaseWindow"
-   ResizeMode="CanMinimize"
    SizeToContent="WidthAndHeight"
    mc:Ignorable="d">
</Window>

コードビハインドでは以下のコードを設定します

BaseWindow.xaml.cs
/// <summary>サイズ差分の計算が終わったか</summary>
private bool m_isDecidedDiffSize = false;

/// <summary>コンテンツとWindowのサイズ差分</summary>
private Size m_diffSize;

private void Window_ContentRendered(object sender, EventArgs e)
{
    if (m_isDecidedDiffSize) return;
    m_diffSize = new Size(ActualWidth - _content.ActualWidth,
                          ActualHeight - _content.ActualHeight);

    m_isDecidedDiffSize = true;
}

image.png

[サイズ変更時]
WindowにSizeChangedイベントを追加します

BaseWindow.xaml
<Window
    x:Class="WpfCustomControlLibrary2.View.BaseWindow"
    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:local="clr-namespace:WpfCustomControlLibrary2.View"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    ContentRendered="Window_ContentRendered"
    Title="BaseWindow"
+   SizeChanged="Window_SizeChanged"
    SizeToContent="WidthAndHeight"
    mc:Ignorable="d">
</Window>

コードビハインドでは以下のコードを設定します

BaseWindow.xaml.cs
/// <summary>中身の大きさが確定したか</summary>
private bool m_isResizeInitialize = false;
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
{
    // ユーザー操作でリサイズするまでは動作させない
    if (SizeToContent != SizeToContent.Manual) return;

    // 中身が確定しないうちは動作させない
    if (m_isResizeInitialize == false)
    {
        ResizeInitialize();
    }

    var ctrl = (FrameworkElement)sender;
    var vm = (DialogViewModelBase)ctrl.DataContext;

    _content.Width = e.NewSize.Width - m_diffSize.Width;
    _content.Height = e.NewSize.Height - m_diffSize.Height;
}

private void ResizeInitialize()
{
    // 一度しか処理させない
    if (m_isResizeInitialize) return;
    m_isResizeInitialize = true;

    if (_content.Content is FrameworkElement ctrl)
    {
        ctrl.Width = double.NaN;
        ctrl.Height = double.NaN;

        _content.Width = ActualWidth - m_diffSize.Width;
        _content.Height = ActualHeight - m_diffSize.Height;

        // MAX値、MIN値があれば設定
        if (!double.IsInfinity(ctrl.MaxWidth)) this.MaxWidth = m_diffSize.Width + ctrl.MaxWidth;
        if (!double.IsInfinity(ctrl.MaxHeight)) this.MaxHeight = m_diffSize.Height + ctrl.MaxHeight;

        if (ctrl.MinWidth > 0) this.MinWidth = m_diffSize.Width + ctrl.MinWidth;
        if (ctrl.MinHeight > 0) this.MinHeight = m_diffSize.Height + ctrl.MinHeight;
    }
}

上記を実装して、さてリサイズしてみましょう
image.png
横幅はちゃんと追従するようになりました、でも縦幅がダメです
これは縦についてはコンテンツの中の要素がStackPanelになっているので、そもそも大きさが変わらないからなんですが、ではGridにしてみると?
ついでに分かりやすいように下のボタンの配置をBottomにしておきます

TestDialog.xaml
- <StackPanel>
+ <Grid>
    <Grid Margin="20,0,20,0">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="120" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="40" />
        </Grid.RowDefinitions>
        <TextBlock VerticalAlignment="Center" Text="作成者" />
        <TextBox
            Grid.Column="1"
            Width="100"
            HorizontalAlignment="Left"
            Text="{Binding 作成者, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    </Grid>

    <!--  ボタン  -->
    <StackPanel
        Margin="10,20,10,10"
        HorizontalAlignment="Right"
+       VerticalAlignment="Bottom"
        Orientation="Horizontal">
        <Button Command="{Binding OkCommand}" Content="OK" />
        <Button Command="{Binding CancelCommand}" Content="Cancel" />
    </StackPanel>
- </StackPanel>
+ </Grid>

ここまでやると実際正しく動作するようになるんですが、今度は初期サイズがおかしいことになってます
image.png
これは、必要最小値の大きさになっていて、縦の大きさを設定していないからなので、最後に初期の縦幅を決めるか、最小値を決めてやれば完成します
今回はとりあえず大本でサイズを設定してしまいますが、Grid側で大きさを定義して自動計算させてしまっても問題ありません

TestDialog.xaml
<UserControl
    x:Class="WpfCustomControlLibrary2.View.TestDialog"
    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"
    Width="300"
+   MinHeight="100"
    mc:Ignorable="d">

image.png
いい感じになりましたね

終わりに

非常に残念ながら、ホットリロードが効きませんので、開発効率は最高とはいきません
しかし旧来のVBAで(別にVBやりたいわけでもないのに)ガリガリコード書くよりは、ずっと楽に開発できるものと信じています

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4