Rails, rails, everywhere

What you will learn

In this lesson, you will use ScrollView components to create a more complex screen with vertical and horizontal navigation.

This should be the final result :

Final result

Retrieve categories name and contents

Before creating new views, you should retrieve categories name and content in prepareData of CatalogScreen. For the moment, always retrieve first catalog to get the categories and their contents.

export default $AppScreen.declare("CatalogScreen", {
    methods: {
      prepareData: function (context) {
            const result = {};

            return this.vodService.getCatalogs()
                .then(catalogs => {
                    result.menu = catalogs;
                    return this._getCatalogContents(catalogs[0]);
                })
                .then(datas => {
                    result.datas = datas;
                    return result;
                });
        },

        _getCatalogContents: function (catalog) {
            let promises = [];
            const datas = [];

            catalog.children.forEach(category => {
                const promise = this.vodService.getCategory(catalog.id, category.id).then(category => {
                    return this.vodService.getContents(category.id).then((contents) => {
                        datas.push({
                            railName: category.name,
                            listData: contents
                        });
                    });
                });

                promises.push(promise);
            });
            return Promise.all(promises).then(_ => {
                return datas;
            });
        },
    }
});

ScrollView usage

A ScrollView is a vertical RecyclingListView of ScrollViewItem. A ScrollViewItem is a View with horizontal navigation. In our case, we will use a RecyclingListView with horizontal navigation to create a rail. By default, a children named list inheriting from RecyclingListView exist in ScrollViewItem.

Create a file CatalogScrollItemView.js to create CatalogScrollItemView inheriting of ScrollItemView. Then, add a TextPrimitive as a children to display category name. Finally, implement setData to assign data to the TextPrimitive.

import $ScrollItemView from "@ScrollItemView";
import $TextPrimitive from "@TextPrimitive";
import $Theme from "@Theme";

/**
 *
 * @name CatalogScrollItemView
 * @class
 * @extends ScrollItemView
 */
export default $ScrollItemView.declare("CatalogScrollItemView", {
    statics: /** @lends CatalogScrollItemView */ {
        TITLE_SIZE: 45,
        MARGIN: 20
    },
    children: {
        title: {class: $TextPrimitive}
    },
    methods: /** @lends CatalogScrollItemView.prototype */ {
        /**
         * @override
         */
        _setData: function (data, options) {
            this.title.text = data.railName;
        }
    },

    style: {
        height: ({statics, list}) => list.height + statics.TITLE_SIZE + statics.MARGIN,
        width: $Theme.FULL_SCREEN_WIDTH - $Theme.SCREEN_HORIZONTAL_MARGIN * 2,
        title: {
            height: ({parent}) => parent.statics.TITLE_SIZE,
            width: $Theme.FULL_SCREEN_WIDTH - $Theme.SCREEN_HORIZONTAL_MARGIN * 2,
            color: $Theme.COLOR_BLACK,
            size: $Theme.H2_FONT_SIZE
        }
    }
});

To see this item, you need to create a new children in your CatalogScreen. Name it scrollView and make it use the new CatalogScrollItemView to populate. Send datas from the screen to the scrollView.

import $ScrollView from "@ScrollView";
import $CatalogScrollItemView from "@CatalogScrollItemView";

export default $AppScreen.declare("CatalogScreen", {
    children: /** @lends CatalogScreen.prototype */ {
        menu: {class: $MenuListView},
        scrollView: {
            class: $ScrollView,
            itemViewClass: () => $CatalogScrollItemView,
            itemMargin: 70
        }
    },

    style: {
        scrollView: {
            x: $Theme.SCREEN_HORIZONTAL_MARGIN,
            y: 100
        }
    },

    methods: /** @lends CatalogScreen.prototype */ {
        connectData: function (context) {
            this.menu.setData(this.data.menu);
            this.scrollView.setData(this.data.datas);
        }
    }

});

Refresh your page to see the different titles being displayed below the menu.

