もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

【WPF】MVVMで「処理中」「待機中」みたいなオーバレイを表示する

はじめに

C#WPFで作った業務アプリで「通信中」と画面全体にオーバレイ表示する機能を作った。

当時はC#(というか.Net Framework)で、MVVMパターンのコーディングが全然わかってなかった

WPFを使っていながら、Windows Formsなコーディングをしてました・・・

(>'A`)>ウワァァ!!書き直したい

最近やっとC#でどうやってMVVMなコーディングをするかわかってきた

色んなフラストレーション解消のため、「待機中」・「読み込み中」・「通信中」みたいなオーバレイを、MVVMパターンで作ってみた。

目次

環境

  • .Net Framework 4.5
  • Prism.Wpf
  • その他、参照の追加(アニメーションGifを使用するため)
    • WindowsFormsIntegration
    • System.Windows.Forms

最初はPrism.Wpfを使っていなかったが、ICommandの実装が面倒臭くなったので追加した

完成形

先に完成形を見せておくと、こんな感じになった。

f:id:monakaice88:20190615084036g:plain

作ってみる

名前空間、クラスはこんな感じ

f:id:monakaice88:20190615084033p:plain

最初は、1ずつのViewとViewModelだけで作っていたけど、ZIndexが期待した動きに中々ならなかった・・・

試しにViewを2つに分けると上手く動作したので、こんな感じになった。

WaitingOverlaySubView、ViewModelの作成

ざっくりポイントまとめ

  1. Gifを再生するので、System.Windows.Forms.PictureBoxを使用(MediaElementを使う方法もあるっぽいけど、上手く動かなかった・・・)
  2. ウインドウのサイズが変わったときに、自分自身のサイズを再設定する
  3. 「待機中」、「NowLoading」など、固定メッセージを変更できるようにしておく(FixedMessageプロパティ)
  4. オーバレイするコントロールのNameを受け取るようにしておく(OverlayTargetNameプロパティ)

xamlはこんな感じ

<UserControl x:Class="WaitingOverlaySample.Controls.WaitingOverlaySubView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WaitingOverlaySample.Controls"
             xmlns:forms="clr-namespace:System.Windows.Forms;assembly=System.Windows.Forms"
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             Width="{Binding Width}" Height="{Binding Height}">

    <Grid>
        <Grid.Resources>
            <SolidColorBrush x:Key="TransparentBackground" Color="#000" Opacity="0.7" />
            <SolidColorBrush x:Key="DefaultBackground" Color="#FFF" Opacity="1" />
            <sys:Double x:Key="GifSize">250</sys:Double>
            <sys:Double x:Key="FontSize">20</sys:Double>
        </Grid.Resources>

        <Rectangle Name="BackgroundRectangle" Fill="{StaticResource TransparentBackground}" Width="{Binding Width}" Height="{Binding Height}"/>
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Background="{StaticResource DefaultBackground}">
            <WindowsFormsHost Height="{StaticResource GifSize}" Width="{StaticResource GifSize}">
                <forms:PictureBox x:Name="AnimationPicture"/>
            </WindowsFormsHost>
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="2*"/>
                    <ColumnDefinition Width="1*"/>
                </Grid.ColumnDefinitions>
                <TextBlock Text="{Binding FixedMessage}" FontSize="{StaticResource FontSize}" HorizontalAlignment="Right" TextAlignment="Right"/>
                <TextBlock Grid.Column="1" Text="{Binding DotLeader}" FontSize="{StaticResource FontSize}" HorizontalAlignment="Left" TextAlignment="Left"/>
            </Grid>
        </StackPanel>
    </Grid>

</UserControl>

xamlのcsはこんな感じ

using System.Windows;
using System.Windows.Forms;

namespace WaitingOverlaySample.Controls
{
    /// <summary>WaitingOverlaySubView.xaml の相互作用ロジック</summary>
    public partial class WaitingOverlaySubView
    {
        /// <summary>コンストラクタ。インスタンスを生成する</summary>
        public WaitingOverlaySubView()
        {
            this.InitializeComponent();
            this._viewModel = new WaitingOverlaySubViewModel();
            this.DataContext = this._viewModel;
            this.AnimationPicture.SizeMode = PictureBoxSizeMode.StretchImage;
            this.AnimationPicture.Image = Properties.Resources.WaitingGif;  // Gifはリソース化

            this.Loaded += this.OnLoaded;
            this.Unloaded += this.OnUnloaded;
            this.IsVisibleChanged += this.OnIsVisibleChanged;
        }

        /// <summary>ViewModel</summary>
        private readonly WaitingOverlaySubViewModel _viewModel;

        /// <summary>ウィンドウ</summary>
        private Window _window;

        /// <summary>オーバレイするターゲットコントロール名</summary>
        public string OverlayTargetName { get; set; }

        /// <summary>再描画処理</summary>
        public void Redraw()
        {
            this.Resize();
        }

        /// <summary>リサイズするぞい</summary>
        private void Resize()
        {
            // 表示しているコントロールの真ん中に表示したいので、親を辿り、ターゲットのサイズを取得する
            FrameworkElement parentElement = this.Parent as FrameworkElement;
            while (parentElement?.Parent != null && parentElement.Parent is FrameworkElement parent)
            {
                if (parent.Name == this.OverlayTargetName)
                {
                    this._viewModel.Width = parent.ActualWidth;
                    this._viewModel.Height = parent.ActualHeight;
                    break;
                }

                parentElement = parent;
            }
        }

        /// <summary>コントロールがロードされたときに実行</summary>
        /// 
        /// <param name="sender">イベント送信元インスタンス</param>
        /// <param name="e">イベントデータ</param>
        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            this._window = Window.GetWindow(this);
            // VisualStudioのビジュアルビューで例外が発生して、見た目が表示出来なくなるのを防ぐ
            if (this._window != null)
            {
                this._window.SizeChanged += this.OnSizeChanged;
            }
        }

        /// <summary>コントロールがアンロードされたときに実行</summary>
        /// 
        /// <param name="sender">イベント送信元インスタンス</param>
        /// <param name="e">イベントデータ</param>
        private void OnUnloaded(object sender, RoutedEventArgs e)
        {
            // WindowインスタンスのSizeChangedイベントにコールバックを登録しまくるので、
            // これをしないとメモリリークか、ぬるぽするかもシレーヌ(未確認)
            if (this._window != null)
            {
                this._window.SizeChanged -= this.OnSizeChanged;
            }
        }

        /// <summary>アプリケーションのウィンドウサイズが変わったときに実行</summary>
        /// 
        /// <param name="sender">イベント送信元インスタンス</param>
        /// <param name="e">イベントデータ</param>
        private void OnSizeChanged(object sender, SizeChangedEventArgs e)
        {
            this.Resize();
        }

        /// <summary>表示状態が変わったときに実行</summary>
        /// 
        /// <param name="sender">イベント送信元インスタンス</param>
        /// <param name="e">イベントデータ</param>
        private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            if ((bool)e.NewValue)
            {
                this._viewModel.StartUpdatingDotLeader();
                this.Resize();
            }
            else
            {
                this._viewModel.StopUpdatingDotLeader();
            }
        }
    }
}

