Unofficial BBC News feed reader app created with NativeScript in couple of hours

Last week Jen Looper from Telerik set a challenge to create an unofficial BBC News reader app built with {N}. After reviewing another app built with ReactNative, I was surprised at how unorganized RN code looked. There is an XCode project and also have some JS styles embedded in the JS.

Knowing that {N} code is much cleaner (as it separates styles and logic) and I really loved the challenge to transform XML to native controls I decided to start on a freetime project and see where it goes. And here is the result:
ios

The Code

Lets start with the simple part first - the main screen that shows the available news items.

<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="onNavigatingTo">
    <ActionBar title="Feed" backgroundColor="#BB1919" color="#FFFFFF" />

    <GridLayout>
        <ListView items="{{ items }}" itemTap="goToItem" separatorColor="#BB1919">
            <ListView.itemTemplate>
                <GridLayout rows="150,auto,auto" columns="*,*" class="Container">
                    <Image row="0" col="0" colSpan="2" src="{{ imageHref }}" stretch="aspectFill" />
                    <Label row="1" col="0" colSpan="2" class="Title" text="{{ title }}" />
                    <Label row="2" col="0" class="Date" text="{{ lastUpdateDate | diffFormat() }}" /> <!-- 1 -->
                    <Label row="2" col="1" class="Category" text="{{ category.name }}" />
                </GridLayout>
            </ListView.itemTemplate>
        </ListView>
        <ActivityIndicator busy="{{ isLoadingIn }}" />
    </GridLayout>
</Page>

Nothing much special here - we put an ActionBar, style it and define a ListView and a template for it to display the news items. There is one thing worth mentioning - the formatting of the last update <!-- 1 -->. Here I’m using a converter to format the date. The converter is defined globally in the app.ts file so it can be used by all views in the application:

import application = require("application");
import moment = require("moment");

application.resources.diffFormat = (value: Date) => {
    let x = moment(value);
    let y = moment();
    let diffMins = y.diff(x, "minutes");
    let diffHrs = y.diff(x, "hours");

    if (diffMins < 60) {
        return `${diffMins} minutes ago`;
    }
    else {
        return `${diffHrs} hour${(diffHrs > 1 ? "s" : "" )} ago`;
    }
}

But wait isn’t moment a JS library, how are we using it for a native app? The cool thing is that because NativeScript is based on JS you can use many JS libraries out of the box (as long as they do not use anything browser/node specific). Neat, huh?

Now for the interesting part - the view that shows the content of the news item. If you look at the feed-item view and model files there is not much happening there. That’s because the main logic for this is hidden in the libs/parse-helper.ts files. First let me say that {N}’s XML Parser traverses the XML tree in depth. So the best shot we have to map those XML elements to NativeScript controls is to use a stack (or in the JS world - a simple array). So the general idea is when we encounter a start element we create an appropriate {N} object and add it to the stack. So for example paragraphs/crossheads I map to Labels, bold/italic text will be represented as Spans inside the Label, links will also be represented by Spans, but with a special styling and so on. Then when we get to some text depending on what we have at the top of the stack we add the text to that element. Finally when we get to an XML end element we pop one item from the stack and add it to the next one.

