アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

RatingSelector

音楽や写真などを管理するソフトでは、コンテンツに評価を付ける機能を持つものがある。身近な例では Windows Vista の Explorer 下部に表示される「評価」とか。

Vista の「評価」コントロール

こうしたコントロールは 5 段階の星印で表すのが一般的なようだ。今回は WPF のユーザーコントロールとして、こんな感じのものを作成してみる。

サンプル プログラム

サンプル プログラムのプロジェクト一式は以下。ビルドには Visual Studio 2008 SP1、プログラムの実行には NET Framework 3.5 SP1 が必要となる。

仕様

はじめに評価付けコントロールの仕様を簡単にまとめる。

  • 5 段階の星印で評価
  • 評価の内部表現は 0 ~ 5 の int 型とする
  • 星印の色を XAML 上から設定 (変更) できるようにする
  • 星印は画像ではなく Path (ベクター データ) として定義、解像度非依存とする

コントロール名は Rating (評価) をあらかじめ定義された 5 段階から Select (選択) するので RatingSelector とする。

XAML

まず RatingSelector という名前のユーザー コントロールを作成し、XAML を以下のように定義。

<UserControl x:Class="WpfRatingSelector.Controls.RatingSelector"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Name="MyRatingSelector">

    <!-- リソース -->
    <UserControl.Resources>
        <ControlTemplate x:Key="RatingButtonTemplate" TargetType="{x:Type ToggleButton}">
            <!-- 標準状態 -->
            <Viewbox>
                <!--
                    MouseEnter の判定を矩形にする為に、透明な枠で囲う。
                    Path のみで構成した場合は、Path 内の塗り潰された領域のみが、MouseEnter や Click の対象となる。
                -->
                <Border Background="Transparent" >
                    <Path Name="RatingIcon" Fill="{Binding ElementName=MyRatingSelector, Path=RatingUnselectedForeground}" Data="F1 M 145.637,174.227L 127.619,110.39L 180.809,70.7577L 114.528,68.1664L 93.2725,5.33333L 70.3262,67.569L 4,68.3681L 56.0988,109.423L 36.3629,172.75L 91.508,135.888L 145.637,174.227 Z" />
                </Border>
            </Viewbox>

            <!-- トリガー -->
            <ControlTemplate.Triggers>
                <!-- 選択状態 -->
                <Trigger Property="IsChecked" Value="True">
                    <Setter TargetName="RatingIcon" Property="Fill" Value="{Binding ElementName=MyRatingSelector, Path=RatingSelectedForeground}"/>
                </Trigger>

                <!-- 選択・非選択のどちらでもない状態 -->
                <Trigger Property="IsChecked" Value="{x:Null}">
                    <Setter TargetName="RatingIcon" Property="Fill" Value="{Binding ElementName=MyRatingSelector, Path=RatingIndeterminateForeground}"/>
                </Trigger>
            </ControlTemplate.Triggers>

        </ControlTemplate>
    </UserControl.Resources>

    <!-- UI -->
    <StackPanel Orientation="Horizontal" MouseLeave="OnRatingPanelMouseLeave">
        <ToggleButton Tag="1" IsThreeState="True" Template="{StaticResource RatingButtonTemplate}" Cursor="Hand" Click="OnRatingButtonClick" MouseEnter="OnRatingButtonMouseEnter" FocusVisualStyle="{x:Null}" />
        <ToggleButton Tag="2" IsThreeState="True" Template="{StaticResource RatingButtonTemplate}" Cursor="Hand" Click="OnRatingButtonClick" MouseEnter="OnRatingButtonMouseEnter" FocusVisualStyle="{x:Null}" />
        <ToggleButton Tag="3" IsThreeState="True" Template="{StaticResource RatingButtonTemplate}" Cursor="Hand" Click="OnRatingButtonClick" MouseEnter="OnRatingButtonMouseEnter" FocusVisualStyle="{x:Null}" />
        <ToggleButton Tag="4" IsThreeState="True" Template="{StaticResource RatingButtonTemplate}" Cursor="Hand" Click="OnRatingButtonClick" MouseEnter="OnRatingButtonMouseEnter" FocusVisualStyle="{x:Null}" />
        <ToggleButton Tag="5" IsThreeState="True" Template="{StaticResource RatingButtonTemplate}" Cursor="Hand" Click="OnRatingButtonClick" MouseEnter="OnRatingButtonMouseEnter" FocusVisualStyle="{x:Null}" />
    </StackPanel>
</UserControl>

評価は以下の状態をもつ。

  • 選択
  • 非選択
  • それ以外

