Wednesday, July 10, 2013

tree control-flow apps on a responsive grid

I wired up a proof of concept responsive browser UI that implements an application's views as a series of panels. A single panel holds the UI focus at any given time, and each panel is compact enough to render on a smart phone, so on a phone sized device the UI renders a single panel at a time, but on a larger device the UI may render 2, 3, or 4 adjoining panels depending on the device size. For example, the demo, application just navigates through a tree of panels (a decision-tree control flow is common in apps). On a typical laptop the UI renders up to 4 adjoining panels where the right-most panel is the "focus" of the app, and a panel's parent is to its left. If we make the browser window smaller, then the UI eventually removes the left-most panel from the screen, and continues removing the left panel as the window shrinks until only the focus-panel remains.

I started on the demo while thinking about how to design an application that from the beginning behaves in a reasonable way on a phone, tablet, or PC. I like the idea of using a responsive design to implement one web app that works well across devices. It's too much work to build a bunch of separate "apps" for iOS, Android, etc. with custom UI designs for phone and tablet. I recently started playing around with purecss (a CSS framework forked from yui) that includes an implementation of a "responsive grid". Responsive grids like those in Twitter bootstrap and purecss leverage CSS3 media queries to implement a web page layout that behaves differently on phone, tablet, and PC sized screens.

I implemented the demo with typescript and YUI - the code is on github. The UI manager follows patterns like those in YUI's app framework. The application registers views (panels) with the manager, and associates each panel with a route based on the panel's parent and a basename filter. The view manager then takes care of deciding which panels to render where when the application triggers a change in route. The view manager also registers a click-handler, so a.little-route links trigger a route change, but the application can also manage the YUI router directly.

Eventually we wind up with application code like this:

    // home page info
    var versionInfo = Y.Node.create("<div id='app-info'><span class='app-title'>LittleEvents</span><br>Version 0.0 2013/06/21</p></div>");
    var homePage = new Y.View();
    homePage.get("container").append(versionInfo);

    // router
    var router = new Y.Router(
 { root: "/littleware_apps/blog/gridDemo.html" }
 );

    // inject homepage and router into view manager
    var app = appModule.ViewManager.getFactory().create("app", "div#app", homePage, router);

    //
    // passed to registerPanel (below) - notifies panel of new path on view change.
    // could also just add a listener on the router ...
    //
    var viewListener = function (panelStatus) {
 if (panelStatus.state.name == "VISIBLE") {
     var oldPath = panelStatus.panel.view.get("pathParts").join("/");
     if (oldPath != panelStatus.path) {
  //log.log("Setting new path: " + oldPath + " != " + panelStatus.path);
  panelStatus.panel.view.set("pathParts", panelStatus.path.split("/"));
  app.markPanelDirty(panelStatus.panel.id);
     }
 }
    };

    // register panels according to position in control-flow tree
    app.registerRootPanel(panel1, panel1.id, viewListener);
    // panel2 is a "child" of panel1
    app.registerPanel(panel2, panel1.id, function () { return true; }, viewListener );
    app.registerPanel(panel3, panel2.id, function () { return true; }, viewListener );
    app.show();

..., and a library interface like this:

    export module ViewManager {
      ...
        /**
         * Little helper manages the display of panels based
         * on the routes triggered in router and size of display
         * (phone, tablet, whatever).
         * Assumes tree-based panel app.
         *
         * @class Manager
         */
        export interface Manager {
            name: string;

            /**
             * Root div under which to manage the panel UI
             */
            container: Y.Node;

            /**
             * Info panel embedded in home page at path "/" - splash screen,
             * version info, whatever placed above the "route index"
             * @property homePage {Y.View}
             */
            homePage: Y.View;
            router: Y.Router;

            /**
             * List of routes to sort and display in the "index" on the home page
             * along with the "root" panel paths.
             */
            routeIndex: string[];


            /**
             * Child panel of homePage - "config"
             * is reserved for internally managed configuration panels.
             */
            registerRootPanel(
                panel: LittlePanel,
                baseName: string,
                listener: (PanelStatus) => void
                );

            /**
             * Register panels along with id of its parent, and a baseFilter
             * that either accepts or rejects a route basename.
             * For example, given some route /path/to/parent/bla/foo/frick,
             * then for each element of the path [path,to,parent,bla,foo,frick],
             * test that element against the children of a parent panel
             * to determine which panel to associate with that route.
             *
             * @method registerPanel
             */
            registerPanel(
                    panel: LittlePanel,
                    parentId: string,
                    routeFilter: (string) => bool,
                    listener: (PanelStatus) => void
                );

            /**
             * By default the manager does not re-render a panel when it
             * becomes "visible" unless the panel is "dirty".
             * If the panel is already visible, then re-render panel once
             * call stack clears: Y.later( 0, () => render() ).
             *
             * @method markPanelDirty
             */
            markPanelDirty(panelId: string);

            /**
             * Triggers the manager to render its initial view (depends on the active route),
             * and begin responding to routing and dirty-panel notifications in the "Active" 
             * ManagerState.  NOOP if already active.
             *
             * @method show
             */
            show();

        }


        export interface Factory {
            /**
             * Create a new view manager that manipulates DOM in the given selector
             *
             * @param name alphanumeric to associate with this manager - used as a key
             *               in persistence store
             * @param selector CSS selector for div under which to build view
             */
            create(name: string, selector: string, homePage: Y.View, router: Y.Router): Manager;
            //create( name:string, selector: string): Manager;

            /**
             * Load manager state from persistent storage
             */
            load( name:string ): Manager;
        }

       ...
   }

Anyway - I'm pretty happy with how things fit together in the demo, but I won't really know how well this works until I use it with a couple apps. We'll see how it goes.

No comments: