【WPF】ツールチップにURLリンクを埋め込み良い感じに表示する方法

# 概要
WPFを使用して、ツールチップにURLリンクを埋め込む実装を行ったので、備忘録としてまとめました。
タイトルにはツールチップにURLリンクを埋め込む以外ににも色々と仕様があったので、下記にまとめています。

# 仕様
- 画面にボタンが3つある
- 各ボタンはマウスオーバーすると、文章 + URLリンクがツールチップのように表示される
- ツールチップの表示場所は、ボタンの下部とする
- ツールチップが開かれた状態から他のボタンをマウスオーバーすると、開いていたツールチップは閉じ、マウスオーバーしたボタンのツールチップが開く
- ボタンやツールチップからカーソルが離れると、ツールチップが閉じられる
- ツールチップに表示されるURLは、URLの文字をそのまま表示するのではなく、文章をクリックしたらリンク先に飛ぶようにする
※例えばツールチップの一文で「ここをクリック」と表示されており、この文章をクリックしたらリンク先に飛ぶような挙動
- ツールチップの文章やURLは外部ファイルに定義されるので、バインディングして表示する

# 実装方法
WPFのTooltipにはURLリンクを貼れるような機能は直接は提供されていなかったので、Popupを使用しました。
また、PGにて以下の挙動で実装しました。

#### ボタンをマウスオーバーしたとき
- マウスオーバーされたボタンのツールチップを開く
- マウスオーバーされていないボタンのツールチップは閉じる

#### ボタンがマウスアウトしたとき
- 下記の条件に紐づくツールチップを閉じるイベントを、0.2秒後に発火させる
  - ボタンもしくはそのボタンに紐づくツールチップがマウスオーバーされていない場合、そのボタンのツールチップを閉じる

#### ツールチップのマウスアウト時
- マウスアウトしたツールチップを閉じる

### 上記の組み合わせにより、下記を実現
- ボタンからツールチップにカーソルが移動する際に、ツールチップが消えない
   - ボタンとツールチップの間には隙間が少しあるが、カーソルが隙間を移動する間はツールチップが消えない。※UIの見やすさ的に、ボタンとツールチップの間に隙間が出来る状態とした。

- 複数のボタンの上を素早くカーソル移動させても表示されるツールチップが重複せず、
最後にマウスオーバーされたツールチップのみが表示される
- ボタンまたはツールチップからマウスアウトすると、ツールチップが消える