ViewModelはこんな感じ

using Prism.Mvvm;
using System.Timers;

namespace WaitingOverlaySample.Controls
{
    /// <summary>WaitingOverlaySubView用のViewModel機能を提供する</summary>
    public class WaitingOverlaySubViewModel : BindableBase
    {
        /// <summary>コンストラクタ。インスタンスを開始する</summary>
        public WaitingOverlaySubViewModel()
        {
            // タイマ間隔、ピリオド数は適当
            this._updateDotLeaderTimer = new Timer(200 /*ms*/)
            {
                AutoReset = true
            };
            this._updateDotLeaderTimer.Elapsed += (s, e) =>
            {
                int count = e.SignalTime.Second % 6;
                this.DotLeader = new string('.', count);
            };
        }

        /// <summary>リーダー更新タイマ</summary>
        private readonly Timer _updateDotLeaderTimer;

        /// <summary>固定メッセージ。プロパティ用</summary>
        private string _fixedMessage;

        /// <summary>リーダー。プロパティ用</summary>
        private string _dotLeader;

        /// <summary>Controlの幅。プロパティ用</summary>
        private double _width;

        /// <summary>Controlの高さ。プロパティ用</summary>
        private double _height;

        /// <summary>固定メッセージ</summary>
        public string FixedMessage
        {
            get => this._fixedMessage;
            set => this.SetProperty(ref this._fixedMessage, value);
        }

        /// <summary>リーダー</summary>
        public string DotLeader
        {
            get => this._dotLeader;
            set => this.SetProperty(ref this._dotLeader, value);
        }

        /// <summary>Controlの幅</summary>
        public double Width
        {
            get => this._width;
            set => this.SetProperty(ref this._width, value);
        }

        /// <summary>Controlの高さ</summary>
        public double Height
        {
            get => this._height;
            set => this.SetProperty(ref this._height, value);
        }

        /// <summary>リーダーの更新を開始する</summary>
        public void StartUpdatingDotLeader() => this._updateDotLeaderTimer.Start();

        /// <summary>リーダの更新を停止する</summary>
        public void StopUpdatingDotLeader() => this._updateDotLeaderTimer.Stop();

    }
}

