Wednesday, October 9, 2024

Choosing a Route

At some level, that feels like most of the app done. But one of the things that I really want to be able to do is to support multiple routes, and that requires the ability to handle input and multiple views.

The overview of widgets in the documentation makes it quite clear that there is a distinction between the "initial view" and subsequent views pushed on top of that. In particular, it is not possible to swipe or scroll the initial view.

Consequently, my thought (and the way a lot of apps seem to be designed) is to have an initial view with some information on it along with a button to open up a swipable view that allows you to rotate (horizontally) through all the routes and scroll (vertically) if a route has too much information to fit on one page.

I think this is going to involve a lot of steps, but I think I'm going to start with trying to handle some input and just print things to the console.

Adding an input handler

Monkey C provides for an input handler to be applied to the initial view by having it be the second member of the array returned by getInitialView. So we can easily define a class to be our input handler and then create an instance of it in getInitialView and pass that back.

I am going to define a BehaviorDelegate because that seems the most promising option from the types of input handlers and specifically I think I want to choose the onSelect method as the one which responds to a tap on the screen which is what will prompt me to open a new view.

This is pretty much the simplest thing I can build:
  function getInitialView() as [Views] or [Views, InputDelegates] {
    return [ new metrolink_watchView(routes[0]), new metrolink_openRoutes() ];
  }
adds the input handler to the initial view. And then
using Toybox.WatchUi;

class metrolink_openRoutes extends WatchUi.BehaviorDelegate {
  function initialize() {
    BehaviorDelegate.initialize();
  }

  function onSelect() {
    System.println("on select...");
    return true;
  }
}
defines the behavior delegate, with onSelect defined to print a message.

In testing this on the simulator, I found that clicking on the watch face prompted my message to come out, while dragging seemed to simulate a swipe (dragging to the right did nothing; every other direction caused my widget to quit).

I would try this on the watch itself, but I don't think I would see any feedback at the moment.

This is checked in as METROLINK_MC_INPUT_HANDLER.

Pushing a new view

Now that we have a "hook" to attach it to, let's see if we can create a new view which can handle drag events. At the moment, it seems to me that we want these views to all be very similar: so similar, in fact, that I think I am going to start off by just reusing exactly the same view code. I think what is going to happen is that ultimately I am going to end up extracting something (probably a base class but possibly a configurable class) which contains the common code and then have multiple other classes to configure that. Bear with me while this quite possibly gets ugly: remember, I'm learning too.

The key to this is the WatchUi.pushView() function, which is basically the same as getInitialView, except it is something you call, rather than being called. So we want to call this and pass it the same view, but a different handler - one that will be able to handle dragging.

After some research and experimentation, it doesn't seem that the BehaviorDelegate can handle the kind of interaction I want, so I have ended up choosing the more primitive InputDelegate and then I'm going to implement the onSwipe method in order to look at all four directions I could swipe. On my watch at least, I will then be able to go back to the main menu by pushing the physical button. No, this is not portable. But then I don't have access to a sufficiently wide range of devices to figure out what the best approach is.

So, onSelect pushes the view so:
  function onSelect() {
    System.println("on select...");
    WatchUi.pushView(new metrolink_watchView(routes[0]), new metrolink_navigate(routes), WatchUi.SLIDE_IMMEDIATE);
    return true;
  }
And the new InputDelegate looks like this:
using Toybox.WatchUi;
import Toybox.Lang;

class metrolink_navigate extends WatchUi.InputDelegate {
  var routes;

  function initialize(routes as Array<Route>) {
    self.routes = routes;
    InputDelegate.initialize();
  }

  function onSwipe(ev) {
    System.println(ev.getDirection());
    return true;
  }
}
Checked in as METROLINK_MC_PUSH_VIEW.

Swiping through the routes