# コード
### View（xaml）
```xml
<!-- ボタン1つ目 hogeボタン -->
<StackPanel>
    <Button
        x:Name="HogeButton"
        Width="100"
        Content="hogeボタン"
        HorizontalContentAlignment="Center"
        VerticalContentAlignment="Center"
        MouseEnter="Button_MouseEnter"
        MouseLeave="Button_MouseLeave"
        Command="{Binding HogeCommand}"/>
    <Popup
        x:Name="HogePopup"
        Width="285"
        MouseLeave="ButtonPopup_MouseLeave">
        <StackPanel>
            <Border
                Background="WhiteSmoke"
                BorderBrush="Black"
                BorderThickness="0.8">
                <TextBlock Margin="5" Background="WhiteSmoke">
                    <Run Text="{Binding HogeButtonToolTip.Title}" FontWeight="Bold"/>
                    <LineBreak/>
                    <Run Text="{Binding HogeButtonToolTip.Text}"/>
                    <LineBreak/>
                    <Hyperlink
                        NavigateUri="{Binding HogeButtonToolTip.Url}"
                        Click="Hyperlink_Click">
                        <TextBlock Text="{Binding HogeButtonToolTip.UrlText}"/>
                    </Hyperlink>
                </TextBlock>
            </Border>
        </StackPanel>
    </Popup>
</StackPanel>
<!-- ボタン2つ目 hugaボタン -->
<StackPanel>
    <Button
        x:Name="HugaButton"
        Width="100"
        Content="hugaボタン"
        HorizontalContentAlignment="Center"
        VerticalContentAlignment="Center"
        MouseEnter="Button_MouseEnter"
        MouseLeave="Button_MouseLeave"
        Command="{Binding FugaCommand}"/>
    <Popup
        x:Name="HugaPopup"
        Width="285"
        MouseLeave="ButtonPopup_MouseLeave">
        <StackPanel>
            <Border
                Background="WhiteSmoke"
                BorderBrush="Black"
                BorderThickness="0.8">
                <TextBlock Margin="5" Background="WhiteSmoke">
                    <Run Text="{Binding HugaButtonToolTip.Title}" FontWeight="Bold"/>
                    <LineBreak/>
                    <Run Text="{Binding HugaButtonToolTip.Text}"/>
                    <LineBreak/>
                    <Hyperlink
                        NavigateUri="{Binding HugaButtonToolTip.Url}"
                        Click="Hyperlink_Click">
                        <TextBlock Text="{Binding HugaButtonToolTip.UrlText}"/>
                    </Hyperlink>
                </TextBlock>
            </Border>
        </StackPanel>
    </Popup>
</StackPanel>
<!-- ボタン3つ目 piyoボタン -->
<StackPanel>
    <Button
        x:Name="PiyoButton"
        Width="100"
        Content="piyoボタン"
        HorizontalContentAlignment="Center"
        VerticalContentAlignment="Center"
        MouseEnter="Button_MouseEnter"
        MouseLeave="Button_MouseLeave"
        Command="{Binding PiyoCommand}"/>
    <Popup
        x:Name="PiyoPopup"
        Width="285"
        MouseLeave="ButtonPopup_MouseLeave">
        <StackPanel>
            <Border
                Background="WhiteSmoke"
                BorderBrush="Black"
                BorderThickness="0.8">
                <TextBlock Margin="5" Background="WhiteSmoke">
                    <Run Text="{Binding PiyoButtonToolTip.Title}" FontWeight="Bold"/>
                    <LineBreak/>
                    <Run Text="{Binding PiyoButtonToolTip.Text}"/>
                    <LineBreak/>
                    <Hyperlink
                        NavigateUri="{Binding PiyoButtonToolTip.Url}"
                        Click="Hyperlink_Click">
                        <TextBlock Text="{Binding PiyoButtonToolTip.UrlText}"/>
                    </Hyperlink>
                </TextBlock>
            </Border>
        </StackPanel>
    </Popup>
</StackPanel>
```

### View（コードビハインド）
```C#
       /// <summary>
        /// ボタン MouseEnterイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Button_MouseEnter(object sender, MouseEventArgs e)
            => HandleButtonTooltipOnMouseOver((Button)sender);

        /// <summary>
        /// ボタン MouseLeaveイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Button_MouseLeave(object sender, MouseEventArgs e)
        {
            var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(0.2) };
            timer.Tick += ClosePopupsOnTimerTick;
            timer.Start();
        }

        /// <summary>
        /// ボタンのPopup MouseLeaveイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void ButtonPopup_MouseLeave(object sender, MouseEventArgs e)
            => ClosePopup((Popup)sender);

        /// <summary>
        /// Hyperlink Clickイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Hyperlink_Click(object sender, RoutedEventArgs e)
        {
            var link = (Hyperlink)sender;
            Process.Start(link.NavigateUri.ToString());
        }

        /// <summary>
        ///  マウスオーバーされたボタンのツールチップを表示し、
        ///  それ以外のボタンのツールチップを非表示にする
        /// </summary>
        /// <param name="button">マウスオーバーされたボタンのオブジェクト</param>
        private void HandleButtonTooltipOnMouseOver(Button button)
        {
            switch (button.Name)
            {
                case "HogeButton":
                    HogePopup.IsOpen = true;
                    HugaPopup.IsOpen = false;
                    PiyoPopup.IsOpen = false;
                    break;

                case "hugaButton":
                    HogePopup.IsOpen = false;
                    HugaPopup.IsOpen = true;
                    PiyoPopup.IsOpen = false;
                    break;

                case "piyoButton":
                    HogePopup.IsOpen = false;
                    HugaPopup.IsOpen = false;
                    PiyoPopup.IsOpen = true;
                    break;

                default:
                    // エラー処理
                    break;
            }
        }

        /// <summary>
        /// ボタンのポップアップを閉じるタイマーイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void ClosePopupsOnTimerTick(object sender, EventArgs e)
        {
            // ボタンもしくはそのボタンに紐づくツールチップがマウスオーバーされていない場合、
            // そのボタンのツールチップを閉じる
            if (!HogeButton.IsMouseOver && !HogePopup.IsMouseOver) HogePopup.IsOpen = false;
            if (!HugaButton.IsMouseOver && !HugaPopup.IsMouseOver) HugaPopup.IsOpen = false;
            if (!PiyoButton.IsMouseOver && !PiyoPopup.IsMouseOver) PiyoPopup.IsOpen = false;

            var timer = (DispatcherTimer)sender;
            timer.Stop();
        }

        /// <summary>
        /// ツールチップを閉じる
        /// </summary>
        /// <param name="popup">ツールチップのオブジェクト</param>
        private void ClosePopup(Popup popup)
        {
            var panel = (StackPanel)popup.Parent;
            foreach (var child in panel.Children)
            {
                var button = child as Button;
                if (button == null) continue;
                switch (button.Name)
                {
                    case "HogeButton":
                        HogePopup.IsOpen = false;
                        break;

                    case "HugaButton":
                        HugaPopup.IsOpen = false;
                        break;

                    case "PiyoButton":
                        PiyoPopup.IsOpen = false;
                        break;

                    default:
                        //　エラー処理 
                        break;
                }
            }
        }
```

