はじめに
エクセルのアドインを初めて作成したところ、思いのほか利用シーンが多かったため備忘録的に記事にします。
セットアップ
セットアップの方法については@rheneさんによる以下の記事通りに行うのがおすすめです
Visual Studio 2019 によるExcelアドインの作成
こちらはVS2022でも同じ手順です
上記の手順4が終わってデバッグできることを前提とします
設定ダイアログの作成
ここではWPFでダイアログを作成します
ソリューションから新しいプロジェクトを追加します
WPF カスタムコントロールライブラリを選びます
エクセルが関係する部分はどうしても.Net Frameworkでの作成になるので注意してください(.netstandard 2.0とかで作っても確かうまくいかなかったはず)
追加が終わったら、今追加したプロジェクトをアドインから参照するようにします
アドイン側のプロジェクトの[参照]を右クリック→[参照の追加]
プロジェクトタブで、追加したソリューションにチェックを入れてOKを押します
ここから実際にファイルを編集していきます
カスタムコントロール側のプロジェクトの中にあるファイルを一度削除します(気にならなければ消さなくても構いませんが、説明のノイズになるので消しました)
その後、今回は以下のようなフォルダ構成にしました
最初に設定Windowそのものを定義します
Viewフォルダを選択して、[新しい項目]から[ウィンドウ(WPF)]を選択して追加します
ここではBaseWindowという名前で作成しました
このWindowは共通要素として使うつもりのため、内部にContentControlだけ配置して、実際の要素はそれぞれのダイアログに任せることにします
<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としました
例えば以下のような構成を作ります
<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とします
とりあえず、最低限のコードを記述します
実際に使う場合は適当なライブラリのクラスを継承することになるでしょう
コード的には、このViewに任意のUserControlを代入することで、Windowに表示できるというわけです
using System.Windows;
namespace WpfCustomControlLibrary2.ViewModel
{
public class DialogViewModelBase
{
public FrameworkElement View { get; set; }
}
}
[呼び出しの作成]
次に、セットアップの記事よりリボンを追加していると思いますので、ボタンを配置してそのボタンを押したら、ダイアログを表示するようにします
ボタン2を新たに配置します
配置したボタンをダブルクリックしてコードを表示します
ここで先にヘルパーメソッドを作ってしまいましょう
ダイアログを呼び出す場合は、すべてこのメソッドを経由するようにします
(参照の追加などが必要になると思いますが、VisualStudioの提案に従って入れてください)
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();
}
そして、ボタンを押したときにこのメソッドを呼ぶように変更します
private void button2_Click(object sender, RibbonControlEventArgs e)
{
var vm = new DialogViewModelBase(); //実際はDialogViewModelBaseを継承した独自ViewModelにする
ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);
}
[実行]
では、デバッグ実行してみましょう
実行してみると、プロパティを作っていないのでいくつかバインドエラーが出ますが、画面はちゃんと表示されました
機能を拡張する
とりあえず動くものにはなりましたが、この時点でいくつか気になる点があると思います
- OKボタンを押したときはアドイン側で何かしたい
- せっかくのWPFなので、大きさは自動計算してほしい(高さがめちゃくちゃ)
[ライブラリの追加]
DialogViewModelBase クラス を拡張することにします
後で必要になるのが確定しているのでINotifyPropertyChangedを実装したクラスを継承しておきます
なんでもいいですが、今回は例として[CommunityToolkit.Mvvm]を導入します
カスタムコントロールのプロジェクトを選んで、右クリックから[Nuget パッケージの管理]を選びます
選んだら、上のタブがおそらくデフォルトだと[インストール済み]が選ばれていると思うので[参照]タブを選び、次に検索窓に[CommunityToolkit.Mvvm]と入力すると対象のライブラリが出てくるのでインストールします
最近のビジュアルスタジオはなぜか2番目の項目が一番上に出ていたりするので、必ず一番上までスクロールして名前を確認してからインストールを選びます
インストールが終わったら、DialogViewModelBase クラスにCommunityToolkit.Mvvm.ComponentModel.ObservableObjectを継承させます
制御の返し方
シンプルにbool値のプロパティを追加します
OKボタンを押したときはtrue、それ以外の場合はfalseを呼び出し元が読み取れるようにします
これは画面が読み取る必要はないので自動実装プロパティにしておきます
public bool IsSuccess { get; set; }
[ボタンの実装]
次にボタンの実装を追加します
OK / キャンセルボタンはある程度は共通にして、残りを継承クラスでやらせることにします
画面側ではあらかじめ以下のような名前のコマンドを想定していたので、こちらに合わせます
<Button Command="{Binding OkCommand}" Content="OK" />
<Button Command="{Binding CancelCommand}" Content="Cancel" />
まずベースクラス側の実装ですが、コマンドのプロパティ及びメソッドを作ってコンストラクタで代入するようにします
メソッドの中身としては「WindowをCloseする処理を呼ぶ」としておきます
そしてOKボタンを押した場合は、先ほど定義したIsSuccessプロパティをtrueに立てるものとします
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に以下のようなフィールドとメソッドを追加します
public event Action OnCloseAction;
protected void OnClose()
{
if (OnCloseAction != null)
{
OnCloseAction();
OnCloseAction = null;
}
}
イベントの登録は、ViewとViewModelの両方を知っている・・・つまりは呼び元のアドインにやらせます
ダイアログを呼び出すメソッドに、コードを追加します
これでOKボタンとキャンセルボタンを押したときに、Windowが閉じるようになります
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();
}
呼び元で結果を受け取るコードを書いてみましょう
ダイアログを閉じた後にプロパティの値を読みます
private void button2_Click(object sender, RibbonControlEventArgs e)
{
var vm = new DialogViewModelBase(); //実際はDialogViewModelBaseを継承した独自ViewModelにする
ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);
+ if (vm.IsSuccess)
+ {
+ }
}
一度実行してみてOKボタンを押します
もちろんプロパティを他にも追加すれば、呼び元で値が取得できるため、画面で設定した内容を元にしてアドインを動作させることができます
[派生クラスの追加]
TestDialogには「作成者」という独自の項目があります
<TextBlock VerticalAlignment="Center" Text="作成者" />
<TextBox
Grid.Column="1"
Width="100"
HorizontalAlignment="Left"
Text="{Binding 作成者, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
この内容に合わせてViewModelを作成しましょう
ViewModelフォルダにTestDialogViewModelを追加します
このクラスは先ほどまで編集していたDialogViewModelBaseを継承させます
作成者というプロパティを作り、今回はOKボタンをoverrideして、入力していなければOKを押せない実装にします
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(作成者);
}
}
}
呼び元ではこのクラスを利用して、ダイアログを作るように変更しましょう
private void button2_Click(object sender, RibbonControlEventArgs e)
{
- var vm = new DialogViewModelBase();
+ var vm = new TestDialogViewModel();
ShowDialog<WpfCustomControlLibrary2.View.TestDialog>(vm);
if (vm.IsSuccess)
{
}
}
作成者のテキストボックスに任意の名前を入れるまでボタンが有効にならないこと
テキストボックスに値を入力すると、その値が呼び元で受け取れること
以上2つが実現できました
Windowの大きさをいい感じにする
今までWindowの大きさが無茶苦茶になっていたのは、単にWindowにWidthとHeightが設定されていたからです
取っ払った後にSizeToContentを設定します
<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>
少なくとも縦幅はいい感じになりましたが、横幅は微妙ですね
これは横幅については中のコンテンツの必要最小値が使われているためです
中のコンテンツ(TestDialog)に横幅を設定してやることで解決できます
<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が設定されているのでこれを外せばいっぱいに広がります)
もしも、ダイアログの大きさをユーザーは自由に変更できないという前提であれば、最後にBaseWindowにプロパティを一つ足せば画面は完成します
<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に追従させないと、とてもではないですが不格好でしょう
しかし、先ほどTestDialogには横幅を直接設定してしまいました
直接設定した値が最優先されてしまうのでこの方法ではこの先には進めそうにありません
(Stretchなどを設定しても直接指定した横幅自体は変わらないので無駄です)
[イベントの設定]
ここから先はデバッグが甘いため、実際に使用する場合は十分にテストしてください
直接指定した値が最優先されるのであれば、値を自分で計算して代入すれば解決しますいきなり力業ですが
理屈としては以下を実装します
- 画面を表示したタイミングではSizeToContent="WidthAndHeight"を利用してWPFに計算させる
- 画面を表示した時のWindowと中のコンテンツの大きさの差分を取る
- ユーザーが手で大きさを操作した後は自分でコンテンツのサイズを計算する
[コンテンツの大きさ確定時]
WindowにContentRenderedイベントを追加します
<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>
コードビハインドでは以下のコードを設定します
/// <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;
}
[サイズ変更時]
WindowにSizeChangedイベントを追加します
<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>
コードビハインドでは以下のコードを設定します
/// <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;
}
}
上記を実装して、さてリサイズしてみましょう
横幅はちゃんと追従するようになりました、でも縦幅がダメです
これは縦についてはコンテンツの中の要素がStackPanelになっているので、そもそも大きさが変わらないからなんですが、ではGridにしてみると?
ついでに分かりやすいように下のボタンの配置をBottomにしておきます
- <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>
ここまでやると実際正しく動作するようになるんですが、今度は初期サイズがおかしいことになってます
これは、必要最小値の大きさになっていて、縦の大きさを設定していないからなので、最後に初期の縦幅を決めるか、最小値を決めてやれば完成します
今回はとりあえず大本でサイズを設定してしまいますが、Grid側で大きさを定義して自動計算させてしまっても問題ありません
<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">
終わりに
非常に残念ながら、ホットリロードが効きませんので、開発効率は最高とはいきません
しかし旧来のVBAで(別にVBやりたいわけでもないのに)ガリガリコード書くよりは、ずっと楽に開発できるものと信じています