So the big goal is to swipe through the routes. It feels odd to me to have an InputDelegate in one hand and a View in the other, rather than having them be connected. I'm not sure why, since this seems completely in line with the MVC pattern; possibly just because this is the first time I am using this language and toolkit. Anyway, I am going to couple them together at this point by creating them outside the call to pushView() and then telling the InputDelegate about the View. I'm then thinking that when events arrive at the InputDelegate, it will call corresponding methods in the View.

I'm not at all sure this is what the designers intended, and it's entirely possible that I will end up deciding that there is a better way to do this, for example to have a "model" object held in common that the InputDelegate updates and then calls WatchUi.requestUpdate() and then the onUpdate method interrogates the model. Indeed, it seems that that would especially be the case if I found myself storing the data (the model) in local storage. So much so that I'm almost talking myself into doing it!

For now, I'm just going to wire up the actions and check (in the simulator) that I can see the events being processed within the View. I'll leave it until the next section to actually try changing what's displayed.

So, in onSelect in the initial view, we push the wired up navigation view.
  function onSelect() {
    System.println("on select...");
    var view = new metrolink_watchView(routes[0]);
    var handler = new metrolink_navigate(routes, view);
    WatchUi.pushView(view, handler, WatchUi.SLIDE_IMMEDIATE);
    return true;
  }
In the swipe handler, we call back into the view:
class metrolink_navigate extends WatchUi.InputDelegate {
  var routes;
  var view;

  function initialize(routes as Array<Route>, view as metrolink_watchView) {
    self.routes = routes;
    self.view = view;
    InputDelegate.initialize();
  }

  function onSwipe(ev) {
    System.println(ev.getDirection());
    switch (ev.getDirection()) {
    case SWIPE_RIGHT: {
      view.previousRoute();
      break;
    }
    case SWIPE_LEFT: {
      view.nextRoute();
      break;
    }
    }
    return true;
  }
}
And in the view, we just print tracing statements:
  function previousRoute() {
    System.println("previous");
  }

  function nextRoute() {
    System.println("next");
  }
It's an interesting thing - if not a religious debate - as to which direction a "swipe" is or "scroll" is. I feel that - at least with scrolling - the clue is in the name. "Scroll Up" means that the scroll moves up relative to the viewport, so what you see in the window is text which is lower down the scroll. It's less clear to me what exactly "swipe right" means, but I think I would expect it to be the motion of swiping from left to right; but of course, that shows the content to the left, so it is equally plausible to describe that as "swipe left", which is how Garmin describe it. So when I "swipe right", I want the previous page, not the next page as I would expect.

This is checked in as METROLINK_MC_HANDLE_SWIPE.

Choosing the correct route

So, a quick confession: I thought that in metrolink_navigate, we were going to need the routes so that it could choose between them. But in actual fact, I think I've finished coding that and we don't, so I'm going to remove it. Meanwhile, the metrolink_watchView does require all the routes so that it can choose between them.

Apart from all that, we need a counter, currRoute that tells us which route we are looking at right now. This starts at 0, and can count up and down but must always stay in the bounds of 0..routes.size() (I've often though it would be good to have a BoundedCounter class to do this, and today was nearly the day, but no). Finally, we need a reset method which stops and clears the timer (so that the onUpdate method will call to the server for the new route), sets showWait to true (so that the user gets immediate feedback that a new route is being selected) and requests a view update (since this is where all the magic happens).
  function previousRoute() {
    System.println("previous");
    self.currRoute --;
    if (self.currRoute < 0) {
      self.currRoute = self.routes.size()-1;
    }
    reset();
  }

  function nextRoute() {
    System.println("next");
    self.currRoute ++;
    if (self.currRoute >= self.routes.size()) {
      self.currRoute = 0;
    }
    reset();
  }
...
  function reset() as Void {
    timer.stop();
    timer = null;
    showWait = true;
    WatchUi.requestUpdate();
  }
Checked in and tagged as METROLINK_MC_CHOOSE_ROUTE.

No comments:

Post a Comment