My Experience Creating My First NativeScript UI Plugin

November 8th, 2017

My daughter is turning 4 next week and we are hosting a unicorn party this weekend, hence my inspirational header image.

Pretty recently I wrote blog post about coming up with a mobile UI element that could handle selecting items from very large lists. I came up with something I think is really cool, so I wrote a blog post about it that was featured on NativeScript.org.

I knew it would make a great plugin, but all I'd ever done is Cocoapods integrations and exposing some cool native iOS APIs. This plugin utilizes NativeScript views, no platform specific APIs needed at all. It's basically a functional wrapper around NativeScript functionality relatively easily achievable with out of the box NativeScript components.

I had no idea how to do it, and frankly, the documentation on creating plugins of this nature leaves a lot to be desired.

Said documentation got me part of the way there. I'll try and parse some of it for you, as I understand it. But to quote my friend and relunctant occasional mentor Brad Martin, "I have no idea what I'm doing." If you notice things that I say that are entirely incorrect or represent a misunderstanding of norms and best practices, get in touch with me or comment below.

Basically, your class should extend a NativeScript layout class, like GridLayout, or View, or StackLayout etc. So if you do something simple like

export class Meme extends StackLayout { constructor() { super(); } }

And register your element in your xml namespace, you can do

<ui:Meme></ui:Meme>

and treat it exactly like a StackLayout. So doing

<ui:Meme> <Label text="I'm on top" /> <Label text="I'm on bottom" /> </ui:Meme>

It will stack just like a StackLayout on both iOS and Android.

But why would I want to do that? Well, if you continue reading that documentation, you can load other NativeScript elements into your class container, so you can provide functionality in your plugin that sets up layouts and views for users of your plugin.

The Filterable List Picker is a perfect example of this. What I wanted was to allow the user to place some simple xml in their app and get a fully functonal list picker.

So, based on that tutorial, I started with something like this:

import { ObservableArray } from 'tns-core-modules/data/observable-array'; import { View, Property } from "tns-core-modules/ui/core/view"; import { GridLayout } from 'tns-core-modules/ui/layouts/grid-layout'; import { StackLayout } from 'tns-core-modules/ui/layouts/stack-layout'; import * as frame from 'tns-core-modules/ui/frame'; import { isIOS } from "tns-core-modules/platform"; let builder = require('tns-core-modules/ui/builder'); export const hintTextProperty = new Property<FilterableListpicker, string>({ name: "hintText", defaultValue: 'Enter text to filter...' }); export const sourceProperty = new Property<FilterableListpicker, ObservableArray<string>>({ name: "source", defaultValue: new ObservableArray(["Test"]), affectsLayout: true }); export class FilterableListpicker extends GridLayout { constructor() { super(); let innerComponent = builder.load(__dirname + '/filterable-listpicker.xml') as View; innerComponent.bindingContext = this; this.addChild(innerComponent); } public source: any; public hintText: any; } hintTextProperty.register(FilterableListpicker); sourceProperty.register(FilterableListpicker);

You'll notice I am using builder to load in some xml from my plugin code. Here's that xml:

<GridLayout id="dc_flp_container" visibility="collapsed"> <StackLayout id="dc_flp" style="background-color: white; border-radius: 10;"> <TextField hint="{{hintText}}" id="filterTextField" style="padding: 10 15; height: 40; background-color: #E0E0E0; border-radius: 10 10 0 0;" /> <ListView items="{{ source }}"> <ListView.itemTemplate> <Label text="{{$value}}" style="margin-left: 15; padding: 10 0;" /> </ListView.itemTemplate> </ListView> </StackLayout> </GridLayout>

So now if we put

<ui:FilterableListpicker></ui:FilterableListpicker>

in our app, it will load up our GridLayout with the contents of our XML file already in it. So we have a StackLayout that holds our ListView.

The next challenge was to get an array of strings into our ListView. This is handled by registering a property on our class. You'll notice in the plugin TypeScript code above, we do

export const sourceProperty = new Property<FilterableListpicker, ObservableArray<string>>({ name: "source", defaultValue: new ObservableArray(["Test"]), affectsLayout: true });

That sets up a Property, which we need to register on our class so we can grab the source we set on our custom UI element.

We do that like this (make sure to do it after the class is defined): sourceProperty.register(FilterableListpicker);

Properties are a little confusing, I still don't totally understand them. Learn more about Properties here.

Now we can add source as a property to our UI element:

<ui:FilterableListpicker source="{{listitems}}"></ui:FilterableListpicker>

listitems is an array of strings in our page's binding context. Pretty cool! Now we get a list containing the array we set up in our app!

To add the filtering capability we need a TextField, and this is a modal (a UI element that sits on top of other UI elements), so we need a Cancel control too. We just need to add some more NativeScript elements to our XML file that creates the FilterableListpicker. I also want to "dim" the content below it so acts similar to a dialog. Here's how I want it to look in the end:

Heres my final xml in filterable-listpicker.xml (the xml thats loaded into the custom UI component)

