2015年11月12日木曜日

WPFでタスクトレイ常駐型アプリを作る

「Windowsでデスクトップアプリを作るならWPF」

そんなWPFでも、弱点が無いわけではありません。その弱点の1つとして、タスクトレイアイコンの非対応が挙げられます。なので、何かしら、WPFとは違うフレームワークを使ってタスクトレイへのアイコン表示をやらなければならないわけですね。
例えば、Windows Formsを使って表示する方法なんかがその代表例でしょう。 ただ、WPFアプリケーションにSystem.Windows.Formsの参照を追加することへの抵抗感や、WPFとWinFormsを相互運用する詳しい知識などが必要で、なかなかハードルの高いものがあります。

そこで、それをラッピングしたライブラリが世の中にはあります。

WPF NotifyIcon

なかなか完成度の高いライブラリです。NuGetからインストールすることもできます。早速これを使ってみましょう。

NotifyIconのXAMLデザイニング

NuGetからWPF NotifyIconをダウンロードしてインストールしたら、早速タスクバーアイコンを表示してみましょう。もちろんXAMLからアイコンを表示させることができます。

<Window x:Class="WpfNotifyIconTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:WpfNotifyIconTest.Views"
        xmlns:vm="clr-namespace:WpfNotifyIconTest.ViewModels"
        xmlns:tb="http://www.hardcodet.net/taskbar"
        Title="MainWindow" Height="350" Width="525">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
    
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

    </i:Interaction.Triggers>

    <tb:TaskbarIcon IconSource="/WpfNotifyIconTest;component/Images/icon.ico" />
</Window>

Livetを使っているので、プロジェクトテンプレートからi:Interaction.Triggers要素の付いたXAMLが勝手に出来上がってきています。
まずは名前空間を指定します。xmlns:tb="http://www.hardcodet.net/taskbar"とでも置いておきましょう。そして、WindowのContentにTaskbarIconコントロールのインスタンスを生成しています。これだけでタスクバーにアイコンが現れます。アイコンは適当なアイコンを作って、そのパスを指定してあげています。
今回作ったのはこの「あ」のアイコンです。どんどんアレンジしていきましょう。

ツールチップを表示する

<tb:TaskbarIcon IconSource="/WpfNotifyIconTest;component/Images/icon.ico" ToolTipText="「あ!」" />

ToolTipTextプロパティはツールチップのテキストに対応しています。マウスをかざすと文字が出てきます。
もちろん依存関係プロパティになっているのでバインディングすることも可能です。ちなみに、改行もちゃんとできます。素晴らしいです。

WPF製のツールチップ

WPF NotifyIconの威力はこんなもんじゃありません。  実は、ToolTipTextプロパティとは別にTrayToolTipプロパティがあって、ここには任意のUIElementが入れられます。

<tb:TaskbarIcon IconSource="/WpfNotifyIconTest;component/Images/icon.ico" >
    <tb:TaskbarIcon.TrayToolTip>
        <Border BorderBrush="Gray" BorderThickness="1" CornerRadius="1" >
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                    <GradientStop Color="White" Offset="0" />
                    <GradientStop Color="LightGray" Offset="1" />
                </LinearGradientBrush>
            </Border.Background>
            <Border.BitmapEffect>
                <DropShadowBitmapEffect Color="Gray" ShadowDepth="4" Softness="0.3"/>
            </Border.BitmapEffect>
            <TextBlock Text="あ!" FontSize="30" Margin="2,2,2,2" />
        </Border>
    </tb:TaskbarIcon.TrayToolTip>
</tb:TaskbarIcon>

自前でいい感じにツールチップっぽい枠を作ってテキストを表示しています。
このように、巨大なツールチップがいとも簡単に完成してしまいました。もちろん今回はテキストしか表示しませんでしたが、Imageタグ等を使って画像を表示したりすることも容易いです。非WPFのタスクトレイアイコンにここまでWPFらしい機能が取り入れられているWPF NotifyIcon、すごいです。

コンテキストメニュー

さて、一通りツールチップをいじり倒した後で、次はコンテキストメニューです。右クリックで出てくるメニューです。

