sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

WPF Calendar コントロールのカスタマイズ

WPF のカレンダーコントロールをしっかりとやっている記事が少なかったので記録を残します。Calendar コントロールは、正直な話をするとあまりよく出来た使いやすいコントロールだという印象がありません。どちらかと言えば、カスタマイズする上でも使いづらいように個人的には思っています。

作成したカレンダー

しかし、デフォルトで提供されているカレンダーはこれくらいなので、結局付き合うハメになる。

カレンダーをカスタマイズするうえでよい見本は、つぎの Youtube がベストだったと思います。

WPF C# | How to customize Calendar Control in WPF

というよりも、複雑なコントールをカスタマイズするときは、この動画のように Visual Studio の機能を使ってテンプレートの基礎になるコードを生成しないと、とっかかりに困ると思う。

xaml ファイルのデザイン画面から右クリックで「テンプレートの編集」、さらに「追加テンプレートの編集」でスタイルとテンプレートを生成できることは覚えておいたほうがいいと思いますので必見。

今回のように、コントールの外観を再設計する場合なんかに思い出せるようにしておく。

カレンダーコントロールの構造

WPF の Calendar コントロールは、つぎの3つのコントロール、つまり、「日(縦6x横7)の42日間の表示」と、「月(縦3x横4)12か月の表示」と、「年(縦3x横4)の12年の表示」の3パターンの表示があります。(違ったらすいません)

コントール的には:

  • PART_Root
    • PART_PreviousButton
    • PART_HeaderButton
    • PART_NextButton
    • 日(※ Name は無いはず)
    • 月 PART_MonthView
    • 年 PART_YearView

カレンダー表示は、3つのコントロール(Disabled 用の PART_DisabledVisual もあるけど、ここでは割愛)からどれか1つを DisplayMode プロパティで選択し、選択されたコントロールを表示し、非選択コントロールを非表示にすることで表示切替を実装しているのだけど、非表示の状態でもコントロールの大きさは確保されたままになっている。Visiblity.Hidden みたいなものです。

この仕様でコントロールをカスタマイズする上で注意が必要になるのは、カレンダーのセル数と各セルの大きさ。日の表示をするときは(6x7)だが、年/月の表示をするときは(3x4)になっている。

たとえば、日のセルサイズを 120px に設定すると横幅は 120x7=840px になる。なので、年/月のセルサイズは 840/4=210px にしておかないと、変な余白が残るコントロールになってしまう。

<Style x:Key="CalendarCalendarDayButtonStyle" TargetType="{x:Type CalendarDayButton}">
    <Setter Property="MinWidth" Value="120"/>
    <Setter Property="MinHeight" Value="120"/>
</Style>

<Style x:Key="CalendarCalendarButtonStyle" TargetType="{x:Type CalendarButton}">
    <!-- 年、月のセルサイズ 120px*7cell=840px / 4cell = 210px -->
    <Setter Property="MinWidth" Value="210"/>
    <Setter Property="MinHeight" Value="210"/>
</Style>

Day セルのカスタマイズ

日のセルにイベントを追加して、セルになにか表示したいときの一例。通常の「日」のセル表示の部分の xaml だけを取り出すとこんな感じに表すことができると思います。

UserControl を CustomCalendar として定義したとします。

<UserControl>
    <Grid>
        <Viewbox Stretch="Uniform">
            <Calendar CalendarDayButtonStyle="{StaticResource CalendarCalendarDayButtonStyle}" />
        </Viewbox>
    </Grid>
</UserControl>

CustomCalendar に依存関係プロパティ Events をサンプルとして定義します。

public partial class CustomCalendar : UserControl
{
    public static readonly DependencyProperty EventsProperty =
        DependencyProperty.Register
        (
            nameof(Events),
            typeof(ObservableCollection<Data>),
            typeof(CustomCalendar),
            new PropertyMetadata
            (
                new ObservableCollection<Data>()
            ));

    public ObservableCollection<Data> Events
    {
        get => (ObservableCollection<Data>)GetValue(EventsProperty);
        set => SetValue(EventsProperty, value);
    }

    public CustomCalendar()
    {
        InitializeComponent();
    }
}

スタイルで Events と連携させます。MultiBinding にするのは、1つ目が「マウスクリックで選択日付」で、2つ目が「Events」そのものを渡します。