ここは IsChecked プロパティによる状態遷移を管理できる ToggleButton にするのが適切だろう。通常時を「非選択」、Trigger で IsChecked の true/false を「非選択」と「それ以外」として扱い、星印の前景色ブラシを変更するようにしている。

コメントにもあるがコントロールの表示部分を Path にすると、マウスのクリック判定が Path の内部だけになる。つまり見た目と操作対象が自動的に一致する。

しかし星印のような図形だと見た目どおりにクリック領域を設定すると操作しにくいので、Border を Path の親にする。この対応によりクリック領域が矩形となり操作しやすくなる。

ToggleButton について。

どの評価ボタンが操作されたかを識別する為に Tag プロパティに対応する数値文字列を割り振っている。Tag は任意のデータを格納する為のプロパティで、今回のような個体識別にも使用できる。詳しくは FrameworkElement.Tag プロパティを参照の事。

コードビハインド

RatingSelector のコードビハインドは以下のようになる。

実際には星印の色を設定する為の依存プロパティも定義しているが、コントロールの本質ではないので、割愛した。

/// <summary>
/// RatingSelector.xaml の相互作用ロジックです。
/// </summary>
public partial class RatingSelector : UserControl
{
    /// <summary>
    /// レーティングとして設定可能な最小値です。
    /// </summary>
    public const int Min = 0;

    /// <summary>
    /// レーティングとして設定可能な最大値です。
    /// </summary>
    public const int Max = 5;

    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    public RatingSelector()
    {
        this.InitializeComponent();
    }

    /// <summary>
    /// レーティング変更ボタンのチェック状態を更新します。
    /// </summary>
    /// <param name="rating">新しいレーティング。</param>
    /// <param name="children">更新対象となるレーティング変更ボタンのコレクション。</param>
    private static void UpdateRatingButtonState( int rating, UIElementCollection children )
    {
        // レーティングの設定範囲内となるボタンをチェック状態とする
        for( int index = 0; index < rating; ++index )
        {
            ToggleButton button = children[ index ] as ToggleButton;
            if( button != null )
            {
                button.IsChecked = true;
            }
        }

        // レーティングの設定範囲外となるボタンのチェック状態を解除する
        for( int index = rating; index < children.Count; ++index )
        {
            ToggleButton button = children[ index ] as ToggleButton;
            if( button != null )
            {
                button.IsChecked = false;
            }
        }
    }

    /// <summary>
    /// レーティング変更ボタンのチェック状態を更新します。
    /// </summary>
    /// <param name="rating">確定しているレーティング。</param>
    /// <param name="tempRating">一時的なレーティング。</param>
    /// <param name="children">更新対象となるレーティング変更ボタンのコレクション。</param>
    private static void UpdateRatingButtonState( int rating, int tempRating, UIElementCollection children )
    {
        // レーティングの設定範囲外となるボタンのチェック状態を解除する
        for( int index = rating; index < children.Count; ++index )
        {
            ToggleButton button = children[ index ] as ToggleButton;
            if( button != null )
            {
                button.IsChecked = false;
            }
        }

        // レーティングの設定範囲内となるボタンをチェック状態とする
        for( int index = 0; index < tempRating; ++index )
        {
            ToggleButton button = children[ index ] as ToggleButton;
            if( button != null )
            {
                button.IsChecked = true;
            }
        }

        // 新しいレーティングから、確定している部分までを、選択・非選択以外の状態とする。
        // 例えば ★★★☆☆ の状態から ★☆☆☆☆ になろうとしている場合は、★◇◇☆☆ のようになる。
        // この処理により、現在より低いレーティングが設定されようとしている事が、分かり易くなる。
        //
        for( int index = tempRating; index < rating; ++index )
        {
            ToggleButton button = children[ index ] as ToggleButton;
            if( button != null )
            {
                button.IsChecked = null;
            }
        }
    }

    /// <summary>
    /// <para>
    /// レーティング変更ボタンがクリックされた時のイベントです。
    /// </para>
    /// <para>
    /// 現在のレーティングが最小値、または現在値と異なるレーティング変更ボタンがクリックされた場合は、レーティング値の更新を行い、
    /// それ以外の場合は、現在値と一致する値が選択された事になるので、レーティングを 1 段階分、減らします。
    /// </para>
    /// <para>
    /// 例えば ★☆☆☆☆ の状態でクリックが行われた場合、ボタンが 3 番目ならば ★★★☆☆、1 番目の時は ☆☆☆☆☆ が設定されます。
    /// </para>
    /// </summary>
    /// <param name="sender">イベント送信元。</param>
    /// <param name="e">イベントデータ。</param>
    private void OnRatingButtonClick( object sender, RoutedEventArgs e )
    {
        ToggleButton button = sender as ToggleButton;
        if( button == null ) { return; }

        int rating = int.Parse( ( string )button.Tag );
        if( this.Rating == RatingSelector.Min || rating != this.Rating )
        {
            this.Rating = rating;
        }
        else
        {
            --this.Rating;
        }

        e.Handled = true;
    }