<tb:TaskbarIcon IconSource="/WpfNotifyIconTest;component/Images/icon.ico" DoubleClickCommand="{Binding ConfigurationCommand}" >
    <tb:TaskbarIcon.TrayToolTip>
        <Border BorderBrush="Gray" BorderThickness="1" CornerRadius="1" >
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                    <GradientStop Color="White" Offset="0" />
                    <GradientStop Color="LightGray" Offset="1" />
                </LinearGradientBrush>
            </Border.Background>
            <Border.BitmapEffect>
                <DropShadowBitmapEffect Color="Gray" ShadowDepth="4" Softness="0.3"/>
            </Border.BitmapEffect>
            <TextBlock Text="あ!" FontSize="30" Margin="2,2,2,2" />
        </Border>
    </tb:TaskbarIcon.TrayToolTip>
    <tb:TaskbarIcon.ContextMenu>
        <ContextMenu>
            <MenuItem Header="_Configuration" FontWeight="Bold" Command="{Binding ConfigurationCommand}" />
            <MenuItem Header="_Exit" Command="{Binding ExitCommand}" />
        </ContextMenu>
    </tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>


TaskbarIcon.ContextMenuにContextMenu要素を入れてあげれば大丈夫です。今回は、設定ウィンドウを出すConfigurationメニューを太字にして、ダブルクリックでこれが起動できるということを示してみました。TaskbarIconはダブルクリック時にDoubleClickCommandを実行してくれます。
このあたりで、一通りのXAMLでできることは完成ですかね。

バルーンチップを出す

さて、タスクトレイアイコンから吹き出しが出て通知する機能をよく見たことがあると思います。
これはWindows8.1でのバルーンチップです。Windows10からはこのように横から四角いのが出てくる形になっております。
さて、これをWPF NotifyIconで出すこと自体はとても簡単です。TaskbarIconのShowBalloonTipメソッドを呼び出すだけです。すなわち、コードビハインドに全部書いてしまえば楽なのですが、 MVVMの体裁を維持したままコントロールのメソッドを呼び出すのはなかなか大変です。MVVMでやるにはMessengerパターンでViewModelからバルーンチップを出せるようにするのがよいでしょう。
私は普段からWPFのMVVMライブラリとしてLivetを使っておりますので、LivetでMessengerパターンを実装する方法を説明していきます。他のライブラリを使っている人は適宜そちらに置き換えて考えてみてください。

Livetでは、ViewModelクラスにMessengerプロパティがあります。これのRaiseメソッドにInteractionMessageクラスのインスタンスを引き渡すことでViewにメッセージを送れます。
Livetには例えばメッセージボックスを表示させるためにInteractionMessageや、ウィンドウを閉じたり最大化したりするためのInteractionMessageが標準で用意されていますが、もちろんバルーンチップを表示するためのInteractionMessageは用意されていないので自前で用意する必要があります。

/// <summary>
/// バルーンチップを表示するための相互作用メッセージです
/// </summary>
public class BalloonTipMessage : InteractionMessage
{
    /// <summary>
    /// コンストラクタ
    /// </summary>
    public BalloonTipMessage() : base() { }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="MessageKey">メッセージキー</param>
    public BalloonTipMessage(string MessageKey) : base(MessageKey) { }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="Title">バルーンのタイトル</param>
    /// <param name="Message">バルーンのメッセージ</param>
    /// <param name="Icon">バルーンアイコン</param>
    /// <param name="MessageKey">メッセージキー</param>
    public BalloonTipMessage(string Title, string Message, BalloonIcon Icon, string MessageKey) : base(MessageKey)
    {
        this.Title = Title;
        this.Message = Message;
        this.Icon = Icon;
    }

    protected override Freezable CreateInstanceCore()
    {
        return new BalloonTipMessage(MessageKey);
    }

    /// <summary>
    /// バルーンのタイトル
    /// </summary>
    public string Title
    {
        get { return (string)GetValue(TitleProperty); }
        set { SetValue(TitleProperty, value); }
    }
    public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(nameof(Title), typeof(string), typeof(BalloonTipMessage), new PropertyMetadata(null));