<Style x:Key="CalendarCalendarDayButtonStyle" TargetType="{x:Type CalendarDayButton}">
    <Setter Property="MinWidth" Value="120"/>
    <Setter Property="MinHeight" Value="120"/>
    <Setter Property="FontSize" Value="14"/>
    <Setter Property="FontWeight" Value="Bold" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type CalendarDayButton}">
                <Grid>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto" />
                            <RowDefinition Height="*" />
                        </Grid.RowDefinitions>

                        <ContentPresenter x:Name="NormalText" 
                                            Grid.Row="0"
                                            TextElement.Foreground="#FF333333" 
                                            Margin="1"
                                            HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" />
                        <TextBlock Grid.Row="1" Grid.RowSpan="2" 
                                    HorizontalAlignment="Center"
                                    FontWeight="Normal">
                            <TextBlock.Text>
                                <MultiBinding Converter="{StaticResource DateToCountMultiConverter}">
                                    <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="DataContext" />
                                    <Binding Path="Events" RelativeSource="{RelativeSource AncestorType=local:CustomCalendar}" />
                                </MultiBinding>
                            </TextBlock.Text>
                        </TextBlock>
                    </Grid>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

MultiConverter のサンプル。選択日付にあるイベントの「件数」を返却します。

public class DateToCountMultiConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (values.Length < 2 || values[0] == null || values[1] == null)
        {
            return "0";
        }

        if (values[0] is DateTime date && values[1] is IEnumerable<Data> events)
        {
            var count = events.Count(e => e.Date.Date == date.Date);
            return count.ToString();
        }

        return "0";
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

ElementNotAvailableException の対応

サンプルのコントロールのテンプレートを変更・削除しすぎて System.Windows.Automation.ElementNotAvailableException が発生しています。

この例外は、わかりやすい例だと DataGrid や ListBox なんかの行データの仮想化データを再表示するときに、(なにかが原因で仮想化した)データが存在しないときに発生する例外です。コントロールを自分でカスタマイズしていると、仮想化されるはずだったコードを削除してしまったり、仮想化するコントロールが無くなっていたりして起こることがあります。

この対処の一例は、仮想化しないようにします。

public class CalendarB : Calendar
{
    protected override AutomationPeer? OnCreateAutomationPeer()
    {
        // 空の AutomationPeer を返すことで Automation サポートを無効化
        return null;
    }
}
    <Grid>
        <Viewbox Stretch="Uniform">
            <local:CalendarB />
        </Viewbox>
    </Grid>

注意として、当然ながら仮想化によるパフォーマンス対策を消すことになる。

カレンダーの Binding 要素の更新

カレンダーの画面更新(日のセルに Binding したテキストを表示するなど)が必要になったときは、正直な話をすると直接的なよい更新方法を実装することができませんでした。

下記のようにすることで実装しておくことが(今は)一番適切だと思います。

public void ForceUpdate()
{
    var selectedMode = CalendarControl.DisplayMode;

    // なんでもいいから違うモードに一旦切り替えて、すぐ戻す
    CalendarControl.DisplayMode = CalendarMode.Year;
    CalendarControl.DisplayMode = selectedMode;
}

InvalidateVisual などでも更新してよさそうなのですが、メソッドまわりを適当に実行しても更新される様子はありませんでした。

Binding 自体を更新するなら BindingOperations.GetBindingExpression でどうかなとも思ったのですが、上記がてっとり早かった。

カレンダーの Binding の修正例

個人的な感想として、今回実装した Calendar のコントロールの Events の要素は、日ごとに要素の数を毎回数え直す必要があったため、あまりよい実装ではないと思います。

最終的には Dictionary で実装してみるほうが、まだよい気がします。

public static readonly DependencyProperty EventsProperty =
    DependencyProperty.Register
    (
        nameof(Events),
        typeof(Dictionary<string, Data>),
        typeof(CustomCalendar),
        new PropertyMetadata
        (
            new Dictionary<string, Data>()
        ));

Data の中で、その日のイベント要素をあらかじめコレクション化しておくほうがカレンダーコントールの Converter の処理としては単純になって、パフォーマンス面でデータ数が増えたときによくなる(守られる)のではないか、と思っています。

さわってみるとわかるけど、意外なところで Binding のイベントが発生しています。なので、パフォーマンス対策は気にしてもいいかもしれない。

サンプル

GitHub にサンプルコードを公開しています。

参考