    /// <summary>
    /// <para>
    /// レーティング変更ボタンの上にマウスカーソルが入った時のイベントです。
    /// このイベントにより、1 番目 ~ カーソル下のボタンまでをチェック状態として表示されるようにします。
    /// </para>
    /// <para>
    /// 例えば ★★☆☆☆ の状態で 4 番目のボタン上にカーソルが入った場合は、★★★★☆ となります。
    /// この処理の目的は、ユーザに入力されるレーティング状態を提示する事となり、実際のレーティング更新は、ボタンがクリックされた時に行われます。
    /// </para>
    /// </summary>
    /// <param name="sender">イベント送信元。</param>
    /// <param name="e">イベントデータ。</param>
    private void OnRatingButtonMouseEnter( object sender, MouseEventArgs e )
    {
        ToggleButton button = sender as ToggleButton;
        if( button == null ) { return; }

        int                    rating   = int.Parse( ( string )button.Tag );
        UIElementCollection    children = ( ( StackPanel )( this.Content ) ).Children;

        // 一時的なレーティングを表示する
        RatingSelector.UpdateRatingButtonState( this.Rating, rating, children );
    }

    /// <summary>
    /// <para>
    /// レーティング パネル上からマウス カーソルが離れた時のイベントです。
    /// OnRatingButtonMouseEnter イベントの処理によって提示された、一時的なレーティング状態は、このイベント処理により、本来の状態に戻ります。
    /// </para>
    /// <para>
    /// 例えば ★★☆☆☆ の状態で 4 番目のボタン上にカーソルが入った場合は、OnRatingButtonMouseEnter イベントで ★★★★☆ となり、
    /// マウスカーソルが離れてこのイベントが発生した場合は、★★☆☆☆ に戻ります。
    /// </para>
    /// </summary>
    /// <param name="sender">イベント送信元。</param>
    /// <param name="e">イベントデータ。</param>
    private void OnRatingPanelMouseLeave( object sender, MouseEventArgs e )
    {
        UIElementCollection    children = ( ( StackPanel )( this.Content ) ).Children;

        // 確定しているレーティングへ戻す
        RatingSelector.UpdateRatingButtonState( this.Rating, children );
    }

    /// <summary>
    /// レーティングが変更された時のイベントです。
    /// </summary>
    /// <param name="sender">イベント送信元。</param>
    /// <param name="e">イベントデータ。</param>
    private static void OnRatingChanged( DependencyObject sender, DependencyPropertyChangedEventArgs e )
    {
        RatingSelector parent = sender as RatingSelector;
        if( parent == null ) { return; }

        UIElementCollection    children = ( ( StackPanel )( parent.Content ) ).Children;
        int                    rating   = ( int )e.NewValue;

        RatingSelector.UpdateRatingButtonState( rating, children );
    }

    /// <summary>
    /// レーティングを示す依存プロパティです。
    /// </summary>
    public static readonly DependencyProperty RatingProperty = DependencyProperty.Register( "Rating", typeof( int ), typeof( RatingSelector ), new FrameworkPropertyMetadata( 0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, new PropertyChangedCallback( OnRatingChanged ) ) );

    /// <summary>
    /// レーティングを設定または取得します。
    /// </summary>
    public int Rating
    {
        get
        {
            return ( int )this.GetValue( RatingSelector.RatingProperty );
        }
        set
        {
            if( value < RatingSelector.Min )
            {
                this.SetValue( RatingSelector.RatingProperty, RatingSelector.Min );
            }
            else if( value > RatingSelector.Max )
            {
                this.SetValue( RatingSelector.RatingProperty, RatingSelector.Max );
            }
            else
            {
                this.SetValue( RatingSelector.RatingProperty, value );
            }
        }
    }    
}

コメントに具体的な動作仕様を記述しているので、これを読みながらコントロールを操作してみると理解しやすいだろう。

サンプル プログラムの実行

サンプル プログラムを実行すると、以下のようになる。星印の部分が RatingSelector となる。

スクリーンショット

サンプルではマスタ詳細パターンによりウィンドウの上部と ListView の選択が連動するように実装している。マスタ詳細パターンについては下記を参照のこと。

ウィンドウ上部で RatingSelector を操作すると ListView 側も変更される。ListView 側の操作も上部へ反映。これらは相互連携するようになっている。