    /// <summary>
    /// バルーンのメッセージ
    /// </summary>
    public string Message
    {
        get { return (string)GetValue(MessageProperty); }
        set { SetValue(MessageProperty, value); }
    }
    public static readonly DependencyProperty MessageProperty = DependencyProperty.Register(nameof(Message), typeof(string), typeof(BalloonTipMessage), new PropertyMetadata(null));

    /// <summary>
    /// バルーンアイコン
    /// </summary>
    public BalloonIcon Icon
    {
        get { return (BalloonIcon)GetValue(IconProperty); }
        set { SetValue(IconProperty, value); }
    }
    public static readonly DependencyProperty IconProperty = DependencyProperty.Register(nameof(Icon), typeof(BalloonIcon), typeof(BalloonTipMessage), new PropertyMetadata(BalloonIcon.None));
}

こんな感じでどうでしょうか。バルーンチップに引き渡す必要があるパラメーターはこのように依存関係プロパティで用意しておきます。そして、コンストラクタに数種類にオーバーロードを用意しておき、あとはFreezable.CreateInstanceCore()をオーバーライドしておくだけです。これは、自分のインスタンスを新しく作って返すだけで大丈夫です。

さて、今度はこれをView側で受け取って何かしらコントロールを動かす仕組みを作ります。いわゆる「アクション」です。これはInteractionMessageより実装はとても楽です。

public class BalloonTipMessageAction : InteractionMessageAction<TaskbarIcon>
{
    protected override void InvokeAction(InteractionMessage message)
    {
        var msg = message as BalloonTipMessage;

        if(msg != null)
            this.AssociatedObject.ShowBalloonTip(msg.Title, msg.Message, msg.Icon);
    }
}

InteractionMessageAction<T>のTには、そのアクションを実行するコントロールの型を入れます。今回はTaskbarIconです。
そして、InvokeActionメソッドをオーバーライドし、InteractionMessageがBalloonTipMessageのときのみアクションを実行します。TaskbarIconのインスタンスはthis.AssociatedObjectですので、このShowBalloonTipメソッドを呼び出せばオッケーです。
ちなみに、ShowBalloonTipメソッドを呼び出すときには、内部的にWinFormsを使っている都合か、System.Drawingの参照を要求されます。ここは意地を張っても仕方ないので、泣く泣く参照を付けましょう。

最後に、TaskbarIconにこのアクションを登録してあげます。

<tb:TaskbarIcon IconSource="/WpfNotifyIconTest;component/Images/icon.ico" DoubleClickCommand="{Binding ConfigurationCommand}" >
    <tb:TaskbarIcon.TrayToolTip>
        <Border BorderBrush="Gray" BorderThickness="1" CornerRadius="1" >
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                    <GradientStop Color="White" Offset="0" />
                    <GradientStop Color="LightGray" Offset="1" />
                </LinearGradientBrush>
            </Border.Background>
            <Border.BitmapEffect>
                <DropShadowBitmapEffect Color="Gray" ShadowDepth="4" Softness="0.3"/>
            </Border.BitmapEffect>
            <TextBlock Text="あ!" FontSize="30" Margin="2,2,2,2" />
        </Border>
    </tb:TaskbarIcon.TrayToolTip>
    <tb:TaskbarIcon.ContextMenu>
        <ContextMenu>
            <MenuItem Header="_Configuration" FontWeight="Bold" Command="{Binding ConfigurationCommand}" />
            <MenuItem Header="_Exit" Command="{Binding ExitCommand}" />
        </ContextMenu>
    </tb:TaskbarIcon.ContextMenu>
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger MessageKey="BalloonTip" Messenger="{Binding Messenger}">
            <m:BalloonTipMessageAction InvokeActionOnlyWhenWindowIsActive="False" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</tb:TaskbarIcon>

