2021年3月14日日曜日

SliderのAutoToolTipPlacementをカスタマイズする

SliderのAutoToolTip

WPFのSliderにはAutoToolTipPlacementというプロパティがあり、これにNone以外の値を設定することで、Sliderを動かしている最中に自動的につまみ(Thumb)に連動するツールチップを表示させ、値をリアルタイムに確認できるようになります。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" />

非常に手軽で強力ですね。

しかし、これが案外WPFらしからぬ機能なのです。というのもWPFはXAMLの力でUIがかなり柔軟に書けるのが特徴なはずなのですが、このツールチップのテンプレートはおろか、数値のフォーマットすら指定できません。申し訳程度にできるのは、AutoToolTipPrecisionプロパティを使って小数点以下の表示桁数を指定する程度です。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="2" />

そのため、例えばDateTimeのような、単純な数値以外を扱うSliderを作っただけで詰んでしまいます。

【MainWindow.xaml】

<Window
    x:Class="SliderTest.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/xaml/behaviors"
    xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
    xmlns:v="clr-namespace:SliderTest.Views"
    xmlns:vc="clr-namespace:SliderTest.Views.Converters"
    xmlns:vm="clr-namespace:SliderTest.ViewModels"
    Title="MainWindow"
    Width="525"
    Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Window.Resources>
        <vc:DateTimeTickConverter x:Key="DateTimeTickConverter" />
    </Window.Resources>

    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodName="Initialize" MethodTarget="{Binding}" />
        </i:EventTrigger>

        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <Grid >
        <Slider VerticalAlignment="Center" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="0"
                Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
                Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
                Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" />
    </Grid>
</Window>

【DateTimeTickConverter.cs】

[ValueConversion(typeof(DateTime), typeof(long))]
public class DateTimeTickConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is DateTime val)
            return val.Ticks;
        else
            return DependencyProperty.UnsetValue;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is double val)
            return new DateTime((long)val);
        else
            return DependencyProperty.UnsetValue;
    }
}

【MainWindowViewModel.cs】