### ViewModel

```C#

        /// <summary>
        /// hogeボタンのボタンガイド情報
        /// </summary>
        public ButtonTooltip HogeButtonToolTip { get; private set; } = new ButtonTooltip();

        /// <summary>
        /// fugaボタンのボタンガイド情報
        /// </summary>
        public ButtonTooltip FugaButtonToolTip { get; private set; } = new ButtonTooltip();

        /// <summary>
        /// piyoのボタンガイド情報
        /// </summary>
        public ButtonTooltip PiyoButtonToolTip { get; private set; } = new ButtonTooltip();

        /// <summary>
        /// hogeボタン押下時のコマンド
        /// </summary>
        public ICommand HogeCommand => _hogeCommand ?? (_hogeCommand = new DelegateCommand<object>(_ =>
        {
            // hogeボタン押下後の処理
        }));
        private DelegateCommand<object> _hogeCommand;

        /// <summary>
        /// hugaボタン押下時のコマンド
        /// </summary>
        public ICommand HugaCommand => _hugaCommand ?? (_hugaCommand = new DelegateCommand<object>(_ =>
        {
            // hugaボタン押下後の処理
        }));
        private DelegateCommand<object> _hugaCommand;

        /// <summary>
        /// piyoボタン押下時のコマンド
        /// </summary>
        public ICommand PiyoCommand => _piyoCommand ?? (_piyoCommand = new DelegateCommand<object>(_ =>
        {
            // piyoボタン押下後の処理
        }));
        private DelegateCommand<object> _piyoCommand;

        /// <summary>
        /// 外部ファイルのデータを元にボタンガイド情報を設定する
        /// </summary>
        /// <param name="buttonGuides">ボタンガイド情報の表示内容が定義された外部のxmlファイルから読み込んだデータ</param>
        private void SetButtonGuide(ButtonGuideXmlData xmlData)
        {
            if (xmlData == null) return;

            // Hogeボタン         
            HogeButtonToolTip.Title = xmlData.Title;
            HogeButtonToolTip.Text = xmlData.Text;
            HogeButtonToolTip.Url = xmlData.LinUrl;
            HogeButtonToolTip.UrlText = xmlData.UrlText;

            // Hugaボタン         
            HugaButtonToolTip.Title = xmlData.Title;
            HugaButtonToolTip.Text = xmlData.Text;
            HugaButtonToolTip.Url = xmlData.LinUrl;
            HugaButtonToolTip.UrlText = xmlData.UrlText;

            // Piyoボタン         
            PiyoButtonToolTip.Title = xmlData.Title;
            PiyoButtonToolTip.Text = xmlData.Text;
            PiyoButtonToolTip.Url = xmlData.LinUrl;
            PiyoButtonToolTip.UrlText = xmlData.UrlText;
        }

```

### ボタンガイド情報に関するデータクラス
```C#

    public class ButtonTooltip
    {
        /// <summary>
        /// ツールチップのタイトル
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        /// ツールチップの文章
        /// </summary>
        public string Text { get; set; }

        /// <summary>
        /// ツールチップのURLの文章
        /// </summary>
        public string UrlText { get; set; }

        /// <summary>
        /// ツールチップのURL
        /// </summary>
        public string Url { get; set; }
    }
```