【WPF】XAMLでNumericUpDownを自作する | サンプルコード付き完全ガイド

WPFでは標準でNumericUpDownボタン(スピンボタン)が搭載されていないので、実際にデザインして使用してみたいと思います。この記事では、以下の流れで進めます。

この記事で作成するもの

スピンボタンには下記の機能をもたせます:

  • Up、Downボタンで数値を上げ下げします。最大値・最小値を設定し、それ以上(以下)の値にならないようにします
  • 入力機能:ReadOnlyを設定し、手入力不可にすることも可能です
  • 増分を設定し、ボタンの押下でその設定した増分数値を上げ下げできるようにします

プロジェクトをGitHubに公開しています。詳しくはこちらをご覧ください。

フォルダ構成

Visual Studio 2022 のWPFアプリケーションプロジェクトを使用して作成します。フォルダ構成は以下のとおりとします。

NumericUpDown/              # プロジェクト名
├── Controls/               # コントロールフォルダ
    └── NumericUpDown.cs    # スピンボタン用の拡張ファイル
├── Styles/                 # スタイルフォルダ
│   └── NumericUpDown.xaml  # スピンボタンのスタイル定義
├── App.xaml                # デフォルトで用意されているファイル
├── AssemblyInfo.cs         # デフォルトで用意されているファイル
├── MainWindow.xaml         # メインウィンドウ
└── MainWindowViewModel.cs  # メインウィンドウのビューモデル

実装方法

スピンボタンの機能定義:NumericUpdown.cs

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;

namespace NumericUpDown.Controls;

internal class NumericUpDown : TextBox
{
    public static readonly DependencyProperty ValueProperty = DependencyProperty.Register
        (nameof(Value),
        typeof(int),
        typeof(NumericUpDown),
        new PropertyMetadata(0, OnValueChanged));

    public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register
        (nameof(Maximum), 
        typeof(int), 
        typeof(NumericUpDown),
        new PropertyMetadata(int.MaxValue));

    public static readonly DependencyProperty MinimumProperty = DependencyProperty.Register
        (nameof(Minimum),
        typeof(int), 
        typeof(NumericUpDown), 
        new PropertyMetadata(int.MinValue));

    public static readonly DependencyProperty IncrementProperty = DependencyProperty.Register
        (nameof(Increment),
        typeof(int), 
        typeof(NumericUpDown),
        new PropertyMetadata(1));

