How to use Reactive UI to trigger a different acti

2019-07-07 05:40发布

问题:

I'm trying to implement a UI control where the user can click a button to have a thing move by a little, or hold the button down and have the thing move while the button is held down.

Let's say I have Task<Unit> StartMove(), Task<Unit> StopMove() and Task<Unit> MoveStep(). The button click should perform the MoveStep() and the button hold should start the move and then stop the move immediately when the button is released. Rapid clicks (double clicks) should be ignored while the move is happening and there should not be more than 2x MoveStep commands sent per second. There also needs to be some fail safe which stops the move on an error or after a long timeout of let's say 5 mins.

The button press is represented by a property on the Button object, which fires a true value when the user presses the button and a false when it is released, this value is called IsPressed on the regular WPF button. A true value followed by a false value less than a second later represents a click and a true value followed by a false value more than a second later represents a hold (this sec value could also be tuned to half a second).

The question boils down to taking a stream of such true / false values that arrive at a random interval (think: The monkey is pressing the button randomly) and determining from this stream if the button is clicked or held down. Based on this, the actions should be triggered: MoveStep for a click and StartMove then StopMove for a button hold.

Working code snippet

I finally got something together that kinda works.

So far I have MainWindow

public partial class MainWindow : Window, IViewFor<AppViewModel>
{
    public AppViewModel ViewModel { get; set; }
    object IViewFor.ViewModel { get => ViewModel; set => ViewModel = value as AppViewModel; }

    public MainWindow()
    {
        ViewModel = new AppViewModel();
        DataContext = ViewModel;
        InitializeComponent();

        this.WhenAnyValue(x => x.MoveLeftButton.IsPressed).InvokeCommand(this, x => x.ViewModel.MoveLeftCommand);

    }

    protected override void OnClosing(CancelEventArgs e)
    {
        ViewModel.Dispose();
        base.OnClosing(e);
    }
}

An AppViewModel

public class AppViewModel : ReactiveObject, IDisposable
{
    public ReactiveCommand<bool, bool> MoveLeftCommand { get; protected set; }

    public AppViewModel()
    {
        MoveLeftCommand = ReactiveCommand.CreateFromTask<bool, bool>(isPressed => _MoveLeft(isPressed));

        MoveLeftCommand.Buffer(TimeSpan.FromMilliseconds(500))
            .Do(x => _InterpretCommand(x))
            .Subscribe(x => Console.WriteLine($"{TimeStamp} {string.Join(",", x)}"))

    }

    private Task<bool> _MoveLeft(bool isPressed)
    {
        return Task.Run(() => isPressed); // Just to set a breakpoint here really
    }

    private static void _InterpretCommand(IList<bool> listOfBools)
    {
        if (listOfBools == null || listOfBools.Count == 0)
        {
            return;
        }

        if (listOfBools.First() == false)
        {
            Console.WriteLine("Stop move");
            return;
        }

        if (listOfBools.Count == 1 && listOfBools.First() == true)
        {
            Console.WriteLine("Start move");
            return;
        }

        if (listOfBools.Count >= 2)
        {
            Console.WriteLine("Click move");
            return;
        }
    }
}

And my MainWindow.xaml is really just

        <Button x:Name="MoveLeftButton" Content="Left"/>

Random sequence example

        var rands = new Random();
        rands.Next();


        var better = Observable.Generate(
            true,
            _ => true,
            x => !x,
            x => x,
            _ => TimeSpan.FromMilliseconds(rands.Next(1000)))
            .Take(20);

        better.Buffer(TimeSpan.FromMilliseconds(500))
            .Do(x => _InterpretCommand(x))
            .Subscribe(x => Console.WriteLine($"{TimeStamp} {string.Join(",", x)}"));

    static string TimeStamp => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);

This produces the output

