Nativescript & Formily: A match made in heaven.
Michael De Giovanni
Posted on October 21, 2022
For any one who want to try the demo out immediately before reading the rest of the post.
- Open Stackblitz Nativescript preview
- Download the Nativescript preview app: android ios
- Login, scan the code and enjoy
Context
Forms can always be a pain, especially if they large in size. Think of a KYC process in a highly regulated industry where there are plenty of steps and several complexities. As developers we code over and over the different elements and take care of all the dependencies. Once the development is done, two months pass, the requirements change, and the poor developers needs to visit back the code base and (hopefully) refactor to adapt to the changes. The bigger the form , the more complex the dependencies become. The more complex the dependencies become, the more refactoring involved.
Further to that the mobile team needs to keep in sync with the changes as well and this may result as well in further divergences.
Let's not forget also, the team developing the API layer. They also need to be able to maintain the fields with validations, etc.
What if there was a way in which a schema
can be shared across all teams? π€ A schema which reflects the data, includes validations, can take care of dependencies, can go cross platform, can be shared with the backend devs.
Enter Formily, by Alibaba which does exactly the above. With a JSON schema, forms can be generated whilst keeping control of the data model.
Formily: a very brief description
I came across formily a couple of months ago. It takes care of the form state management.
It does not provide its own library of components but instead it provides bridges, with which you can connect either a component library or your own set of components.
As extra cherries on the cake formily also provides:
- a drag and drop designer from where a schema can be copied
- Web devtools extension to be able to debug.
- Wrappers for several component libraries (elements UI, ant design, Vant, semi etc)
Since it is component agnostic, this makes a perfect candidate for using it with Nativescript.
Getting Started
Using the amazing Preview environment that the Nativescript team together with Stackblitz have done, it was time to start hacking at it. (More information can be found here at https://preview.nativescript.org/)
For those coming from web development, Nativescript utilises the V8 engine to interact directly with the Native platforms. This means that based on your mobile development expertise you can:
- Write Javascript as if you don't know the underlying platform. Here you can choose your flavour be it React, Vue Svelte, Vanilla . My choice: definitely Vue
- Write Javascript using the Nativescript SDK to access native elements
- Write Javascript which reflect the Platform SDK and is marshalled to the native language (Java/Kotlin/ObjC/Swift) For a better explainer to this, have a look at this Youtube video by Nathan Walker
Setting up
The first hiccup I came across was that Nativescript officially supports Vue2 (there is a way to run Vue3 with {N} using DOMiNATIVE, but thats a different topic)
Formily, already caters for this, however it utilises a package called vue-demi
.
This took a while to sort out and for Stackblitz to work I had to use npm vue-demi-switch 2 nativescript-vue
which according to the documentation is used to:
a) specify the version
b) specify the alias for Vue.
The composition API was also installed using
import Vue from 'nativescript-vue';
import VueCompositionAPI from '@vue/composition-api';
Vue.use(VueCompositionAPI);
Next in the page I followed the setup for formily
import { createForm } from "@formily/core";
import { createSchemaField } from "@formily/vue";
const { SchemaField } = createSchemaField({
components: {}
})
From my understanding, createForm
takes care of the data aspect and the reactivitiy, whilst createSchemaField
creates a Vue component which supports a set of bridged components. It takes a JSON schema from which the child components would be generated.
A basic JSON schema was copied , the template was given
<Form :form="form">
<SchemaField :schema="schema" />
</Form>
....And the app crashed.
Since Formily is a framework still oriented mainly towards the web, it was obvious that at some point there was going to be either a <div/>
or a <form/>
. For now this was solved using two polyfills
Vue.registerElement('div', () => StackLayout);
Vue.registerElement('form', () => StackLayout);
Once that was done, the crashes stopped π.
However, so far there are no visible components.
Creating Bridges.
So far SchemaField
has no registered components. So now it is time to build some bridges.
Formily provides a Vue library exactly for this.
These bridges consist of 3 parts.
- Your component. In my case Nativescript Vue components.
- Using the
connect
function from@formily/vue
to be able to bridge between the format Formily uses and the properties, attributes, events and children your component has. Usually you would also use themapProps
function to be able to map between the two sides. One example would be Formily usesvalue
but the Nativescript TextField component takes in a prop calledtext
, hence we map the props - Did I say 3?
Here is a simple example of this:
import { connect, mapProps, h } from '@formily/vue';
import { defineComponent } from 'vue-demi';
let input = defineComponent({
name: 'FormilyTextField',
props: {},
setup(customProps: any, { attrs, slots, listeners }) {
return () => {
return h(
'TextField',
{
attrs,
on: listeners,
},
slots
);
};
},
});
const Input = connect(input);
As can be seen in the above example vue-demi is used to define the component, and the Nativescript TextField
component is being bridged for Formily.
Currently I have built the below list of components. (No where near as exhaustive as Formily's wrappers).
Using the Nativescript native components
- TextField - which can be mapped to
Input
Textarea
Password
Number
just by adjusting some props - Switch
- DatePicker
- TimePicker
- ListPicker - which can be mapped to a
Select
To test out JSON schema is created, the components are registered with SchemaField and lo and behold with magic we have a JSON schema form generated! π
Creating the decorator π
In Formily there is a clear split between what is a component for input and what is decoration. The base component that Formily indicates is the FormItem
which takes care of:
- The label
- Any descriptions
- Any feedbacks (error messages etc)
- Any tooltips
Since this does not exist natively in Nativescript an initial one is created once again.
This time round, the component to be bridged needs to be created.
<template>
<StackLayout
:style="wrapperStyle"
:class="wrapperClass"
:orientation="layout"
verticalAlignment="top"
>
<GridLayout columns="*,40" rows="auto" class="w-full items-center">
<Label
:text="`${label}${asterisk && required ? '*' : ''}`"
:style="labelStyle"
verticalAlignment="center"
class="text-lg font-semibold"
:class="labelClass"
:textWrap="labelWrap"
v-if="label"
/>
<Label
v-if="tooltip"
@tap="showTooltip"
col="2"
class="bg-gray-100 rounded-full w-7 h-7 text-center text-xl"
text="βΉ"
horizontalAlignment="right"
/>
</GridLayout>
<GridLayout rows="auto">
<slot></slot>
<!-- slot outlet -->
</GridLayout>
<Label v-if="feedbackText" :text="feedbackText" :class="feedbackClass" />
</StackLayout>
</template>
<script lang="ts">
import { defineComponent } from "vue-demi";
import Vue from "nativescript-vue";
import BottomSheetView from "~/component/BottomSheet/BottomSheetView.vue";
import { OpenRootLayout } from "~/component/OpenRootLayout";
export default defineComponent({
name: "FormItem",
props: {
required: {
type: Boolean,
},
label: {
type: String,
},
labelStyle: {},
labelClass: {},
labelWrap: {
type: Boolean,
default: false,
},
layout: {
type: String,
default: "vertical",
},
tooltip: {},
wrapperStyle: {},
wrapperClass: {},
feedbackText: {},
feedbackStatus: {
type: String,
enum: ["error", "success", "warning"],
}, // error/success/warning
asterisk: {
type: Boolean,
},
gridSpan: {},
},
computed: {
feedbackClass(): string {
switch (this.feedbackStatus) {
case "error":
return "text-red-400";
case "success":
return "text-green-400";
case "warning":
return "text-yellow-400";
default:
return "text-gray-100";
}
},
},
methods: {
showTooltip() {
let tooltipText = this.tooltip;
const view = new Vue({
render: (h) =>
h(BottomSheetView, { props: { label: "Information" } }, [
h("Label", {
attrs: { text: tooltipText, textWrap: true, row: 2 },
class: "w-full text-lg mb-8 leading-tight",
}),
]),
}).$mount().nativeView;
OpenRootLayout(view);
},
},
});
</script>
Nativescript here provides all the normal Vue functionality that a web developer is used to. With one subtle difference: there are no HTML attributes. However one can easily transfer the knowledge from HTML to Nativescript in this aspect.
-
StackLayout
- Lets you stack children vertically or horizontally -
GridLayout
- is a layout container that lets you arrange its child elements in a table-like manner. The grid consists of rows, columns, and cells. A cell can span one or more rows and one or more columns. It can contain multiple child elements which can span over multiple rows and columns, and even overlap each other. By default, has one column and one row. -
Label
- holds some text
Style wise as one can easily see Tailwind utility classes are being used.
The props so far expose the base necessary functionality.
The component for now has one single method and that is to generate a tooltip. And on this aspect, we can leverage what another layout container that Nativescript supplies: the Root Layout
The RootLayout
<RootLayout> is a layout container designed to be used as the primary root layout container for your app with a built in api to easily control dynamic view layers. It extends a GridLayout so has all the features of a grid but enhanced with additional apis.
Is the definition in that the documentation gives.
In more humble terms, think of this layout in which it can open any component over everything else. This is exceptionally great for Bottom sheets, Modal like components, Side Menu's . Let your imagination go loose here.
To get it working with Nativescript Vue.
- I created a Vue component
- I mounted the view component
- I called a helper function which summons π§π½ββοΈ the rootLayout and tells it to open this component. This helper function does nothing more than
getRootLayout().open(myComponent, ...some overlay settings like color and opacity, ...some animation setting)
Long story short: This same component is utilised for the DatePicker
Completing the bridge
At this stage we have a function Form generated by a JSON schema. However the data is not yet reflected back properly.
Why?
The reason is simple, Formily expects to receive that information back from the components over a change
event.
Digging deep into their Elements UI wrapper, they use vue-demi to transform any component such that the web input functions are mapped to this change event.
One problem.
Vue does not support an input
event or a change
event. So, a listener is introduced to the bridges based on the components event (example: textField has textChanged
). This component specific event, in turn, emits a consolidated event input
with the value from the component.
And this immediately gives back full reactivity back to Formily.
Demo time
Before proceeding to a quick animated gif demo. Here is the demo JSON definition used:
{
type: "object",
properties: {
firstName: {
type: "string",
title: "Test2",
required: true,
"x-component": "Input",
"x-component-props": {
hint: "First Name",
},
},
lastName: {
type: "string",
"x-component-props": {
hint: "Last Name",
},
required: true,
"x-component": "Input",
},
username: {
type: "string",
title: "Username",
required: true,
"x-decorator": "FormItem",
"x-component": "Input",
"x-component-props": {
hint: "@ChuckNorris...",
disabled: true,
},
"x-decorator-props": {
tooltip: "Lorem ipsum test a tooltip sheet with some text here.",
},
"x-reactions": {
dependencies: ["firstName", "lastName"],
fulfill: {
state: {
value:
"{{$deps[0] ? `@${$deps[0]}${($deps[1] ? '.' + $deps[1] : '')}` : undefined}}",
},
},
},
},
password: {
type: "string",
title: "Password",
required: true,
"x-decorator": "FormItem",
"x-component": "Password",
pattern: '/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{6,16}$/'
},
testSwitch: {
type: "string",
title: "Rememeber me?",
required: true,
"x-decorator": "FormItem",
"x-component": "Switch",
},
from: {
type: "string",
title: "Appointment Date",
required: true,
"x-decorator": "FormItem",
"x-component": "DatePicker",
},
time: {
type: "string",
title: "Appointment Time",
required: true,
"x-decorator": "FormItem",
"x-component": "TimePicker",
},
country: {
type: "string",
title: "Country",
required: true,
"x-decorator": "FormItem",
"x-component": "Select",
enum: [{
label: "π¨π¦ Canada",
value: "CA"
},{
label: "π¬π§ United Kingdom",
value: "UK"
},{
label: "πΊπΈ United States",
value: "Us"
}]
},
},
},
}
Here are some pointers on this schema:
- The key for each nested object like
firstName
,lastName
etc is what final data object will have. -
x-component
indicates which component to use -
x-decorator
indicates as described above the decoration around the input component - Some base validations such as
required
,pattern
live as top level. These includeminimum
,maximum
but can also include custom validators including async validations - Any of the keys for a field can be written as JSX with
{{}}
. This gives the possibility to include some logic in the schema. -
x-reactions
found under the username snippet takes care of listening to two dependencies:firstName
andlastName
and fulfils the reaction by adjusting the value based on the dependencies. Formily supports two types of reactions- Active: Meaning the current active component, changes something in another component
- Reactive: A component listens to another components changes.
- Components and decorators can receive additional props using
x-component-props
andx-decorator-props
respectively.
And here is a quick screen grab of the app:
Wrapping it up
The full code can be found at the following repository and can be easily tested out using Stackblitz Preview and the Nativescript Preview application
Posted on October 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.