WaitingOverlayの作成

ざっくりポイントまとめ

  1. UserControlではなく、Canvasを継承
  2. オーバレイするので、ZIndexはとりあえず最大に
  3. WaitingOverlaySubViewに「OverlayTargetName」・「FixedMessage」を、そのまま渡すために依存プロパティを定義

xamlはこんな感じ

<Canvas x:Class="WaitingOverlaySample.Controls.WaitingOverlay"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WaitingOverlaySample.Controls"
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             ZIndex="{x:Static sys:Int32.MaxValue}">

    <local:WaitingOverlaySubView x:Name="SubView" Canvas.Top="0" Canvas.Left="0"/>

</Canvas>

xamlのcsはこんな感じ

using System;
using System.Windows;
using System.Windows.Threading;

namespace WaitingOverlaySample.Controls
{
    /// <summary>WaitingOverlay.xaml の相互作用ロジック</summary>
    public partial class WaitingOverlay
    {
        /// <summary>コンストラクタ。インスタンスを生成する</summary>
        public WaitingOverlay()
        {
            this.InitializeComponent();
            this.IsVisibleChanged += (s, e) =>
            {
                // 表示・非表示切替時にViewの位置が悪い。Viewのサイズ計算処理の動作するタイミングが悪いっぽい
                // 表示状態が切り替わったときに、微妙に遅らせて、再描画処理を呼び出す。
                // 参考サイト:http://geekswithblogs.net/ilich/archive/2012/10/16/running-code-when-windows-rendering-is-completed.aspx
                this.Dispatcher.BeginInvoke(
                    new Action(() => this.SubView.Redraw()),
                    DispatcherPriority.ContextIdle
                );
            };
        }

        /// <summary>依存性プロパティ</summary>
        /// 
        /// <remarks>
        /// SubViewにプロパティ値を受け渡す
        /// </remarks>
        /// 
        /// <see cref="https://qiita.com/tricogimmick/items/62cd9f5deca365a83858"/>
        public static readonly DependencyProperty OverlayTargetNameProperty = DependencyProperty.Register(
            "OverlayTargetName",
            typeof(string),
            typeof(WaitingOverlay),
            new FrameworkPropertyMetadata(null, OnOverlayTargetNameChanged)
            );

        /// <summary>依存性プロパティ</summary>
        /// 
        /// <remarks>
        /// SubViewにプロパティ値を受け渡す
        /// </remarks>
        /// 
        /// <see cref="https://qiita.com/tricogimmick/items/62cd9f5deca365a83858"/>
        public static readonly DependencyProperty FixedMessageProperty = DependencyProperty.Register(
            "FixedMessage",
            typeof(string),
            typeof(WaitingOverlay),
            new FrameworkPropertyMetadata("Waiting", OnFixedMessageChanged)
            );

        /// <summary>オーバレイするControlの名前</summary>
        public string OverlayTargetName
        {
            get => (string)this.GetValue(OverlayTargetNameProperty);
            set => this.SetValue(OverlayTargetNameProperty, value);
        }

        /// <summary>固定メッセージ</summary>
        public string FixedMessage
        {
            get => (string)this.GetValue(FixedMessageProperty);
            set => this.SetValue(FixedMessageProperty, value);
        }

        /// <summary>OverlayTargetNameが変更される度に実行する</summary>
        /// 
        /// <param name="obj">依存オブジェクト</param>
        /// <param name="e">イベントデータ</param>
        private static void OnOverlayTargetNameChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            if (obj is WaitingOverlay myView)
            {
                myView.SubView.OverlayTargetName = myView.OverlayTargetName;
            }
        }

        /// <summary>FixMessageが変更される度に実行する</summary>
        /// 
        /// <param name="obj">依存オブジェクト</param>
        /// <param name="e">イベントデータ</param>
        private static void OnFixedMessageChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            if (obj is WaitingOverlay myView)
            {
                ((WaitingOverlaySubViewModel)myView.SubView.DataContext).FixedMessage = myView.FixedMessage;
            }
        }
    }
}

使ってみる

MainWindowの見た目

MainWindow全体にオーバレイするように、WindowにNameを設定。そのNameをWaitingOverlayに渡している

<Window x:Class="WaitingOverlaySample.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:WaitingOverlaySample"
        xmlns:controls="clr-namespace:WaitingOverlaySample.Controls"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800"
        Name="OverlayTarget">

    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
    </Window.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <controls:WaitingOverlay OverlayTargetName="OverlayTarget"
                                 FixedMessage="{Binding WaitingMessage}"
                                 Visibility="{Binding IsWaiting, Converter={StaticResource BooleanToVisibilityConverter}}"/>

        <Button Grid.Row="0" Content="5秒待ち" Command="{Binding Wait5SecCommand}" Margin="30"/>
        <Button Grid.Row="1" Content="どんちき♪どんちき♪" Command="{Binding DrummingCommand}" Margin="30"/>

    </Grid>