private static _handleStartElement(elementName: string, attr: any) {
    let structureTop = ParseHelper.structure[ParseHelper.structure.length - 1];

    switch (elementName) {
        // ...
        case "bold":
            let sb: Span;
            if (structureTop instanceof Span) { /* 1 */
                sb = structureTop;
            }
            else {
                sb = new Span();
                ParseHelper.structure.push(sb);
            }

            sb.fontAttributes = sb.fontAttributes | enums.FontAttributes.Bold;
            break; 

        case "link":
            if (!ParseHelper._urls) {
                ParseHelper._urls = [];
            }
            let link = new Span();
            link.underline = 1;
            link.foregroundColor = new Color("#BB1919");
            ParseHelper.structure.push(link);
            ParseHelper._urls.push({start: (<Label>structureTop).formattedText.toString().length}); /* 2 */
            break;

        case "url": /* 3 */
            let lastUrl = ParseHelper._urls[ParseHelper._urls.length - 1];
            lastUrl.platform = attr.platform;
            lastUrl.href = attr.href;
            break;

        case "caption":
            ParseHelper._isCaptionIn = true; /* 4 */
            break;

        // ...

        case "video":
            let videoSubView = 
                builder.load(fs.path.join(fs.knownFolders.currentApp().path, "view/video-sub-view.xml")); /* 5 */
            let model = ParseHelper._getVideoModel(attr.id);
            videoSubView.bindingContext = model;
            ParseHelper.structure.push(videoSubView);
            break;

        // ...
    }
}
private static _handleEndElement(elementName: string) {
    switch (elementName) {
        // ...
        case "paragraph":
        case "listItem":
        case "crosshead":
            let label: Label = ParseHelper.structure.pop();
            if (ParseHelper._urls) { /* 6 */
                label.bindingContext = ParseHelper._urls.slice();
                ParseHelper._urls = null;
            }
            (<StackLayout>ParseHelper.structure[ParseHelper.structure.length - 1]).addChild(label);
            break;  

        // ...

        case "italic":
        case "bold":
        case "link":
            // Added check for nested bold/italic tags
            if (ParseHelper.structure[ParseHelper.structure.length - 1] instanceof Span) { /* 7 */
                let link: Span = ParseHelper.structure.pop();
                (<Label>ParseHelper.structure[ParseHelper.structure.length - 1]).formattedText.spans.push(link);
            }
            break;

        case "caption":
            ParseHelper._isCaptionIn = false;
            break;      
        // ...
    }
}
private static _handleText(text: string) {
    if (text.trim() === "") return;

    let structureTop = ParseHelper.structure[ParseHelper.structure.length - 1];

    if (structureTop instanceof Label) {
        let span = new Span();
        span.text = text;
        (<Label>structureTop).formattedText.spans.push(span);
    }
    else if (structureTop instanceof Span) {
        (<Span>structureTop).text = text;
        if (ParseHelper._isCaptionIn) { /* 8 */
            ParseHelper._urls[ParseHelper._urls.length - 1].length = text.length;
        }
    }
    else {
        console.log("UNKNOWN TOP", structureTop);
    }
}

Couple of things worth mentioning:

  1. Since we can have tested bold and italic formatting I had to add /*1*/ in order not add multiple spans but instead apply the formatting on the previous span. And also /*7*/ which pops from the stack only if the item is a Span. In case of nested formatting the Span would be inserted to the Label on the first end bold/italic XML element.
  2. For links since we use simple text we need to remember on what positions exactly do links show (/*2*/) what are their properties (/*3*/) and what is the length of the text in the link (/*4*/ and /*8*/). Then once we finish parsing all the items for the Label we set the found urls as binding context for the Label (/*6*/)
  3. For the video I decided to take a different approach. Because for the video we will need a more complex layout - because we have poster image, play button image and then when clicked we should load and show the video, I decided to separate this in a separate file video-sub-view.xml:
<GridLayout height="200" tap="{{ startVideo }}">
    <Image stretch="aspectFill" src="{{ posterHref }}" />
    <Image src="~/images/play-button.png" width="100" stretch="aspectFit" height="200" />
    <ActivityIndicator busy="{{ isLoadingIn }}" />
</GridLayout>

Then on <!--5--> I load the file with the built-in functions provided by {N}. The neat part is that this function returns a View object which is basically the base building block for all controls. And with that view you can do whatever you can do with any other {N} control. In this case I set the bidningContext. The only catch is that the builder requires the full path to load the XML file. So you cannot use relative paths but you must first get the application directory and then add to that the path to your file. For showing and playing the actual video I’m using Brad Martin’s nativescript-videoplayer plugin.

Conclusion

With only a couple of hours {N} allowed me to create a fully functional native app that works seamlessly for both iOS and android.
You can find the full code here.
You can find more about the challenge and other entries here

Labels: , ,