To complete integration of the scroll view, you should handle navigation. Even if scroll view is coming with a navigation behaviour, you should manage the “out-of-bounds” use cases. For example, when pressing down will focused on a ScrollView, two cases can happend :

  • There is more items to focused on the scroll view, and you should let internal navigation occurred,
  • There is no more items to focus on the scroll view, and you may want to change the focus to another view of the screen.

Caution

Navigation methods, like up, down, left or right, returns a boolean to know if the event as been consumed. If methods return true, it means that an action has already been taken for this event.

For your screen use case, you will have :

  • when pressing down
    • call internal navigation
      • if consumed, return true
      • if not consumed
        • if focus is on menu, focus scroll view and return true
        • otherwise return false
  • when pressing up
    • call internal navigation
      • if consumed, return true
      • if not consumed
        • if focus is on scroll view, focus menu and return true
        • otherwise return false
export default $AppScreen.declare("CatalogScreen", {
    methods: /** @lends CatalogScreen.prototype */ {
        down: function () {
            const res = $AppScreen.prototype.down.apply(this, arguments);

            if (res) {
                return true;
            }

            if (this.focusedChild.id === this.menu.id) {
                this.scrollView.focus();
                return true;
            }

            return false;
        },

        up: function () {
            const res = $AppScreen.prototype.up.apply(this, arguments);

            if (res) {
                return true;
            }

            if (this.focusedChild.id === this.scrollView.id) {
                this.menu.focus();
                return true;
            }

            return false;
        }
    }
});

Caution

There is no support of super keyword to call parent’s method implementation. Use $SuperClass.prototype.<methodName>.apply(this, arguments) instead.

You can now navigate vertically between your menu and the scroll view. Also, you already have navigation inside the scroll view. When requesting focus on the second title, view automatically scrolls. Do not hesitate to hide the menu when the scrolling begins:

export default $AppScreen.declare("CatalogScreen", {
    style: {
        menu: {
            opacity: ({parent}) => parent.scrollView.index > 0 ? 0 : 1
        }
    }
});

Create a rail full of tiles

As for the menu, create a new class inheriting of RecyclingListView in a file named RailListView. Also create a new class LandscapeTileItemView inheriting of RecyclingItemView that will represent an item of the rail. In a first step, only add a TextPrimitive as a child to display the content’s title.


import $RecyclingListView from "@RecyclingListView";
import $LandscapeTileItemView from "@LandscapeTileItemView";
import $MHListNavigation from "@MHListNavigation";
import $Theme from "@Theme";

/**
 *
 * @name RailListView
 * @class
 * @extends RecyclingListView
 * @property {Number} itemMargin Space between items
 * @property {LandscapeTileItemView} itemViewClass Class of an item
 */
export default $RecyclingListView.declare("RailListView", {
    traits: [$MHListNavigation],
    statics: /** @lends RailListView */ {
        ITEM_HEIGHT: $LandscapeTileItemView.TILE_CARD_HEIGHT
    },
    properties: /** @lends RailListView.prototype */ {
        itemViewClass: () => $LandscapeTileItemView,
        itemMargin: 20
    },

    style: {
        height: ({statics}) => statics.ITEM_HEIGHT,
        width: $Theme.FULL_SCREEN_WIDTH - $Theme.SCREEN_HORIZONTAL_MARGIN * 2
    }
});
import $RecyclingItemView from "@RecyclingItemView";
import $ImagePrimitive from "@ImagePrimitive";
import $TextPrimitive from "@TextPrimitive";
import $RectanglePrimitive from "@RectanglePrimitive";
import $Theme from "@Theme";

/**
 *
 * @name LandscapeTileItemView
 * @class
 * @extends RecyclingItemView
 */
export default $RecyclingItemView.declare("LandscapeTileItemView", {
    statics: /** @lends LandscapeTile */ {
        TILE_CARD_WIDTH: 320,
        TILE_CARD_HEIGHT: 24
    },
    children: /** @lends LandscapeTile.prototype */ {
        title: {
            class: $TextPrimitive
        }
    },
    methods: /** @lends LandscapeTile.prototype */ {
        /**
         * Set properties with current data
         * @private
         * @override
         */
        _setData: function () {
            if (this.data != null) {
                this.title.text = this.data.title;
            }
        }
    },

    style: {
        width: ({statics}) => statics.TILE_CARD_WIDTH,
        height: ({statics}) => statics.TILE_CARD_HEIGHT,
        title: {
            width: ({parent}) => parent.statics.TILE_CARD_WIDTH,
            height: ({parent}) => parent.statics.TILE_CARD_HEIGHT
        }
    }
});

