目的
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.WPFReactiveUI.FodyExtended.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にビューモデルを直接設定。 DateTimeUpDownのValueはビューモデルの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 バインディングもリアルタイムで更新される。