2012年4月8日日曜日

wpf : スライダーとスピナー付きの整数しか入力できないTextBox

UserControlを継承してスライダーとスピナー付きの整数しか入力できないTextBoxを作りました。

とりあえずNumericBoxというクラス名にしました。 スライダーが付いている時点でBoxではないような気もしますが、名前を考えるのが面倒だったのでそんな感じで。 勢いで作ってちょっとだけ動作チェックして勢いで公開です。 そんな品質です。

まずはコードを載せます。 NumericBox.xamlから。

<UserControl x:Class="UserControls.NumericBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="50" d:DesignWidth="300">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="auto"/>
            <ColumnDefinition Width="20"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Slider
                Name="slider"
                Grid.Column="0"
                Grid.Row="0"
                Grid.RowSpan="2"
                VerticalAlignment="Center"
                Margin="0,0,2,0"
        />
        <TextBox
                Name="textBox"
                Grid.Column="1"
                Grid.Row="0"
                Grid.RowSpan="2"
                VerticalAlignment="Center"
                TextAlignment="Right"
                InputMethod.IsInputMethodEnabled="False"
                SizeChanged="OnTextBoxSizeChanged"
                PreviewKeyDown="OnTextBoxPreviewKeyDown"
                KeyDown="OnTextBoxKeyUpDown"
                KeyUp="OnTextBoxKeyUpDown"
                GotFocus="OnTextBoxGotFocus"
        />
        <RepeatButton
                Name="buttonIncrement"
                Grid.Column="2"
                Grid.Row="0"
                Click="OnRepeatButtonIncrementClick"
                VerticalAlignment="Bottom"
        >
            <Polyline
                    Stroke="{x:Static SystemColors.ControlTextBrush}"
                    StrokeThickness="1"
                    Points="0,4 4,0 8,4"
            />
        </RepeatButton>
        <RepeatButton
                Name="buttonDecrement"
                Grid.Column="2"
                Grid.Row="1"
                Click="OnRepeatButtonDecrementClick"
                VerticalAlignment="Top"
        >
            <Polyline
                    Stroke="{x:Static SystemColors.ControlTextBrush}"
                    StrokeThickness="1"
                    Points="0,0 4,4 8,0"
            />
        </RepeatButton>
    </Grid>
</UserControl>

GridにSlider、TextBox、RepeatButton2つを並べただけの簡単なものです。 今のところは利用するときのスタイル設定については考えてません。 スタイルを変えたくなったらコレをコピー&ペーストして書き換える方向で。

対応するC#のコードNumericBox.xaml.csはこうなりました。

// NumericBox.xaml.cs
using System;
using System.Media;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;