</Window>

MainWindowのViewModel

using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Timers;

namespace WaitingOverlaySample
{
    /// <summary>MainWindow用のViewModel機能を提供する</summary>
    public class MainWindowViewModel : BindableBase
    {
        /// <summary>コンストラクタ。インスタンスを生成する</summary>
        public MainWindowViewModel()
        {
            bool CanExecute() => !this.IsWaiting;
            Expression<Func<bool>> observers = () => this.IsWaiting;

            this.Wait5SecCommand = new DelegateCommand(this.Wait5Sec, CanExecute)
                .ObservesProperty(observers);
            this.DrummingCommand = new DelegateCommand(this.Drumming, CanExecute)
                .ObservesProperty(observers);
        }

        /// <summary>待機中かどうか表す。プロパティ用</summary>
        private bool _isWaiting;

        /// <summary>待機中のメッセージ。プロパティ用</summary>
        private string _waitingMessage;

        /// <summary>待機中かどうか表す</summary>
        public bool IsWaiting
        {
            get => this._isWaiting;
            set => this.SetProperty(ref this._isWaiting, value);
        }

        /// <summary>待機中のメッセージ</summary>
        public string WaitingMessage
        {
            get => this._waitingMessage;
            set => this.SetProperty(ref this._waitingMessage, value);
        }

        /// <summary>5秒待ちコマンド</summary>
        public DelegateCommand Wait5SecCommand { get; }

        /// <summary>どんちきコマンド</summary>
        public DelegateCommand DrummingCommand { get; }

        /// <summary>5秒間待ってやる</summary>
        private async void Wait5Sec()
        {
            this.IsWaiting = true;
            {
                this.WaitingMessage = "5秒お待ち";
                await Task.Delay(5000 /*ms*/);
            }
            this.IsWaiting = false;
        }

        /// <summary>どんちき└(^ω^)┐♫┌(^ω^)┘♫どんちき</summary>
        private async void Drumming()
        {
            string MakeMessage()
            {
                bool even = (DateTime.Now.Second % 2) == 0;
                return even ? "└(^ω^)┐♫どんちき" : "┌(^ω^)┘♫どんちき";
            }

            this.IsWaiting = true;
            {
                this.WaitingMessage = MakeMessage();
                Timer timer = new Timer(1000 /*ms*/)
                {
                    AutoReset = true
                };
                timer.Elapsed += (s, e) => this.WaitingMessage = MakeMessage();
                timer.Start();
                await Task.Delay(10000 /*ms*/);
                timer.Stop();
                timer = null;
            }
            this.IsWaiting = false;
        }

    }
}

ボタンを押すとこんな感じになる

f:id:monakaice88:20190615084036g:plain

バインディングを使っているので、待機中のメッセージをViewModelから変更できる

f:id:monakaice88:20190615084051g:plain

オーバレイする親を指定するので、Window全体ではなく一部分だけオーバレイすることも可能になった

MainWindowの見た目を以下のように修正

<Window x:Class="WaitingOverlaySample.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:WaitingOverlaySample"
        xmlns:controls="clr-namespace:WaitingOverlaySample.Controls"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800"
        Name="OverlayTarget">

    <Grid>
        <Grid.Resources>
            <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
        </Grid.Resources>

        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <Button Grid.Row="0" Content="5秒待ち" Command="{Binding Wait5SecCommand}" Margin="30"/>
        <Button Grid.Row="1" Content="どんちき♪どんちき♪" Command="{Binding DrummingCommand}" Margin="30"/>

        <StackPanel Grid.Row="2" Name="StackPanel" Margin="20" Background="LightBlue">
            <controls:WaitingOverlay OverlayTargetName="StackPanel"
                                     FixedMessage="{Binding WaitingMessage}"
                                     Visibility="{Binding IsWaiting, Converter={StaticResource BooleanToVisibilityConverter}}"/>
            <Label Content="ココにオーバレイする"/>
        </StackPanel>

    </Grid>

</Window>

動かしてみるとこんな感じ

f:id:monakaice88:20190615084114g:plain

おわりに

MVVMパターンで実装しているオーバレイのサンプルコードが見つからない(調べ方が悪い?)

処理中にぐるぐるとアニメーションをオーバレイするライブラリはあったけど・・・

これを作った一番の正直な理由は、猫がキーボードを叩いているgifをプログラミングで使いたかったんや

ソースコード

github.com