最後の<i:Interaction.Triggers>というところです。ここで、このアクションのメッセージキーを設定し、BalloonTipMessageActionを設定してあげています。m:は、このアクションを実装した名前空間の参照です。そして、Livetの最新版の1.3では、アクションはウィンドウがアクティブな時しか実行されないようになっているので、非アクティブでも実行できるようにInvokeActionOnlyWhenWindowIsActiveをFalseにしておきます。前のバージョンでは一時期ハマりやすいポイントということでデフォルトでこれがFalseになってたようですが、それより前にデフォルトでTrueになっていた時代があったせいで混乱が起き、結局デフォルトでTrueになるように戻ったようです。

ViewModelからバルーンチップを表示させるのはこのようにすればおkです。

Messenger.Raise(new BalloonTipMessage("タイトル", "メッセージ内容", Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info, "BalloonTip"));

これでバルーンチップを表示する手順は終了です。

実は、ツールチップの表示について、内部的に非WPFのToolTipTextプロパティに対してWPFのTrayToolTipプロパティがあるように、バルーンチップの表示でも非WPFのShowBalloonTipメソッドに対してWPFのShowCustomBalloonメソッドがあったりします。が、今回はこの話は割愛させていただきます(実は私もあまり触ってみたことが無いため)。興味のある方はぜひいじってみてください。

MainWindowを消す

さて、ここまでタスクトレイアイコンに求められる主なことを一通りやってきましたが、ここで気づいたと思います。

_人人人人人人人人人人人人_
> MainWindowいらなくね? <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

実は、このプログラムを実行するたびにMainWindowが表示されていました。
アプリケーションにもよりますが、例えば常駐中は一切ウィンドウを出さず、設定画面など必要なときのみウィンドウを表示する、といった使い方は大いにあり得そうです。そのようなときに、このようなウィンドウは障害となってしまいます。しかし、TaskbarIconはXAML上で定義されているので、MainWindowクラスをインスタンス化しない限りタスクトレイにアイコンすら表示できなくなってしまいそうです。いったいどうすればいいでしょう。

「MainWindowを非表示にする」もしくは「MainWindowを隠す」といったあたりの方針でいろいろ試してみましたが、現状の最適解としてはこんな感じですかね。

<Window x:Class="WpfNotifyIconTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:WpfNotifyIconTest.Views"
        xmlns:m="clr-namespace:WpfNotifyIconTest.Views.Messaging"
        xmlns:vm="clr-namespace:WpfNotifyIconTest.ViewModels"
        xmlns:tb="http://www.hardcodet.net/taskbar"
        WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False" Width="0" Height="0"
        WindowStartupLocation="Manual" Top="-100" Left="-100" SourceInitialized="Window_SourceInitialized" >

    <!-- 中略 -->

</Window>

まず、WindowStyleをNoneにしています。これによって、一切タイトルバーや枠などの無いウィンドウになります。ResizeModeはNoResizeにして、隠したいウィンドウが大きいウィンドウサイズに変更されないようにしています。タスクバーに表示したくないので、ShowInTaskbarはFalseにしています。そして、ウィンドウの幅、高さをゼロにして、ウィンドウを画面の座標のマイナスの方向に吹っ飛ばしています。

実は、これだけだと3つ問題があります。

1つ目は、幅・高さともにゼロとはいえ、実は小さなサイズのウィンドウが存在してしまっていることです。適当にウィンドウを正の座標に持ってきてからよ~く観察すると、小さい点があることに気が付きます。
デスクトップの背景に同化して気づいていませんでしたが、マウスで適当に範囲選択すると、小さいポツがデスクトップにあることに気が付きます。これが今回作ったウィンドウです。最初は、液晶のドット欠けか何かかなあと思っていましたが、場合によっては見えなかったりするのでなんだろうって思っていたら、このアプリケーションのウィンドウだったわけですね。
マイナスの座標にウィンドウをもっていけば一応ごまかせますが、マルチディスプレイとかで、マイナスの座標にサブディスプレイを配置することは可能ですので、そのような場合は見えてくるかもしれません。そのとき、この小さなウィンドウだったら「え?液晶のドット欠けじゃね?」とごまかせます。これが1つ目の問題ですね。

