かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

ReactiveProperty で二度押し防止 2019 年 6 月版

改版履歴

  • ReactiveProperty v5.6.0 に合わせてアップデート

本文

ということで書いていきましょう。

といっても二度押し防止系は AsyncReactiveCommand 使うと楽。以上です。 例えば非同期処理が終わるまで押せないボタンを実現したい場合は以下のような ViewModel になります。

using Reactive.Bindings;
using System.ComponentModel;
using System.Threading.Tasks;

namespace DoubleClickApp
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public AsyncReactiveCommand HeavyProcessCommand { get; }

        public ReactivePropertySlim<string> Message { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();
            HeavyProcessCommand = new AsyncReactiveCommand()
                .WithSubscribe(HeavyProcessAsync);
        }

        private async Task HeavyProcessAsync()
        {
            Message.Value = "処理開始!!";
            await Task.Delay(3000);
            Message.Value = "処理終了!!";
        }
    }
}

XAML 側はこんな感じで普通に適当なボタンなどの Command プロパティに紐づけるだけです。

<Window x:Class="DoubleClickApp.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:DoubleClickApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBlock Text="{Binding Message.Value}" />
        <Button Content="Click me!!" Command="{Binding HeavyProcessCommand}" />
    </StackPanel>
</Window>

実行すると、いい感じに処理中はボタン押せなくなります。

f:id:okazuki:20190620195731g:plain

複数個非同期処理があって、どれかが実行中は他のボタンを押せなくしたいんだ

世の中はシンプルじゃなくて複数の非同期処理があって、どれかが実行中はボタンが押せないようにしたいということはよくあります。 AsyncReactiveCommand は、IReactiveProperty<bool> から生成することもできるのですが、この方法で作った AsyncReactiveCommand は押せない状態を共有します。

つまり、以下のように同じ IReactiveProperty<bool> を元に生成した AsyncReactiveCommand

using Reactive.Bindings;
using System.ComponentModel;
using System.Threading.Tasks;

namespace DoubleClickApp
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private ReactivePropertySlim<bool> SharedStatus { get; }

        public AsyncReactiveCommand HeavyProcessCommand1 { get; }
        public AsyncReactiveCommand HeavyProcessCommand2 { get; }
        public AsyncReactiveCommand HeavyProcessCommand3 { get; }

        public ReactivePropertySlim<string> Message { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();

            SharedStatus = new ReactivePropertySlim<bool>(true); // 初期状態は実行可能で
            HeavyProcessCommand1 = SharedStatus
                .ToAsyncReactiveCommand()
                .WithSubscribe(HeavyProcess1Async);
            HeavyProcessCommand2 = SharedStatus
                .ToAsyncReactiveCommand()
                .WithSubscribe(HeavyProcess2Async);
            HeavyProcessCommand3 = SharedStatus
                .ToAsyncReactiveCommand()
                .WithSubscribe(HeavyProcess3Async);
        }

        private async Task HeavyProcess1Async()
        {
            Message.Value = "1 番開始!";
            await Task.Delay(3000);
            Message.Value = "1 番終了!!";
        }
        private async Task HeavyProcess2Async()
        {
            Message.Value = "2 番開始!";
            await Task.Delay(3000);
            Message.Value = "2 番終了!!";
        }
        private async Task HeavyProcess3Async()
        {
            Message.Value = "3 番開始!";
            await Task.Delay(3000);
            Message.Value = "3 番終了!!";
        }
    }
}

以下のように各々のボタンにバインドすると

<Window x:Class="DoubleClickApp.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:DoubleClickApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBlock Text="{Binding Message.Value}" />
        <Button Content="One" Command="{Binding HeavyProcessCommand1}" />
        <Button Content="Two" Command="{Binding HeavyProcessCommand2}" />
        <Button Content="Three" Command="{Binding HeavyProcessCommand3}" />
    </StackPanel>
</Window>

以下のようになります。

f:id:okazuki:20190620115915g:plain

やったね!

もっと複雑なケース

入力エラーが無くなったら押せるボタンが複数あって、それの二度押し防止をしつつ、他のボタンの処理が走ってる間は押せないようにしたい!というケース。 その場合は、IObservable<bool> から AsyncReactiveCommand を作る ToAsyncReactiveCommand メソッドの引数に、状態共有をするための IReactiveProperty<bool> を渡してあげれば OK です。この機能は ReactiveProperty v5.6.0 で追加されました。

