3

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
gakera
  • 3,589
  • 4
  • 30
  • 36
  • I read a little more of the Reactive UI documentation (some of it is very confusing, why are there chatlogs?) - and it seems that I could use the BindCommand method in the view itself, I'll have to look into that when I get the chance https://reactiveui.net/docs/handbook/commands/binding-commands – gakera Sep 30 '17 at 12:35

2 Answers2

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!

gakera
  • 3,589
  • 4
  • 30
  • 36
0

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);

Rodney Littles
  • 544
  • 2
  • 11
  • The problem with that is I want to start moving as soon as the user presses the button, but only stop moving after at least one second. So it's really 2 commands that I need to send, start and stop, based on the isPressed value. – gakera Oct 04 '17 at 18:14
  • If there are events emitted you can subscribe to the event. You can invoke one command, every second you can force it to fire an event, and subscribe to the event in a different observable. So start fires off a command, stop listens for events to do some work on them. I am new to Reactive programming, so I'd struggle providing a working sample. – Rodney Littles Oct 05 '17 at 16:17
  • Actually, my specifications changed a little bit and I'm now using a special command for clicks and the start / stop for button holds, so I think a Buffer makes sense, I just have to pass the buffered values along to process them, to decide which command to send. This isn't perfect, it doesn't stop moving immediately when I release the button hold, but I don't know how to fix that yet. – gakera Oct 06 '17 at 20:22