namespace UserControls
{
    public partial class NumericBox : UserControl
    {
        public static readonly DependencyProperty ValueMaxProperty = DependencyProperty.Register(
                "ValueMax",
                typeof(long),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        (long)long.MaxValue,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnValueMaxChanged)
                )
        );

        public long ValueMax
        {
            get { return (long)GetValue(ValueMaxProperty); }
            set { SetValue(ValueMaxProperty, value); }
        }

        public static readonly DependencyProperty ValueMinProperty = DependencyProperty.Register(
                "ValueMin",
                typeof(long),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        (long)0,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnValueMinChanged)
                )
        );

        public long ValueMin
        {
            get { return (long)GetValue(ValueMinProperty); }
            set { SetValue(ValueMinProperty, value); }
        }

        public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
                "Value",
                typeof(long),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        (long)0,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(NumericBox.OnValueChanged)
                )
        );

        public long Value
        {
            get { return (long)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        public static readonly DependencyProperty SmallChangeProperty = DependencyProperty.Register(
                "SmallChange",
                typeof(long),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        (long)1,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnSmallChangePropertyChanged)
                )
        );

        public long SmallChange
        {
            get { return (long)GetValue(SmallChangeProperty); }
            set { SetValue(SmallChangeProperty, value); }
        }

        public static readonly DependencyProperty LargeChangeProperty = DependencyProperty.Register(
                "LargeChange",
                typeof(long),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        (long)1,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnLargeChangePropertyChanged)
                )
        );

        public long LargeChange
        {
            get { return (long)GetValue(LargeChangeProperty); }
            set { SetValue(LargeChangeProperty, value); }
        }

        public static readonly DependencyProperty TextBoxWidthProperty = DependencyProperty.Register(
                "TextBoxWidth",
                typeof(double),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        double.NaN,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnTextBoxWidthChanged)
                )
        );

        public double TextBoxWidth
        {
            get { return (double)GetValue(TextBoxWidthProperty); }
            set { SetValue(TextBoxWidthProperty, value); }
        }

        public static readonly DependencyProperty SliderVisibilityProperty = DependencyProperty.Register(
                "SliderVisibility",
                typeof(Visibility),
                typeof(NumericBox),
                new FrameworkPropertyMetadata(
                        Visibility.Visible,
                        FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                        new PropertyChangedCallback(OnSliderVisibilityChanged)
                )
        );

        public Visibility SliderVisibility
        {
            get { return (Visibility)GetValue(SliderVisibilityProperty); }
            set { SetValue(SliderVisibilityProperty, value); }
        }

        public static readonly RoutedEvent ValueChangedEvent = EventManager.RegisterRoutedEvent(
                "ValueChanged",
                RoutingStrategy.Bubble,
                typeof(RoutedPropertyChangedEventHandler<long>),
                typeof(NumericBox)
        );

        public NumericBox()
        {
            InitializeComponent();

            NumericBoxValidationRule validationRule = new NumericBoxValidationRule(this);
            validationRule.ValidatesOnTargetUpdated = true;

            Binding textBinding = new Binding("Value");
            textBinding.Source = this;
            textBinding.ValidationRules.Add(validationRule);
            textBinding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
            textBox.SetBinding(TextBox.TextProperty, textBinding);

            Binding sliderBinding = new Binding("Value");
            sliderBinding.Source = this;
            slider.SetBinding(Slider.ValueProperty, sliderBinding);

            DataObject.AddPastingHandler(textBox, TextBoxPastingEventHandler);
        }

        private static void OnValueMaxChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            long newMax = (long)args.NewValue;
            thisCtrl.slider.Maximum = newMax;

            if (newMax < thisCtrl.Value)
                thisCtrl.Value = newMax;
        }

        private static void OnValueMinChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            long newMin = (long)args.NewValue;
            thisCtrl.slider.Minimum = newMin;

            if (thisCtrl.Value < newMin)
                thisCtrl.Value = newMin;
        }

        private static void OnSmallChangePropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            thisCtrl.slider.SmallChange = (long)args.NewValue;
        }

        private static void OnLargeChangePropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            thisCtrl.slider.LargeChange = (long)args.NewValue;
        }

        private static void OnTextBoxWidthChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            thisCtrl.textBox.Width = (double)args.NewValue;
        }

        private static void OnSliderVisibilityChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;
            thisCtrl.slider.Visibility = (Visibility)args.NewValue;
        }

        private static void OnValueChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            NumericBox thisCtrl = (NumericBox)obj;

            RoutedPropertyChangedEventArgs<long> evt = new RoutedPropertyChangedEventArgs<long>(
                    (long)args.OldValue,
                    (long)args.NewValue,
                    ValueChangedEvent
            );
            thisCtrl.OnValueChanged(evt);
        }

        public event RoutedPropertyChangedEventHandler<long> ValueChanged
        {
            add { AddHandler(ValueChangedEvent, value); }
            remove { RemoveHandler(ValueChangedEvent, value); }
        }

        protected virtual void OnValueChanged(RoutedPropertyChangedEventArgs<long> args)
        {
            RaiseEvent(args);
        }

        private void OnRepeatButtonIncrementClick(object sender, RoutedEventArgs e)
        {
            if(Value < ValueMax)
                Value++;
        }

        private void OnRepeatButtonDecrementClick(object sender, RoutedEventArgs e)
        {
            if(ValueMin < Value)
                Value--;
        }

        private void OnTextBoxSizeChanged(object sender, SizeChangedEventArgs e)
        {
            double h = textBox.ActualHeight / 2;
            buttonIncrement.Height = h;
            buttonDecrement.Height = h;
        }

        private void TextBoxPastingEventHandler(object sender, DataObjectPastingEventArgs evt)
        {
            evt.CancelCommand();

            string t = evt.DataObject.GetData(typeof(string)) as string;
            long pasteValue = 0;
            if (long.TryParse(t, out pasteValue))
            {
                Value = pasteValue;
                if (pasteValue < ValueMin || ValueMax < pasteValue)
                {
                    SystemSounds.Beep.Play();
                }
            }
            else
            {
                SystemSounds.Beep.Play();
            }
        }

        // textBoxにフォーカスがあるとき上下およびPageUp/Downキーが押されたらValueを増減させる。
        // キーリピートにも対応するためPreviewKeyDownイベントで処理。
        private void OnTextBoxPreviewKeyDown(object sender, KeyEventArgs evt)
        {
            Key key = evt.Key;
            if (key != Key.Up && key != Key.Down && key != Key.PageUp && key != Key.PageDown)
            {
                evt.Handled = false;
                return;
            }

            long valueTmp = 0;
            long.TryParse(textBox.Text, out valueTmp);
            switch (key)
            {
            case Key.Up:
                valueTmp++;
                break;
            case Key.Down:
                valueTmp--;
                break;
            case Key.PageUp:
                valueTmp += LargeChange;
                break;
            case Key.PageDown:
                valueTmp -= LargeChange;
                break;
            }

            if (ValueMax < valueTmp)
                valueTmp = ValueMax;
            if (valueTmp < ValueMin)
                valueTmp = ValueMin;
            Value = valueTmp;

            evt.Handled = true;
        }

        private void OnTextBoxKeyUpDown(object sender, KeyEventArgs evt)
        {
            Key key = evt.Key;
            int selStart = textBox.SelectionStart;
            int selLen = textBox.SelectionLength;
            int txtLen = textBox.Text.Length;

            if (key == Key.Back || key == Key.Delete)
            {
                if (
                        (selLen == txtLen) ||
                        (Value < 0 && (selStart == 1) && (selLen == txtLen - 1))
                ) // 全消去やマイナス記号だけ残るときは0を設定
                {
                    textBox.Text = "0";
                    evt.Handled = true;
                }
                else
                {
                    evt.Handled = false;
                }
            }
            else if (
                    evt.IsUp &&
                    (key == Key.OemMinus || key == Key.Subtract) &&
                    Value != 0 &&
                    ValueMin < 0
            ) // 符号反転(カーソル位置無視。ValueMin~Maxの範囲外でも動くようにtextBox.Textの文字列を元にする。)
            {
                String text = textBox.Text;
                if (text != null && 0 < text.Length)
                {
                    textBox.Text = (text[0] == '-' ? text.Substring(1) : "-" + text);
                }
                evt.Handled = true;
            }
            else if ((key == Key.D0 || key == Key.NumPad0) && Value != 0) // 先頭にゼロを追加するのは禁止
            {
                evt.Handled = (selLen < txtLen && selStart == 0);
            }
            else if (
                    (Key.D1 <= key && key <= Key.D9) ||
                    (Key.NumPad1 <= key && key <= Key.NumPad9) ||
                    key == Key.Tab
            )
            {
                evt.Handled = false;
            }
            else
            {
                evt.Handled = true;
            }
        }

        private void OnTextBoxGotFocus(object sender, RoutedEventArgs e)
        {
            Dispatcher.BeginInvoke((Action)(() => textBox.SelectAll()));
        }
    }
}