<GridLayout id="dc_flp_container" visibility="collapsed"> <StackLayout tap="{{cancel}}" width="100%" height="100%" /> <StackLayout width="{{listWidth}}" row="1" id="dc_flp" height="{{listHeight}}" style="background-color: white; border-radius: 10;"> <TextField hint="{{hintText}}" text="{{filterText}}" id="filterTextField" style="padding: 10 15; height: 40; background-color: #E0E0E0; border-radius: 10 10 0 0;" /> <ListView items="{{ source }}" height="{{listHeight - 80}}" itemTap="{{choose}}"> <ListView.itemTemplate> <Label text="{{$value}}" style="margin-left: 15; padding: 10 0;" /> </ListView.itemTemplate> </ListView> <StackLayout style="background-color: #E0E0E0; height: 40; border-radius: 0 0 10 10;"> <Button text="Cancel" tap="{{cancel}}" verticalAlignment="middle" style="font-weight: bold; height: 40; background-color: transparent; background-color: transparent; border-color: transparent; border-width: 1; font-size: 12;" /> </StackLayout> </StackLayout> </GridLayout>

To enable filtering, we need to setup a listener on the TextField. To accomplish this, I save the array the user set up (the source property) to another array that we do not filter, so we can safely set the source to a filtered array. We can take care of this in the constructor of our class. At the time the constructor is called though, the source property is set to the default property we set in the Property declaration.

This is hacky, and somebody please tell me how to do this correctly, but I grab the source in a setTimeout, which allows the property to be initialized with the array from the app:

constructor() { super(); let innerComponent = builder.load(__dirname + '/filterable-listpicker.xml') as View; innerComponent.bindingContext = this; this.addChild(innerComponent); setTimeout(() => { this.source.forEach(element => { this.unfilteredSource.push(element); }); if (isIOS) { let parent: any = frame.topmost().getViewById('dc_flp_container').parent; parent.visibility = "collapse"; } }, 10) let textfield = innerComponent.getViewById('filterTextField') textfield.on('textChange', (data: any) => { this.source = this.unfilteredSource.filter(item => { return item.toLowerCase().indexOf(data.value.toLowerCase()) !== -1; }) }) }

I figured out how to avoid the setTimeout. Properties come with a defaultValue, as well as an onValueChanged callback. So instead of using the timeout, now I simply store my unfilteredSource array outside of my class so its accessible everywhere, and set the source when the value changes from undefined to something:

export const sourceProperty = new Property<FilterableListpicker, ObservableArray<string>>({ name: "source", defaultValue: undefined, affectsLayout: true, valueChanged: (target, oldValue, newValue) => { if (!oldValue) { let parent: any = frame.topmost().getViewById('dc_flp_container').parent; parent.visibility = "collapse"; newValue.forEach(element => { unfilteredSource.push(element) }) } } });

It's worth pointing out why I need to set the visibility to collapsed on the parent if its iOS. This is because apparently iOS uses a ContentView to store the elements, and that view restricts the user from interacting with the content under it. Even though our GridLayout's visibility is set to hidden, on iOS we also need to set its parent view to hidden as well. This is an unforunate quirk.

Then you'll notice I grab the textfield and watch the textChange event, and set the source to the filtered array.

This works awesome. Since source is an ObservableArray, the array filters in front of our eyes.

But obviously, we need to handle some events, like the user tapped something in the ListView, and the user tapped the cancel button. This was the most confusing part about this, and the reason I decided to write this blog post, there are no resources that I could find out there describing how this is done. You have to parse through open source plugin code that does this to figure it out. I used Brad Martin's nativescript-videoplayer plugin to see how he did it. And I also bugged him on Slack in the NativeScript community and he helped me out. He's the man.

Here's how its done. You need to declare the property as a public static in your class. The name of the property in your class needs to be yourpropertyEvent. So your property name + 'Event'. Weird, I know. So if I wanted a canceled property in my UI component that will call a function if the modal is canceled, I need to declare it like this: public static canceledEvent = "canceled";.

I need an event for canceled and itemTapped:

public static canceledEvent = "canceled"; public static itemTappedEvent = "itemTapped";

And then I set the properties on my UI Component:

<ui:FilterableListpicker id="myfilter" source="{{listitems}}" canceled="{{cancelFilterableList}}" itemTapped="{{itemTapped}}" />

Then, when the event occurs at which you'd like to call the function defined in the UI component, you need to notify the component of that event. Notice that in my plugin xml, I have the Cancel button call a cancel() function. When the user taps cancel, I want to hide the modal, but also call the function the user defined.

public cancel() { this.notify({ eventName: 'canceled', object: this }); this.hide(); }

notify does that for us. The UI Component is notified, and the function is called. Amazing. We need to do the same for itemTapped.

Notice the itemTap function in my ListView in the plugin XML (the standard event in NativeScript when a ListView item is tapped), I am calling a function in my class called choose().

public choose(args) { let item = this.source[args.index]; this.hide(); this.notify({ eventName: 'itemTapped', object: this, selectedItem: item }); }

So, I get the item selected in the source, hide the modal, and notify the component which calls itemTapped, defined in the app. The event arguments are the object included in notify, so I've created the itemSelected key to include the string the user tapped.

Everything works!

I felt compelled to add some other cool things like the option to customize the dimmer color, blur the background on iOS, customize the placeholder text in the textfield, and let the user control the width and height of the modal.

You can check out all my plugin code here to get a better sense, feel free to fork it and mess around. And see the documentation on exactly how to use it here.

Reach out with any questions, I'm happy to help anyone and everyone. And follow me on twitter!