2017-10-06 19:11:54.231 
Start move
2017-10-06 19:11:54.720 True
2017-10-06 19:11:55.220 
Stop move
2017-10-06 19:11:55.719 False,True
Stop move
2017-10-06 19:11:56.221 False
Start move
2017-10-06 19:11:56.719 True
Stop move
2017-10-06 19:11:57.222 False
2017-10-06 19:11:57.719 
Start move
2017-10-06 19:11:58.220 True
Stop move
2017-10-06 19:11:58.720 False
2017-10-06 19:11:59.219 
Click move
2017-10-06 19:11:59.719 True,False
2017-10-06 19:12:00.217 
Start move
2017-10-06 19:12:00.719 True
Stop move
2017-10-06 19:12:01.221 False
Click move
2017-10-06 19:12:01.722 True,False
Start move
2017-10-06 19:12:02.217 True
2017-10-06 19:12:02.722 
Stop move
2017-10-06 19:12:03.220 False
2017-10-06 19:12:03.720 
Start move
2017-10-06 19:12:04.217 True
Stop move
2017-10-06 19:12:04.722 False
Start move
2017-10-06 19:12:05.220 True
Stop move
2017-10-06 19:12:05.516 False

回答1:

With insight from this answer: https://stackoverflow.com/a/46629909/377562 I stringed together something that works great!

BufferWithClosingValue from the linked answer:

public static IObservable<IList<TSource>> BufferWithClosingValue<TSource>(
    this IObservable<TSource> source, 
    TimeSpan maxTime, 
    TSource closingValue)
{
    return source.GroupByUntil(_ => true,
                               g => g.Where(i => i.Equals(closingValue)).Select(_ => Unit.Default)
                                     .Merge(Observable.Timer(maxTime).Select(_ => Unit.Default)))
                 .SelectMany(i => i.ToList());
}

Random sequence example:

var alternatingTrueFalse = Observable.Generate(
    true,
    _ => true,
    x => !x,
    x => x,
    _ => TimeSpan.FromMilliseconds(new Random().Next(1000)))
    .Take(40).Publish().RefCount();

var bufferedWithTime = alternatingTrueFalse.BufferWithClosingValue(TimeSpan.FromMilliseconds(500), false);

var clicks = bufferedWithTime.Where(x => x.Count() == 2).ThrottleFirst(TimeSpan.FromMilliseconds(500));
var holdStarts = bufferedWithTime.Where(x => x.Count() == 1 && x.First() == true);
var holdStops = bufferedWithTime.Where(x => x.Count() == 1 && x.First() == false);

clicks.Select(_ => "Click").DumpTimes("Clicks");
holdStarts.Select(_ => "Hold Start").DumpTimes("Hold Start");
holdStops.Select(_ => "Hold Stop").DumpTimes("Hold stop");

Using the ThrottleFirst / SampleFirst implementation from this answer: https://stackoverflow.com/a/27160392/377562

Example output

2017-10-08 16:58:14.549 - Hold Start-->Hold Start :: 6
2017-10-08 16:58:15.032 - Hold stop-->Hold Stop :: 7
2017-10-08 16:58:15.796 - Clicks-->Click :: 7
2017-10-08 16:58:16.548 - Clicks-->Click :: 6
2017-10-08 16:58:17.785 - Hold Start-->Hold Start :: 5
2017-10-08 16:58:18.254 - Hold stop-->Hold Stop :: 7
2017-10-08 16:58:19.294 - Hold Start-->Hold Start :: 8
2017-10-08 16:58:19.728 - Hold stop-->Hold Stop :: 7
2017-10-08 16:58:20.186 - Clicks-->Click :: 6

This doesn't seem to have any race condition problems that I've had with some other attempts at solving this, so I like it!



回答2:

In my limited experience I believe you should be able to add Rx extensions like Throttle or Buffer after your WhenAnyValue statement but before you invoke the command.

this.WhenAnyValue(x => x.MoveLeftButton.IsPressed) .Buffer(TimeSpan.FromSeconds(1)) .InvokeCommand(this, x => x.ViewModel.MoveLeftCommand);