使うプロパティーをつらつらと書き並べていったらこんな長さに。 プロパティの分だけお決まりのコードが並んでます。 それを除いたらあまり長くないです。 キーイベントの処理でほんのちょっとだけ頑張りました。

TextBoxには範囲外の大きすぎる数字、小さすぎる数字を入力できるようになっています。 範囲外だと検証エラーの赤枠が表示されます。 範囲内の数字しか入力できないようにするとキー入力が面倒になってしまうことがあるのでそうしました。 範囲内に収まっているかどうかをチェックするのは次のNumericBoxValidationRuleクラスでやっています。

// NumericBoxValidationRule.cs
using System.Globalization;
using System.Windows.Controls;

namespace UserControls
{
    public class NumericBoxValidationRule : ValidationRule
    {
        private NumericBox _owner;

        public NumericBoxValidationRule(NumericBox owner)
        {
            _owner = owner;
        }

        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            long param;
            long min = _owner.ValueMin;
            long max = _owner.ValueMax;

            if (!long.TryParse(value as string, out param))
            {
                return new ValidationResult(
                        false,
                        "" + min + "~" + max + "の整数を入力してください。"
                );
            }

            if (param < min || max < param)
            {
                return new ValidationResult(
                        false,
                        "" + min + "~" + max + "の整数を入力してください。"
                );
            }

            return new ValidationResult(true, null);
        }
    }
}

NumericBoxValidationRuleはNumericBoxのプロパティを元に範囲チェックをしています。 ValidationRuleをxamlから追加するとNumericBoxのインスタンスを指定できないのでC#のコードから追加しています。 このネタに関する大雑把な説明は前の投稿参照。

ここまでがNumericBoxの部品となるコードです。

NumericBoxを使うときのコードはこんな感じです。 サンプルのMainWindow.xamlは、

<Window x:Class="NumericBoxTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:appCtrls="clr-namespace:UserControls"
        Title="MainWindow" Height="120" Width="200">
    <StackPanel>
        <appCtrls:NumericBox
                x:Name="numericBox1"
                ValueMin="-100"
                ValueMax="200"
                SmallChange="2"
                LargeChange="20"
                TextBoxWidth="40"
                Value="{Binding ElementName=numericBox2,Path=Value}"
                ValueChanged="NumericBox_ValueChanged"
        />
        <appCtrls:NumericBox
                x:Name="numericBox2"
                ValueMin="-100"
                ValueMax="200"
                SmallChange="2"
                LargeChange="20"
                TextBoxWidth="40"
                Margin="0,10"
        />
        <TextBlock Name="textBlock"/>
    </StackPanel>
</Window>

MainWindow.xaml.csは、

// MainWindow.xaml.cs
using System.Windows;

namespace NumericBoxTest
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void NumericBox_ValueChanged(object sender, RoutedPropertyChangedEventArgs<long> e)
        {
            textBlock.Text = "" + e.NewValue;
        }
    }
}

NumericBoxを使うときは次のプロパティの設定が必須です。

  • ValueMin ... 最小値
  • ValueMax ... 最大値

Sliderを操作するときの移動量は、NumericBoxの次のプロパティで設定します。

  • SmallChange ... 矢印キーでの増減量
  • LargeChange ... TrackクリックやPageUp/PageDownキーでの増減量

これは中身のSliderの同名のプロパティに値を渡しています。 LargeChangeの方はTextBoxにフォーカスを合わせてPageUp/PageDownキーを押しても同じだけ増減します。 RepeatButtonを押したときの増減量は1固定です。 このへん趣味ですな。 あと、TextBoxにフォーカスを合わせて矢印キーの上下でも1増減します。

このサンプルには書いてませんが、SliderVisibilityプロパティでSliderの表示非表示を切り替えることができます。

  • SliderVisibility ... 標準コントロールと同様にVisibilityを設定

標準のままSliderを表示させる場合、TextBoxWidthプロパティでTextBoxの幅を指定してください。

  • TextBoxWidth ... TextBoxの幅

これを指定しないと、入力値の桁数でTextBoxの幅が変化してしまいます。 Sliderを表示しない場合は、レイアウト次第では指定しなくてもかまいません。

ユーザーが入力した値はValueプロパティに格納されます。

  • Value ... ユーザーが入力した値

Valueの変化はValueChangedイベントで捕捉できます。

  • ValueChanged ... Valueが変化したときに発行されるイベント

ただし、TextBoxに範囲外の値が入力された場合はValueプロパティは変化しません。 ValueChangedは発行されませんし、Valueをバインドした場合も範囲外の値が入力された場合は反映されません。 このへんは「とりあえず仕様」ですね。 なんかのツールに組み込んでみて使いづらかったら変更するかも?