Rx.NET と ReactiveUI を用いたカスタム時刻入力コントロールの実装

目的

WPF アプリケーションにおいて、ReactiveX(Rx.NET)および ReactiveUI を活用して、秒数を基準とした時刻入力用のカスタムコントロールを作成する。このコントロールは以下を満たす。

  • 内部に DateTimeUpDown(Extended.Wpf.Toolkit 使用)を持ち、表示・編集形式は "HH:mm:ss" に限定。
  • 日付部分は固定(例: 2000年1月1日)で、ユーザーが操作できるのは時・分・秒のみ。
  • 外部に対して整数型の Value プロパティを提供し、その値は「時×3600 + 分×60 + 秒」に対応する0~86399の範囲の秒数とする。
  • Value プロパティは双方向バインディング可能であり、値変更時に自動的にUIと同期される。
  • 初期値はいずれも最小値(0 / 00:00:00)で、テスト時には最大値(86399 / 23:59:59)を設定して正常に反映されることを確認する。

プロジェクトの作成

Visual Studio を起動し、「新しいプロジェクトの作成」から WPF アプリ (.NET Framework または .NET) を選択。プロジェクト名を TimeSpanControlDemo などとして作成する。

必要なNuGetパッケージのインストール

以下のパッケージを NuGet パッケージマネージャー経由で追加する。

  • ReactiveUI.WPF
  • ReactiveUI.Fody
  • Extended.Wpf.Toolkit

これらにより、リアクティブプログラミングの構文糖衣、プロパティ通知の簡略化、および高度なUIコントロールが利用可能になる。

カスタムコントロールの定義: TimeEditorControl

新しいユーザー コントロールとして TimeEditorControl.xaml を追加し、XAML を以下のように記述する。

<UserControl x:Class="TimeSpanControlDemo.TimeEditorControl"
             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:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
             xmlns:vm="clr-namespace:TimeSpanControlDemo.ViewModels"
             mc:Ignorable="d"
             d:DesignWidth="120" d:DesignHeight="30">
    <UserControl.DataContext>
        <vm:TimeEditorViewModel />
    </UserControl.DataContext>
    <Grid>
        <xctk:DateTimeUpDown Value="{Binding InternalTime}"
                              Format="Custom"
                              FormatString="HH:mm:ss"
                              Minimum="01/01/2000 00:00:00"
                              Maximum="01/01/2000 23:59:59"
                              Width="100"/>
    </Grid>
</UserControl>

ここで重要な点は:

  • コントロール自体の DataContext にビューモデルを直接設定。
  • DateTimeUpDownValue はビューモデルの InternalTime プロパティにバインド。
  • 日付は固定(2000年1月1日)とし、時間帯だけを操作対象とする。

ビューモデルの実装

次に、ViewModels/TimeEditorViewModel.cs を作成し、以下のように実装する。

using ReactiveUI;
using System;
using System.Reactive.Linq;

namespace TimeSpanControlDemo.ViewModels
{
    public class TimeEditorViewModel : ReactiveObject
    {
        private DateTime _internalTime = new DateTime(2000, 1, 1, 0, 0, 0);

        public DateTime InternalTime
        {
            get => _internalTime;
            set => this.RaiseAndSetIfChanged(ref _internalTime, value);
        }

        public IObservable<int> TotalSecondsStream =>
            this.WhenAnyValue(x => x.InternalTime)
                .Select(dt => dt.Hour * 3600 + dt.Minute * 60 + dt.Second)
                .DistinctUntilChanged();

        public void UpdateFromSeconds(int totalSeconds)
        {
            totalSeconds = Math.Clamp(totalSeconds, 0, 86399);
            int h = totalSeconds / 3600;
            int m = (totalSeconds % 3600) / 60;
            int s = totalSeconds % 60;

            var newTime = new DateTime(2000, 1, 1, h, m, s);
            if (InternalTime != newTime)
            {
                InternalTime = newTime;
            }
        }
    }
}

このビューモデルの特徴:

  • InternalTime は ReactiveUI の RaiseAndSetIfChanged を使って変更通知を行う。
  • TotalSecondsStream は Rx の WhenAnyValue を使い、InternalTime 変更時に自動計算される秒数ストリームを提供。
  • UpdateFromSeconds は外部からの整数値を受け取り、対応する時刻に変換して InternalTime を更新。

コントロール本体のロジック実装

TimeEditorControl.xaml.cs を以下のように修正する。

using System.Windows;
using System.Windows.Controls;
using TimeSpanControlDemo.ViewModels;
using System.Reactive.Disposables;

namespace TimeSpanControlDemo
{
    public partial class TimeEditorControl : UserControl, IViewFor<TimeEditorViewModel>
    {
        public TimeEditorControl()
        {
            InitializeComponent();

            ViewModel = DataContext as TimeEditorViewModel;

            // DisposeWith を使ってリソース管理
            Disposable.Create(() =>
            {
                var subscription = ViewModel.TotalSecondsStream
                    .ObserveOnDispatcher()
                    .Subscribe(sec => SetValue(ValueProperty, sec));
            }).DisposeWith(this);
        }

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register(
                nameof(Value),
                typeof(int),
                typeof(TimeEditorControl),
                new FrameworkPropertyMetadata(
                    0,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    OnValueChanged));

        public int Value
        {
            get => (int)GetValue(ValueProperty);
            set => SetValue(ValueProperty, value);
        }

        private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is TimeEditorControl control && e.NewValue is int seconds)
            {
                control.ViewModel?.UpdateFromSeconds(seconds);
            }
        }

        public TimeEditorViewModel ViewModel
        {
            get => (TimeEditorViewModel)GetValue(ViewModelProperty);
            set => SetValue(ViewModelProperty, value);
        }

        public static readonly DependencyProperty ViewModelProperty =
            DependencyProperty.Register(
                nameof(ViewModel),
                typeof(TimeEditorViewModel),
                typeof(TimeEditorControl),
                new PropertyMetadata(null));
    }
}

ポイント:

  • IViewFor<T> インターフェースを実装することで、ReactiveUI のビューバインディング機構と完全に統合。
  • DependencyProperty として Value を定義し、変更時にビューモデルの UpdateFromSeconds を呼び出す。
  • TotalSecondsStream からの出力は UI スレッドで処理され、Value プロパティに反映。
  • DisposeWith を使用して、メモリリークを防ぐための適切な解放処理を確保。

MainWindow でのテスト

MainWindow.xaml でカスタムコントロールを使用して動作確認を行う。

<Window x:Class="TimeSpanControlDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:TimeSpanControlDemo"
        Title="Time Editor Test" Height="150" Width="300">
    <StackPanel Margin="10">
        <local:TimeEditorControl x:Name="Editor" Value="86399" />
        <TextBlock Text="{Binding ElementName=Editor, Path=Value, StringFormat='Total Seconds: {0}'}"
                   Margin="0,10" FontSize="14" />
    </StackPanel>
</Window>

アプリケーションを実行すると、コントロールには 23:59:59 が表示され、下部の TextBlock には Total Seconds: 86399 と正しく表示される。また、手動で時間を変更すると、Value バインディングもリアルタイムで更新される。

タグ: ReactiveUI Rx.NET WPF Extended.Wpf.Toolkit DependencyProperty

7月9日 16:29 投稿