例えば Input プロパティにエラーが無い時に押せる重い処理のコマンドが 2 つある場合には以下のようになります。

using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace DoubleClickApp
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public AsyncReactiveCommand HeavyProcessCommand1 { get; }
        public AsyncReactiveCommand HeavyProcessCommand2 { get; }

        public ReactivePropertySlim<string> Message { get; }

        private ReactivePropertySlim<bool> SharedCanExecuteState { get; }

        [Required]
        public ReactiveProperty<string> Input { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();

            Input = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Input);

            // エラーが無くなったら押せる重たい処理のコマンド
            SharedCanExecuteState = new ReactivePropertySlim<bool>(true);
            HeavyProcessCommand1 = Input.ObserveHasErrors
                .Inverse()
                .ToAsyncReactiveCommand(SharedCanExecuteState) // 状態共有用の IReactiveProperty<bool> を渡す
                .WithSubscribe(HeavyProcessAsync);
            HeavyProcessCommand2 = Input.ObserveHasErrors
                .Inverse()
                .ToAsyncReactiveCommand(SharedCanExecuteState) // 状態共有用の IReactiveProperty<bool> を渡す
                .WithSubscribe(HeavyProcessAsync);
        }

        private async Task HeavyProcessAsync()
        {
            Message.Value = "開始!";
            await Task.Delay(3000);
            Message.Value = "終了!!";
        }
    }
}

XAML 側は以下のような感じ。

<Window x:Class="DoubleClickApp.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:DoubleClickApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBlock Text="{Binding Message.Value}" />
        <TextBox Text="{Binding Input.Value, UpdateSourceTrigger=PropertyChanged}" />
        <Button Content="Click me!!" Command="{Binding HeavyProcessCommand1}" />
        <Button Content="Click me!!" Command="{Binding HeavyProcessCommand2}" />
    </StackPanel>
</Window>

以下のように動きます。状態共有しつつエラーが無い時だけ押せるようになります。

f:id:okazuki:20190620152114g:plain

まとめ

AsyncReactiveCommand 割と便利。

以下古い内容

ギブアップ

入力エラーが無くなったら押せるボタンが複数あるんだけど、それの二度押し防止をしつつボタンは同時に1つしか押せないようにたいんだよね。

入力エラーの有無は IObservable<bool> で簡単に取れるので、それを ToReactiveProperty して、その ReactiveProperty<bool> から AsyncReactiveCommand を作れば勝つる!!と思うけど、それをやると以下のケースで死にます。

  • 入力エラーを無くす
  • どれかボタンを押す
  • 非同期処理が走ってる間に入力項目をエラーにして、再度エラーを無くす
  • 非同期処理が終わってなくてもボタンが押せるようになる!!

コードとしてはこんなイメージです。ダメな例。

using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace DoubleClickApp
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private ReactiveProperty<bool> SharedStatus { get; }

        public AsyncReactiveCommand HeavyProcessCommand { get; }

        public ReactivePropertySlim<string> Message { get; }

        [Required]
        public ReactiveProperty<string> Input { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();

            Input = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Input);

            SharedStatus = Input
                .ObserveHasErrors
                .Inverse()
                .ToReactiveProperty();
            HeavyProcessCommand = SharedStatus
                .ToAsyncReactiveCommand()
                .WithSubscribe(HeavyProcessAsync);
        }

        private async Task HeavyProcessAsync()
        {
            Message.Value = "開始!";
            await Task.Delay(3000);
            Message.Value = "終了!!";
        }
    }
}

上記コードはコマンドは一つですけど、まぁ同じダメなことが起こります。ReactiveProperty<bool> が外部から書き換えられてしまうのが問題ですね。あくまで AsyncReactiveCommand 間でのステートの共有用にとどめたほうが問題が起きないです。

因みにボタンが 1 つのみの場合は以下のように普通に行けるので安心してください。

using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace DoubleClickApp
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public AsyncReactiveCommand HeavyProcessCommand { get; }

        public ReactivePropertySlim<string> Message { get; }

        [Required]
        public ReactiveProperty<string> Input { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();

            Input = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Input);

            // エラーが無くなったら押せる重たい処理のコマンド
            HeavyProcessCommand = Input.ObserveHasErrors
                .Inverse()
                .ToAsyncReactiveCommand()
                .WithSubscribe(HeavyProcessAsync);
        }

        private async Task HeavyProcessAsync()
        {
            Message.Value = "開始!";
            await Task.Delay(3000);
            Message.Value = "終了!!";
        }
    }
}