To make your scroll list using this list, override list child by your RailListView class and set datas in it.

import $RailListView from "@RailListView";

export default $ScrollItemView.declare("CatalogScrollItemView", {
    children: {
        list: {class: $RailListView}
    },
    methods: /** @lends CatalogScrollItemView.prototype */ {
        /**
         * @override
         */
        _setData: function (data, options) {
            this.list.setData(data.listData, options);
        }
    },
    style: {
        list: {
            y: ({parent}) => parent.statics.TITLE_SIZE + parent.statics.MARGIN
        }
    }
});

Add getImageurl in model and dipsplay image

Improve tile design

Current tile is missing a way to inform where focus is. Let’s add an image to each of the contents and add a border over it when focus is on the tile.

First, modify the CustomBEContent.

Caution

Content is an ImageModel and offer an getImageUrl that requires an implementation of _getImageUrl methods. It is the only method alowed on Model

Note

Lorem Picsum can generate image of the expected size. Use id as a seed to always retrieve the same image of a content.

export default $AbstractContent.declare("CustomBEContent", {
    methods: /** @lends WiztiviBECmsContent.prototype */ {
        /**
         * Allows to retrieve an model image from image data and options passed as arguments
         * @param {Object} options Options to help construct image url
         * @param {number} options.ratio ratio of the image asked
         * @param {number} options.width width of the image asked
         * @param {number} options.height height of the image asked
         * @return {string} Image url
         */
        _getImageUrl: function (options) {
            return `https://picsum.photos/seed/${this.id}/${options.width}/${options.height}`;
        }
    }
});

In the LandscapeTileItemView, add an ImagePrimitive to display this new image and a RectanglePrimitive to create a border over it.

import $ImagePrimitive from "@ImagePrimitive";
import $RectanglePrimitive from "@RectanglePrimitive";
import $Theme from "@Theme";
export default $RecyclingItemView.declare("LandscapeTileItemView", {
    statics: /** @lends LandscapeTile */ {
        TILE_CARD_WIDTH: 320,
        IMAGE_CARD_HEIGHT: 160,
        TILE_CARD_HEIGHT: 184
    },
    children: /** @lends LandscapeTile.prototype */ {
        image: {
            class: $ImagePrimitive
        },
        title: {
            class: $TextPrimitive
        },
        border: {
            class: $RectanglePrimitive
        }
    },
    methods: /** @lends LandscapeTile.prototype */ {
        /**
         * Set properties with current data
         * @private
         * @override
         */
        _setData: function () {
            if (this.data != null) {
                this.image.url = this.data.getImageUrl({width: this.statics.TILE_CARD_WIDTH, height: this.statics.IMAGE_CARD_HEIGHT});
                this.title.text = this.data.title;
            }
        }
    },
    style: {
        image: {
            width: ({parent}) => parent.statics.TILE_CARD_WIDTH,
            height: ({parent}) => parent.statics.IMAGE_CARD_HEIGHT,
            borderRadius: 10
        },
        title: {
            height: ({parent}) => parent.statics.TILE_CARD_HEIGHT - parent.statics.IMAGE_CARD_HEIGHT,
            y: ({parent}) => parent.statics.IMAGE_CARD_HEIGHT
        },
        border: {
            width: ({parent}) => parent.statics.TILE_CARD_WIDTH,
            height: ({parent}) => parent.statics.IMAGE_CARD_HEIGHT,
            opacity: ({parent}) => parent.isFocused ? 1 : 0,
            borderWidth: 10,
            borderColor: $Theme.COLOR_BLUE_LIGHT,
            borderRadius: 10
        }
    }
});

Summary

To summarize, during this lesson you have :

  • used ScrollView components,
  • handled a mix of internal and external navigation in a screen

Result of you application architecture is as follows :

diagram