public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        var now = DateTime.Now;
        Minimum = now;
        Value = now;
        Maximum = now + TimeSpan.FromDays(1);
    }


    public DateTime Minimum
    {
        get { return _Minimum; }
        set { 
            if(_Minimum == value)
                return;
            _Minimum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Minimum;


    public DateTime Maximum
    {
        get { return _Maximum; }
        set { 
            if(_Maximum == value)
                return;
            _Maximum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Maximum;


    public DateTime Value
    {
        get { return _Value; }
        set { 
            if(_Value == value)
                return;
            _Value = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Value;
}

何の変哲もない、ただ単にDateTimeをConverter経由でSliderにバインディングするだけのプログラムです。

なのに、このようにツールチップに表示される値はDateTimeのTicksの値になってしまい、アプリユーザーにとっては無意味な数の羅列となってしまうのです。変換しているので当然なのですが、それはもちろんDateTimeがそのままではSliderのMinimum/Maximum/Valueにバインディングできないからです。

通常のWPF脳ならば、BindingのStringFormatみたいなのは無いかなとか、DataTemplateでToolTipをカスタマイズできないかなとか考えるわけです。が、どんなに探してもSliderにあるこの自動ツールチップに設定できるパラメーターは「ツールチップの表示位置」と「数値の小数点以下の桁数」のみなのです。困った困った。

先人の知恵

さて、本件ちょっとググると、まあ10年以上前にこの解決策を提示してくれている人はすぐに見つかるわけです。

Modifying the auto tooltip of a Slider 

比較的シンプルなコードで作っています。Sliderクラスを継承したFormattedSliderクラスを作り、そこにAutoToolTipFormatというプロパティを作る作戦ですね。

ただし、少しコードを読んでいくとビビります。というのも、Sliderクラス内部のprivateフィールドである「_autoToolTip」をリフレクションで無理矢理取り出し、それを改造することで数値のフォーマット化を実現しているのです。 うーん、さすがに無理矢理すぎる気が。現実的にはあまり問題無いのかもしれませんが、例えば.NETの内部実装のリファクタリングで_autoToolTipという名前が変えられただけで正常に動かないソフトになってしまいます。

それが許せる人はこれを使えば良いかもしれませんが、個人的にはちょっと禁忌に触れた黒魔術感があって嫌だなー…。

Behaviorによる実装

さて、やっと本題に入ります。私は黒魔術ではなく正攻法で攻めていきます。

上のようにSliderクラスを継承した新しいクラスを作っても良いのですが、WPFの場合は強力なカスタマイズ機能によりほとんどそのような手順は不要です。既存のクラスに添付して振る舞いを変えさせたければBehaviorでしょう。

【SliderThumbToolTipBehavior.cs】

public class SliderThumbToolTipBehavior : Behavior<Slider>
{
    ToolTip? tooltip;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.AddHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.AddHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.AddHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.RemoveHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.RemoveHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.RemoveHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    void Thumb_DragStarted(object sender, DragStartedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb) {
            if(tooltip == null) {
                tooltip = new ToolTip();
                tooltip.Placement = PlacementMode.Custom;
                tooltip.PlacementTarget = thumb;
                tooltip.CustomPopupPlacementCallback = ToolTip_CustomPopupPlacementCallback;
            }

            thumb.ToolTip = tooltip;
            tooltip.Content = ToolTipTemplate.LoadContent();
            tooltip.IsOpen = true;
            TooltipReposition();
        }
    }

    void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
    {
        TooltipReposition();
    }

    void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb && tooltip != null) {
            tooltip.IsOpen = false;
        }
    }

    void TooltipReposition()
    {
        if(tooltip != null) {
            double temp;
            if(AssociatedObject.Orientation == Orientation.Horizontal) {
                temp = tooltip.HorizontalOffset;
                tooltip.HorizontalOffset += 0.125;
                tooltip.HorizontalOffset = temp;
            } else {
                temp = tooltip.VerticalOffset;
                tooltip.VerticalOffset += 0.125;
                tooltip.VerticalOffset = temp;
            }
        }
    }

    CustomPopupPlacement[] ToolTip_CustomPopupPlacementCallback(Size popupSize, Size targetSize, Point offset)
    {
        CustomPopupPlacement? ret = null;

        switch(Placement) {
            case AutoToolTipPlacement.TopLeft:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, -popupSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(-popupSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
            case AutoToolTipPlacement.BottomRight:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, targetSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(targetSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
        }

        if(ret != null)
            return new CustomPopupPlacement[] { ret.Value };
        else
            return Array.Empty<CustomPopupPlacement>();
    }



    public AutoToolTipPlacement Placement
    {
        get { return (AutoToolTipPlacement)GetValue(PlacementProperty); }
        set { SetValue(PlacementProperty, value); }
    }
    public static readonly DependencyProperty PlacementProperty =
        DependencyProperty.Register(nameof(Placement), typeof(AutoToolTipPlacement), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(AutoToolTipPlacement)));


    public DataTemplate ToolTipTemplate
    {
        get { return (DataTemplate)GetValue(ToolTipTemplateProperty); }
        set { SetValue(ToolTipTemplateProperty, value); }
    }
    public static readonly DependencyProperty ToolTipTemplateProperty =
        DependencyProperty.Register(nameof(ToolTipTemplate), typeof(DataTemplate), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(DataTemplate)));
}

【MainWindow.xaml】(抜粋)

<Slider VerticalAlignment="Center" 
        Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
        Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
        Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" >
    <i:Interaction.Behaviors>
        <vb:SliderThumbToolTipBehavior Placement="TopLeft" >
            <vb:SliderThumbToolTipBehavior.ToolTipTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Value, StringFormat=HH:mm:ss.fff}" />
                </DataTemplate>
            </vb:SliderThumbToolTipBehavior.ToolTipTemplate>
        </vb:SliderThumbToolTipBehavior>
    </i:Interaction.Behaviors>
</Slider>

まず、OnAttatchedで3つの添付イベントをリッスンし、つまみを握ったときにツールチップ表示、動かしたときにツールチップ位置変更、離したときにツールチップ非表示を行います。ミソは、このイベントのe.OriginalSourceにつまみ(Thumbクラス)のインスタンスが入っていると言うことですね。

このビヘイビア自体には2つの依存関係プロパティを用意しています。1つ目はPlacementで、これはSlider本家のAutoToolTipPlacementと同じ役目なので説明はいらないでしょう。2つ目はToolTipTemplateで、ツールチップの中身に表示するものを定義するテンプレートです。これを用意することにより、「値のフォーマット化」にとらわれず自由自在に表示内容を設定できるようにしています。

ツールチップの位置を変える方法ですが、残念ながら直接的に再計算を要求させる方法(ToolTip.CustomPopupPlacementCallbackを呼ばせる方法)は無いようです。ですので、不本意ながら「HorizontalOffset/VerticalOffsetを変更して戻す」という手段を用いることで再計算させています。2度再計算させちゃうので効率悪いんですがね。

XAMLではSlider本家のAutoToolTipPlacementは使わず、ビヘイビアのほうのPlacementを設定します。あとは、ToolTipTemplateにツールチップの内容を設定するだけです。

これで見事ツールチップに意図した通りのものを表示できるようになりました。めでたしめでたし。

0 件のコメント:

コメントを投稿