    static NumericUpDown()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(NumericUpDown), new FrameworkPropertyMetadata(typeof(NumericUpDown)));
    }

    /// <summary>
    /// 値 (数値)
    /// </summary>
    public int Value
    { 
        get => (int)GetValue(ValueProperty);
        set => this.SetValue(ValueProperty, value);
    }

    /// <summary>
    /// 最大値
    /// </summary>
    public int Maximum 
    { 
        get => (int)GetValue(MaximumProperty); 
        set => this.SetValue(MaximumProperty, value); 
    }

    /// <summary>
    /// 最小値
    /// </summary>
    public int Minimum 
    { 
        get => (int)GetValue(MinimumProperty); 
        set => this.SetValue(MinimumProperty, value); 
    }

    /// <summary>
    /// 増分
    /// </summary>
    public int Increment 
    {
        get => (int)GetValue(IncrementProperty); 
        set => this.SetValue(IncrementProperty, value); 
    }

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        if (GetTemplateChild("PART_UpButton") is RepeatButton upButton)
        {
            upButton.Click += this.UpButton_Click;
        }

        if (GetTemplateChild("PART_DownButton") is RepeatButton downButton)
        {
            downButton.Click += DownButton_Click;
        }

        if (GetTemplateChild("PART_TextBox") is TextBox textBox)
        {
            textBox.PreviewTextInput += this.OnPreviewTextInput;
            textBox.TextChanged += OnTextChanged;
        }

        this.Unloaded += NumericUpDown_Unloaded;
    }

    /// <summary>
    /// 値が変更されたときの処理
    /// </summary>
    /// <param name="d"></param>
    /// <param name="e"></param>
    private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not NumericUpDown control)
        {
            return;
        }

        if (control.GetTemplateChild("PART_TextBox") is not TextBox textBox)
        {
            return;
        }

        // 新しい値を取得
        int newValue = control.ParseNum((int)e.NewValue);

        // 値を変更
        control.Value = newValue;
        textBox.Text = newValue.ToString();

        // ボタンの状態を変更
        control.ChangeButtonEnabled();
    }

    /// <summary>
    /// 入力文字が数値であるか判定
    /// </summary>
    /// <param name="text"></param>
    /// <returns></returns>
    private static bool IsTextNumeric(string text) => int.TryParse(text, out _) || text == "-";

    private void UpButton_Click(object sender, RoutedEventArgs e)
    {
        this.Value += this.Increment;
    }

    private void DownButton_Click(object sender, RoutedEventArgs e)
    {
        this.Value -= this.Increment;
    }

    /// <summary>
    /// テキスト入力のプレビュー処理
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void OnPreviewTextInput(object sender, TextCompositionEventArgs e)
    {
        e.Handled = !IsTextNumeric(e.Text);
    }

    /// <summary>
    /// テキスト変更処理
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void OnTextChanged(object sender, TextChangedEventArgs e)
    {
        if (sender is TextBox textBox)
        {
            OnValueChanged(this, new DependencyPropertyChangedEventArgs(ValueProperty, this.Value, this.ParseText(textBox.Text)));
        }
    }

    /// <summary>
    /// テキストの解析処理。テキストを数値にして返す
    /// </summary>
    /// <param name="text">テキスト</param>
    /// <returns></returns>
    private int ParseText(string text)
    {
        if (!int.TryParse(text, out int newValue))
        {
            return Value;
        }

        return this.ParseNum(newValue);
    }

    /// <summary>
    /// 数値の解析処理
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    private int ParseNum(int value)
    {
        if (value > Maximum)
        {
            return Maximum; // 範囲外なら最大値を返す
        }
        else if (value < Minimum)
        {
            return Minimum;  // 範囲外なら最小値を返す
        }

        return value;
    }

    /// <summary>
    /// ボタンの状態を変更する
    /// </summary>
    private void ChangeButtonEnabled()
    {
        if (GetTemplateChild("PART_UpButton") is RepeatButton upButton)
        {
            upButton.IsEnabled = Value < Maximum;
        }

        if (GetTemplateChild("PART_DownButton") is RepeatButton downButton)
        {
            downButton.IsEnabled = Value > Minimum;
        }
    }

    /// <summary>
    /// アンロード処理
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void NumericUpDown_Unloaded(object sender, RoutedEventArgs e)
    {
        if (GetTemplateChild("PART_UpButton") is RepeatButton upButton)
        {
            upButton.Click -= this.UpButton_Click;
        }

        if (GetTemplateChild("PART_DownButton") is RepeatButton downButton)
        {
            downButton.Click -= DownButton_Click;
        }

        if (GetTemplateChild("PART_TextBox") is TextBox textBox)
        {
            textBox.PreviewTextInput -= this.OnPreviewTextInput;
            textBox.TextChanged -= OnTextChanged;
        }

        this.Unloaded -= NumericUpDown_Unloaded;
    }
}

