Lessons Learned from my first NativeScript Plugin
About a couple months back, I started taking a look at NativeScript as a potential framework for my next mobile app. I'm the type of person that tends to get excited and just dive in and starts building what I want to be building vs building the hello world starter apps, so I started working on an input form that this app will need. Right away I realized I would want to use a native Android TextInputLayout widget - essentially a text field with a placeholder that "floats" up to become the label, and supports an error message. This isn't part of the core Android runtime (it's part of the design support library for material design) and isn't directly exposed via NativeScript. However, NativeScript's documentation boasts of the ease of accessing native runtimes, and rightfully so in my opinion, so I thought I'd put that to the test and make a plugin. Let's take a look at some of the hurdles I ended up facing and some of the resources that helped me build my first NativeScript plugin (actually, first NativeScript project altogether) - nativescript-textinputlayout.
Resources
I'll start with the resources that helped me through this as it's where I started when building the plugin. As with doing anything one has never done before, there is one tried-and-true method of pushing forward - finding where someone else did it (or similar) and building on top of that! These are the giant resources whose shoulders I built upon:
- NativeScript Plugin Tutorial and NativeScript Plugin Seed Project by Nathan Walker
- Source code of other NativeScript plugins. There's a nice listing of them here.
- NativeScript on Slack
- Specifically Nathanael Anderson - this guy is super knowledgeable and really helped me with differences between some of the core NativeScript Layout/View components.
- NativeScript source - although, the source is large and considering I was building the plugin with next to no NativeScript experience it was a bit overwhelming at times :)
Hurdles
Picking a base class
If your plugin has any UI component at all, which mine did, the first thing you'll likely be doing is sub-classing one of the NativeScript components so that your component integrates with the runtime as automatically as possible. I looked at a few different plugins as well as the NativeScript source, and realized that there are actually a bunch of different components to pick from. Here's a list of the different components I tried with before finally getting it right, as well as some reasons they don't work:
- EditableTextBase: The TextInputLayout is essentially a text field, right? So why not inherit from this and just have all the text input functionality already right there?
- After further review into the android TextInputLayout spec, I realized it actually is just a wrapper for an android EditText component. In order to have continued on with this approach I would have had to create a NativeScript TextField under the hood anyways, and then I wasn't sure how I'd keep bindings in sync, etc... so this approach seemed out...
- LayoutBase: Well, the Android TextInputLayout inherits from android.widget.LinearLayout, so I should probably use a Layout-ish component as my base class, right?
- This approach actually worked - my component rendered just fine and my bindings worked. However, if you went away from the view to another, and then navigated back to the view using the navigation history (back button), then the bindings were broken and I couldn't figure out how to reconnect them. Nooo! After asking some folks on slack, LayoutBase should only be used if I'm actually "laying out", or positioning, child elements and setting their bounds, etc... Admittedly, I should've paid better attention to the source on this, but just didn't catch it until it was explained to me. (Thanks Nathanael!)
- ContentView: My element isn't doing any layout stuff, so maybe it's just a View that gets one child (remember, it's a wrapper for a single text field). ContentView is allowed to have only one child element, so that works - right?
- On the surface, it looks like this would work. However, if you look into the Android TextInputLayout documentation, you'll notice that it adds padding at the top for the label, and potentially adds padding to the bottom for an error and/or a counter, while the text field sits between all that. Examining the ContentView closely, we can see that it only takes the measurements of its one child into account when getting rendered (see the
layoutView
getter function), so this actually won't work either because our TextInputLayout takes up some space, as well as the text field that gets added as its child. - View: This is a base class for all kinds of different things in NativeScript, and appears to be the right choice for our component.
- Now we have a base class that seems to fit our needs. We can add a child element and it can have its own measurements (border, padding, etc...) apart from its child's size. Now, how do we handle rendering it, or children (or in this case one child) getting added to it, lifecycle events, etc?
Rendering your component
So we're going to sub-class off of NativeScript's core View
, but the first thing we need to do is render the component. It doesn't really matter what kind of sweet functionality we have in mind if the component doesn't get rendered to the screen. Fortunately for us, the View
definition shows that there's a _createUI
method. As it turns out, any time NativeScript wants to create the UI of a View
, this is the method it calls. Go figure :p Also, if your component correlates to a native object, this is the time to instantiate it. The View
definition shows a _nativeView
getter, so be sure to implement that also - else your view won't render! Let's sub-class from View
and populate that method:
//textInputLayout.ts
import {View} from "ui/core/view";
class TextInputLayout extends View {
//typically, you'd have a .common.ts for common stuff
//and a .android.ts that extends common for android-specific stuff (same for ios)
//I'm showing in same class for simplicity
//provide getters to native component that this NativeScript component represents
get android() { return this._android; }
get _nativeView() { return this._android; }
//how we create the UI of our View
public _createUI() {
//easily access native runtime - like, for realz easy :)
this._android = new android.support.design.widget.TextInputLayout(this._context);
}
}
Working with children
At this point, we have an implementation of a View and it knows how to create its UI, now we just need to be able to add a child element to it. Fortunately, NativeScript's View component has some pretty nice mechanisms baked in to make this fairly trivial - it just takes some digging in the source to start seeing how it all comes together :).
Firstly, we need to make our View capable of having children. In the source, we can find an interface named AddChildFromBuilder
and it dictates that anything implementing it will need to have a _addChildFromBuilder
method. So, when we are declaring our component, we need to implement that interface (if not using TypeScript, you simply just have to supply the function and don't worry about declaring interface implementations). NativeScript builds up the object tree for the view based on the XML using various builder thingies (yeah, that's the technical term :p), and we want the NativeScript runtime to know that our component can handle child XML elements. Here's how we do this:
import {View} from "ui/core/view";
class TextInputLayout extends View implements View.AddChildFromBuilder {
public _addChildFromBuilder(name: string, value: any): void {
//the value is the object value - i.e. what the builder built based on the XML
}
}
Since the TextInputLayout is only compatible with text fields, one thing we need to do is do some checking on the child. So let's update the _addChildFromBuilder
method to do this, and add some more imports
to get the types of children we want to accept:
//new imports
import {TextView} from 'ui/text-view';
import {TextField} from 'ui/text-field';
//update _addChildFromBuilder to only accept those children
//notice I changed the second param from "any" type to either TextField or TextView
public _addChildFromBuilder(name: string, child: TextField | TextView): void {
if (!(child instanceof TextView || child instanceof TextField)) {
throw new Error('TextInputLayout may only have a <TextView> or <TextField> as a child');
}
//do something with the child here
}
Since the allowed child elements in this case are all sub-classed from View
, we can even listen for certain events, such as 'loaded'
, to fire and then react accordingly:
// in _addChildFromBuilder()
child.on(View.loadedEvent, () => console.log('Child is fully loaded!'));
When an element is loaded, its native counterpart (i.e. child.android
or child.ios
) will be populated also. If you're building a component that allows children to be added, you want to make sure that the child's native counterpart gets added to your component's native counterpart. This may look something like this:
//remember earlier, we set the _android property in the _createUI method
//now, we can use the android getter to get a handle to it and add the
//*native* correlation of a child to our *native* component!
child.on(View.loadedEvent, () => this.android.addView(child.android));
Now the NativeScript runtime knows that we can have child elements in our XML, it can add those child elements using the builder, and our native component can get that child's native correlation added super easily. Here's an example of how you'd use the TextInputLayout plugin (which is available on npm):
<Page
xmlns="http://schemas.nativescript.org/tns.xsd"
<!-- pull in the namespace of the plugin -->
xmlns:TIL="nativescript-textinputlayout">
<StackLayout>
<!--use the TextInputLayout and provide a child text element-->
<!--TextInputLayout attributes omitted for simplicity-->
<TIL:TextInputLayout>
<!--ONE child element can be added, MUST be either TextField or TextView-->
<TextField text="{{ someViewModelText }}" />
</TIL:TextInputLayout>
</StackLayout>
</Page>
Lessons Learned
There are two things I'd probably do differently in hindsight.
- Gain a clear understanding of some of the core NativeScript classes first. While I learned a lot manually trying multiple classes to base-class off of, it would've certainly been easier in building the plugin had I started there :p
- Probably would've went ahead and built one or two of the small tutorial apps. As excited as I was to dive right in, there's a lot to NativeScript and learning a bit more up front about view lifecycles, etc... would've been helpful.
Once I better understood what to sub-class off of and how it worked, getting the plugin wrapped up was really easy. Hopefully this post can help someone else that may be hitting those same hurdles :)
-Bradley