2つ目の問題は、Alt+Tabなどでこのウィンドウが見えてしまうことです。これに関しては解決することが可能です。MainWindowをツールウィンドウにすればいいのです。実は、上記のXAMLにSourceInitializedイベントのハンドラが登録されています。なので、MainWindow.のコードビハインドを見てみましょう。

/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Window_SourceInitialized(object sender, EventArgs e)
    {
        var helper = new WindowInteropHelper(this);
        var exStyle = (Native.WS_EX)Native.GetWindowLontPtr(helper.Handle, (int)Native.GWL.EXSTYLE).ToInt32();
        exStyle |= Native.WS_EX.TOOLWINDOW;
        Native.SetWindowLongPtr(helper.Handle, (int)Native.GWL.EXSTYLE, new IntPtr((int)exStyle));
    }

    private class Native
    {
        [Flags]
        public enum WS_EX : int
        {
            TOOLWINDOW = 0x80,
        }

        public enum GWL : int
        {
            EXSTYLE = -20,
        }

        #region GetWindowLontPtr

        [DllImport("user32.dll", EntryPoint = "GetWindowLong")]
        private static extern IntPtr _GetWindowLong(IntPtr hWnd, int nIndex);

        [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
        private static extern IntPtr _GetWindowLongPtr(IntPtr hWnd, int nIndex);

        public static IntPtr GetWindowLontPtr(IntPtr hWnd, int nIndex)
        {
            return IntPtr.Size == 8 ? _GetWindowLongPtr(hWnd, nIndex) : _GetWindowLong(hWnd, nIndex);
        }

        #endregion

        #region SetWindowLongPtr

        [DllImport("user32.dll", EntryPoint = "SetWindowLong")]
        private static extern int _SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);

        [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
        private static extern IntPtr _SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);

        public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong)
        {
            return IntPtr.Size == 8 ? _SetWindowLongPtr(hWnd, nIndex, dwNewLong) : new IntPtr(_SetWindowLong(hWnd, nIndex, dwNewLong.ToInt32()));
        }

        #endregion
    }
}

コードビハインドではネイティブWin32APIをたたいてこのウィンドウをToolWindowに設定しています。なぜXAMLのWindowStyleでToolWindowを指定しないのかというと、そこで設定してしまうとウィンドウのサイズが上記のような極小サイズにならないからです。×ボタンくらいのサイズになります。なので、コードビハインドで拡張ウィンドウスタイルでツールウィンドウを指定してあげています。
拡張ウィンドウスタイルを取得するにはGetWindowLongPtr関数、設定するにはSetWindowLongPtr関数を呼び出せば良いのですが、よくよくwinuser.hを読むと、64bit環境では確かにGetWindowLongPtr関数を呼び出しているのですが、32bit環境ではGetWindowLong関数を呼んでいることがわかります。まあポインタのサイズが32bitと64bitで違うので仕方ないですね。なので、C#でこれを呼び出すときはIntPtr.Sizeをチェックすることで自らのアプリケーションが32bitか64bitかを判定し呼び分けています。
これで、Alt+Tabでも画面が見えない状態で、かつドットサイズのウィンドウに保つことができました。

最後の問題点は、起動直後にAlt+F4でこのソフトが終了できてしまうことです。別のアプリケーションに一度移動すれば、Alt+Tab等ででも切り替えることができなくなるので終了させてしまうことはなくなりますが、起動直後はアクティブなようでAlt+F4で終了できてしまいます。これについては、解決策を見つけることはできませんでした。いやいや、キーフックでもすりゃいいいのかもしれませんが…。


ということで、一応2つほど(ドットサイズのウィンドウの存在、起動直後Alt+F4で終了できてしまう点)問題が残っていますが、ウィンドウを消すことができました。


さて、ここまでで 、XAMLによるNotifyIconのデザイン、Messengerパターンによるバルーンチップ表示の手法、そしてMainWindowを消す話の3種類を見てきました。これで、WPFによる常駐型アプリケーションがそれなりに実用的な形で作ることができるのではないでしょうか。

0 件のコメント:

コメントを投稿