スピンボタンのスタイル定義:NumericUpDown.xaml

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:ctrl="clr-namespace:NumericUpDown.Controls">
  <SolidColorBrush x:Key="TextBox.Static.Border" Color="#FFABAdB3"/>
  <SolidColorBrush x:Key="TextBox.MouseOver.Border" Color="#FF7EB4EA"/>
  <SolidColorBrush x:Key="TextBox.Focus.Border" Color="#FF569DE5"/>
  <Style TargetType="{x:Type ctrl:NumericUpDown}">
    <Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
    <Setter Property="BorderBrush" Value="{StaticResource TextBox.Static.Border}"/>
    <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="KeyboardNavigation.TabNavigation" Value="None"/>
    <Setter Property="HorizontalContentAlignment" Value="Left"/>
    <Setter Property="FocusVisualStyle" Value="{x:Null}"/>
    <Setter Property="AllowDrop" Value="true"/>
    <Setter Property="ScrollViewer.PanningMode" Value="VerticalFirst"/>
    <Setter Property="Stylus.IsFlicksEnabled" Value="False"/>
    <Setter Property="VerticalContentAlignment" Value="Center"/>
    <Setter Property="Maximum" Value="100"/>
    <Setter Property="Minimum" Value="-100"/>
    <Setter Property="Increment" Value="1"/>
    <Setter Property="Width" Value="100"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="{x:Type ctrl:NumericUpDown}">
          <Border x:Name="border" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" SnapsToDevicePixels="True">
            <Grid>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
              </Grid.ColumnDefinitions>
              <TextBox x:Name="PART_TextBox" Grid.Column="0" Text="{TemplateBinding Text}" IsReadOnly="{TemplateBinding IsReadOnly}" VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" BorderThickness="0"/>
              <StackPanel Grid.Column="1" VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
                <RepeatButton x:Name="PART_UpButton" Content="▲" BorderThickness="0" Background="Transparent"/>
                <RepeatButton x:Name="PART_DownButton" Content="▼" BorderThickness="0" Background="Transparent"/>
              </StackPanel>
            </Grid>
          </Border>
          <ControlTemplate.Triggers>
            <Trigger Property="IsEnabled" Value="false">
              <Setter Property="Opacity" TargetName="border" Value="0.56"/>
            </Trigger>
            <Trigger Property="IsMouseOver" Value="true">
              <Setter Property="BorderBrush" TargetName="border" Value="{StaticResource TextBox.MouseOver.Border}"/>
            </Trigger>
            <Trigger Property="IsKeyboardFocused" Value="true">
              <Setter Property="BorderBrush" TargetName="border" Value="{StaticResource TextBox.Focus.Border}"/>
            </Trigger>
          </ControlTemplate.Triggers>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
    <Style.Triggers>
      <MultiTrigger>
        <MultiTrigger.Conditions>
          <Condition Property="IsInactiveSelectionHighlightEnabled" Value="true"/>
          <Condition Property="IsSelectionActive" Value="false"/>
        </MultiTrigger.Conditions>
        <Setter Property="SelectionBrush" Value="{DynamicResource {x:Static SystemColors.InactiveSelectionHighlightBrushKey}}"/>
      </MultiTrigger>
    </Style.Triggers>
  </Style>
</ResourceDictionary>

使用方法

MainWindow.xaml

<Window x:Class="NumericUpDown.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:NumericUpDown"
        xmlns:ctrl="clr-namespace:NumericUpDown.Controls"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800"
        d:DataContext="{d:DesignInstance Type=local:MainWindowViewModel, IsDesignTimeCreatable=True}">
  <Window.DataContext>
    <local:MainWindowViewModel/>
  </Window.DataContext>
  <StackPanel VerticalAlignment="Center">
    <ctrl:NumericUpDown
      Text="{Binding Value, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
      Value="{Binding Value, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
      Maximum="100"
      Minimum="-100"
      Increment="1"/>
  </StackPanel>
</Window>

プロパティ解説:

  • Text、Value:スピンボタンコントロールに表示する値です
  • Maximum:スピンボタンの最大値を設定します
  • Minimum:スピンボタンの最小値を設定します
  • Increment:増分を設定します。2を設定するとUp、Downボタン押下で±2上下されます

注意点

  • カスタムコントロールにデザインを適用するには、XAMLのリソース辞書を読み込ませる必要があります。GitHubにUpしているプロジェクトでは、App.xamlに下記を記述してアプリケーション全体で使用できるようにしています:
<ResourceDictionary>
  <ResourceDictionary.MergedDictionaries>
    <ResourceDictionary Source="/Styles/NumericUpDown.xaml"/>
  </ResourceDictionary.MergedDictionaries>
</ResourceDictionary>