Nativescript & Formily: A match made in heaven.

m_de_giovanni

Michael De Giovanni

Posted on October 21, 2022

Nativescript & Formily: A match made in heaven.

For any one who want to try the demo out immediately before reading the rest of the post.

  1. Open Stackblitz Nativescript preview
  2. Download the Nativescript preview app: android ios
  3. 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:

  1. a drag and drop designer from where a schema can be copied
  2. Web devtools extension to be able to debug.
  3. 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:

  1. 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
  2. Write Javascript using the Nativescript SDK to access native elements
  3. 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);
Enter fullscreen mode Exit fullscreen mode

Next in the page I followed the setup for formily

import { createForm } from "@formily/core";
import { createSchemaField } from "@formily/vue";
Enter fullscreen mode Exit fullscreen mode
const { SchemaField } = createSchemaField({
  components: {}
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

....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);
Enter fullscreen mode Exit fullscreen mode

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.

  1. Your component. In my case Nativescript Vue components.
  2. 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 the mapProps function to be able to map between the two sides. One example would be Formily uses value but the Nativescript TextField component takes in a prop called text, hence we map the props
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. The label
  2. Any descriptions
  3. Any feedbacks (error messages etc)
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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.

  1. StackLayout - Lets you stack children vertically or horizontally
  2. 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.
  3. 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.

  1. I created a Vue component
  2. I mounted the view component
  3. 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)
Enter fullscreen mode Exit fullscreen mode

Long story short: This same component is utilised for the DatePicker
Tooltip using RootLayout
DatePicker using rootLayout

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"
            }]
          },
        },
      },
    }
Enter fullscreen mode Exit fullscreen mode

Here are some pointers on this schema:

  1. The key for each nested object like firstName, lastName etc is what final data object will have.
  2. x-component indicates which component to use
  3. x-decorator indicates as described above the decoration around the input component
  4. Some base validations such as required, pattern live as top level. These include minimum , maximum but can also include custom validators including async validations
  5. Any of the keys for a field can be written as JSX with {{}}. This gives the possibility to include some logic in the schema.
  6. x-reactions found under the username snippet takes care of listening to two dependencies: firstName and lastName 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.
  7. Components and decorators can receive additional props using x-component-props and x-decorator-props respectively.

And here is a quick screen grab of the app:

Formily with NS

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

https://github.com/azriel46d/nativescript-formily-example

πŸ’– πŸ’ͺ πŸ™… 🚩
m_de_giovanni
Michael De Giovanni

Posted on October 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related