0.2.0 • Published 2 years ago

@heartlandone/vega-react-native v0.2.0

Weekly downloads
-
License
-
Repository
-
Last release
2 years ago

Vega React Native

A React Native Library for Vega Design System. Different with other wrappers for Vega (e.g. @heartlandone/vega-angular, @heartlandone/vega-react, @heartlandone/vega-vue), this package is not a simple wrapper of @heartlandone/vega (built on top of vega-stencil), but a complete standalone library to provide vega components that can be used in React Native apps.

Table of Contents

About React Native

React Native brings React's declarative UI framework to iOS and Android. With React Native, you use native UI controls and have full access to the native platform with the following benefits:

  • Declarative. React makes it painless to create interactive UIs. Declarative views make your code more predictable and easier to debug.
  • Component-Based. Build encapsulated components that manage their state, then compose them to make complex UIs.
  • Developer Velocity. See local changes in seconds. Changes to JavaScript code can be live reloaded without rebuilding the native app.
  • Portability. Reuse code across iOS, Android, and other platforms.

Why not re-use @heartlandone/vega within a React Native wrapper library?

React Native renders to general platform views (host views) instead of DOM nodes (which can be considered web’s host views). Rendering to host views is made possible by the Fabric Renderer. Fabric lets React talk to each platform and manage its host view instances. The Fabric Renderer exists in JavaScript and targets interfaces made available by C++ code,

Which is making the RN component is coming with the following restrictions compared with DOM nodes (We only list major ones): 1. DOM structure is gone: 1. No querySelector() supported 2. No appendChild() and parent supported 3. No interface of component properties directly exposed 2. CSS is replaced by StyleSheet: 1. No complex CSS selectors (class, element tag, etc.). There is only support for simple CSS class selector that maps 1-to-1 with an element. 2. There is no cascade, CSS properties are not inherited from parent elements 3. Many CSS properties are element specific, e.g. you can not give Text properties (font-family, etc.) to a View and vice versa. 4. React Native only implements a subset of CSS. The more complex CSS features are left out, and what you get is a set of CSS features that work well to do styling in both browsers and native apps. 5. There are some new styling properties in React Native that do not exist in regular CSS.

Hence, if we simply create a wrapper library from heartlandone/vega most of the UI and functionality won't be working anymore in RN apps.

High level picture about how to tackle this situation

To address this gap between native platform and web browser, we came up with the following architecture to build the vega-react-native

vega-react-native architecture

The main idea behind this graph is:

1. Use vega-stencil generated component interface as specifications of vega-react-native

Instead of directly importing the component implementation from vega-stencil, we are using the built interface declaration as the backbone in vega-react-native to help us determine what UI behavior we should support for the specific component, which also saves us from duplicating the work to declare component props in vega-react-native.

2. Create adapters/polyfill in order to consume the code from vega-stencil

To re-use some helper/utility methods in vega-stencil, we need to create polyfill/adapters to make sure the method logic is applied to the native platform as well. For example in vega-stencil/src/utils/breakpoint.ts we have:

export const getCurrentBreakpoint = (): BreakpointsTokenType => {
   //...
   const windowWidth: number = window.innerWidth;
   //...
};

In the native platform, there is no global variable window with innerWidth supported, hence we need to provide a polyfill as below:

// in vega-react-native/src/adapters/global.ts

import {Dimensions} from 'react-native';

 /**
 * A polyfill for {@link window.innerWidth} by using {@link Dimensions.get('window').width}
 */
 function defineWindowWidth() {
    Object.defineProperty((global as any).window, 'innerWidth', {
       value: Dimensions.get('window').width,
    });
 }

 defineWindowWidth();

3. Create VegaNodeContainer to support the DOM API for RN components

VegaNodeContainer is a HOC component which is a DOM API polyfill in RN env.

3.1 Glossary

  • HostElement or RN Component: The original component created on top of RN which including all the essential properties for the vega components, which will be wrapped by VegaNodeContainer
  • VegaNode: the instance created from RN component constructor wrapped by VegaNodeContainer.
  • VegaNodeContainer: the HOC component used for wrapping the Host Element and return a VegaNodeContained as the creator of the VegaNode
  • VegaNodeContained: Creator (constructor) created by VegaNodeContainer
  • VegaNodeInterface: The interface used to describe the shape of VegaNode in terms of the public API exposed.
  • VegaNodeRefObject or VegaNodeRef: The reference of the VegaNode which is used to get the current linked VegaNode reference by VegaNodeRef#current.

The functionality supported by VegaNode can be found in VegaNodeInterface:

export interface VegaNodeInterface {
  getInternalType(): string;
  getClassList(): string[];
  addClass(className: string): Promise<void>;
  removeClass(className: string): Promise<void>;
  setProperty(key: string, value: unknown): Promise<void>;
  getProperty<T>(key: string): T;
  setParent(parent: VegaNodeInterface): void;
  getParent(): VegaNodeInterface | null;
  appendChild(child: VegaNodeInterface | ReactNode): Promise<void>;
  removeChild(child: VegaNodeInterface): Promise<void>;
  getChildren(): VegaNodeInterface[];
  hasChild(child: VegaNodeInterface): boolean;
  getRendered(): VegaNodeRendered | null;
  setRendered(rendered: VegaNodeRendered): void;
  querySelector(selector: string): VegaNodeInterface | null;
  querySelectorAll(selector: string): VegaNodeInterface[];
  render(): VegaNodeRendered;
}

Most of the api methods should be straightforward as they are used to resolve the gaps we mentioned previously for DOM API in RN env.

And here is a graph explaining how the VegaNode is internally working to consume the external pass-in attributes into the host elements and connect the children of the host elements to build the virtual DOM tree:

vega-node component graph

Within the above diagram: 1. We have a state constructed during constructor to include:

  • newProps: to override the original Props passed to the host elements.
  • addedClassNameSet/removeClassNameSet: to override the original classList passed to the host elements which will be transferred to Stylesheet by VegaClassRenderer
  • children: to maintain the references of VegaNode rendered output and passed to the host element as the actual children.
  1. The state will be the bridge between component consumer and the host element that is consumed, to make sure all the UI manipulation (e.g. overriding props, adding class, appending child) on the vega component(VegaNodeContained) is state-controlled and can be reflected on the host elements.
  2. Out of the state, we will have the private property children within the VegaNode to contain the reference of VegaNodeContained for all the children of the given host elements, this part is important as it is building up the tree structured hierarchy between parent and child vega nodes (by invoking linkChildNodeRef internally), which will be taken as the virtual DOM tree.

❗Note before using DOM manipulation API to control children rendering❗

you have to explicitly set the property stateControlChild={true} before using DOM manipulation API appendChild() or removeChild() to control children rendering.

This is mainly for avoiding the conflicts between props#children control flow and state#children control flow, to be more specific, if you have a component that is using this.props to control the children UI/UX like below:

render() {
   return (
        <VegaTouchableOpacity>
           <VegaText classList={['v-font-btn-label']}>{props.label}</VegaText>
        </VegaTouchableOpacity>
   )
}

You cannot use DOM manipulation API (by stateControlChild={true}) anymore on both VegaText and VegaTouchableOpacity, since the embed children for both of them is related with the propagated property props.

After getting an overview about how the VegaNode is working, we will move forward about how to integrate VegaNode with the vega component development in React Native.

3.2 Constructing VegaNode connected to RN components

In order to make your created RN component integrated with VegaNode along with all the API listed in the VegaNodeInterface you need: 1. Call the VegaNode builder method VegaNodeContainer and pass in your RN component to get a VegaNodeContained component for your RN component, here we take View as an example:

import {View, ViewProps} from 'react-native';
import {VegaNodeContainer, VegaNodeContained} from '../core/vega-node';

export const VegaView: VegaNodeContained<ViewProps> =
VegaNodeContainer<ViewProps>(View);
  1. After you wrapped the component creator with VegaNodeContainer, all the component instance created by VegaView will support all the methods in VegaNodeInterface (Meanwhile it will behave exactly same as RN native View), so you can do something like

      <VegaView
           classList={['v-border-secondary-btn', 'v-bg-action-focus']} // ==> the class will be transferred to RN Stylesheet which we will cover shortly
           ref={viewRef}  // ==> get the reference of the RN node
           style={styles.viewStyleA}
      />
      <VegaView
           classList={['v-border-secondary-btn']}
           ref={viewRefB}
           style={styles.viewStyleB}
      />
      <VegaButton
           block={true}
           label="Switch"
           ref={buttonRef}
           variant="primary"
           icon={'arrow-expand'}
           danger={true}
           onVegaClick={buttonClickHandler}
      />
     // append a RN component directly which will render it before appending
     viewRefA.current?.appendChild(
       <VegaText ref={textRef} classList={['v-font-btn-label']}>
         Hello Vega!
       </VegaText>,
     );
    
     // append a child by using the reference
     await viewRefB.current?.appendChild(textRef.current!);
    
     // set/get the property from a vega node contained RN component
     await buttonRef.current.setProperty(
         'danger',
         !buttonRef.current.getProperty('danger'),
     );

3.3 Vega Node Root

In some use cases we are not able to get the reference we would like to operate on before hands, and in web env, we can simply use the root element document to query the targeting element from top to bottom.

In RN, there is no such way to achieve it by default. Hence, we came up a dummy root node as the default parent for all the VegaNode, which means as long as the VegaNode is not a child of other VegaNode, its parent will be VegaNodeRoot.

After we established this connection, we can make use of VegaNodeRoot to do the query from top to bottom:

const nestedVegaViewA: VegaNodeInterface = VegaNodeRoot.querySelector(
   '.nested-view VegaView.v-bg-accent1-primary.v-border-secondary-btn',
 )!;
 const nestedVegaViewB: VegaNodeInterface = VegaNodeRoot.querySelector(
   '.nested-view VegaView.v-bg-accent2-primary',
 )!;
 const nestedVegaText: VegaNodeInterface = VegaNodeRoot.querySelector(
   '.nested-view VegaView.v-border-secondary-btn VegaText',
 )!;

3.4 API usages:

  1. getInternalType() This API is for getting the concrete component name wrapped with VegaNodeContainer which can be used for example in querySelector(). You can take this API as the same one with Element.tagName
    vegaViewRef.getInternalType() // 'VegaView'
    vegaButtonRef.getInternalType() // 'VegaButton'
  2. getClassList(): string[]; addClass(className: string): Promise<void>; removeClass(className: string): Promise<void>; Those APIs are the CRUD operation for the classList stored in VegaNode, which will not only re-rendering the style for the VegaNode if there is a class registered but can also be reflected in the query behavior on the VegaNode.

    <VegaView ref={viewRef} classList={['classA']}></VegaView>
    
    viewRef.current.getClassList() // classList: ['classA']
    viewRef.current.addClass('classB') // classList: ['classA', 'classB']
    viewRef.current.removeClass('classA') // classList: ['classB']
  3. setProperty(key: string, value: unknown): Promise<void>; getProperty<T>(key: string): T; Update/retrieve the properties of the component wrapped in VegaNodeContainer

    <VegaButton
           ref={buttonRef}
           danger={true}
           //...
    />
    
    // flip the VegaButton#danger property
    await buttonRef.current.setProperty(
         'danger',
         !buttonRef.current.getProperty('danger'),
    );
  4. VegaNode#parent and VegaNode#children VegaNode#parent and VegaNode#children is used to build the hierarchy structure of the vega node graph. for example, we have

    <VegaView>
         <VegaButton/>
         <VegaText/>
    </VegaView>

    which comes to the vega node graph like

    VegaNode<VegaView>
       |___ VegaNode<VegaButton>
       |___ VegaNode<VegaText>

    and here are the APIs related to this structure:

    • setParent(parent: VegaNodeInterface): void;: set the parent of the current VegaNode, this methods doesn't need to be called explicitly since VegaNode#appendChild/removeChild already helps you to update the parent of the child.
    • getParent(): VegaNodeInterface | null;: get the parent of the current VegaNode
    • appendChild(child: VegaNodeInterface | ReactNode): Promise<void>; append the child node to the current VegaNode
      • Note: this method can either be passed in with a node reference or a JSX syntax component which will be rendered first and then append to the VegaNode:
        viewRefA.current?.appendChild(
           <VegaText ref={textRef} classList={['v-font-btn-label']}>
              Hello Vega!
           </VegaText>,
        ); 
        // textRef.current will be the last child of viewRefA.current
    • removeChild(child: VegaNodeInterface): Promise<void>;: remove child of from the current VegaNode
      • Note: this method will be automatically called when appendChild() is called with an existing VegaNode:

        viewRefA.current?.appendChild(
           <VegaText ref={textRef} classList={['v-font-btn-label']}>
              Hello Vega!
           </VegaText>,
        );
        
        await viewRefB.current?.appendChild(textRef.current!);
        // which will internally call:
        //   viewRefA.current.removeChild(textRef.current!), 
        // then:
        //   viewRefB.current?.appendChild(textRef.current)
    • getChildren(): VegaNodeInterface[];: get all the children of the current VegaNode
    • hasChild(child: VegaNodeInterface): boolean;: check if the VegaNode is one of the children of the current VegaNode

4. Create VegaClassRenderer to support native CSS styling

VegaClassRenderer is used for fill the gap that RN is not supporting rendering the style to the component based on the class name.

Internally, it will use a class name mapping to register the class and styleSheet pair. And within VegaNode#render method, it will retrieve the registered style per class and set into HostElement#style.

Just like what we did in vega-stencil, we pre-defined lots of classes based on the design token and registered them within the VegaClassRenderer before any VegaNode created during runtime (code link)

4.1. How to

If you want to register new classes, you can try:

VegaClassRenderer.registerClasses([
  {
    classSelector: 'vega-button',
    style: VegaClassRenderer.render(['v-self-start', 'v-rounded-full']),
  },
  {
    classSelector: 'vega-button-size-small',
    style: VegaClassRenderer.render(['v-px-size-16', 'v-py-size-4']),
  },
  {
    classSelector: 'vega-button-size-default',
    style: VegaClassRenderer.render(['v-px-size-24', 'v-py-size-8']),
  },
 { 
     // we also support compound class group (non-nested) registration to make the stylesheet rendered differently for different class combinations 
    classSelector: ['vega-button-variant-secondary', 'vega-button-size-small'],
    style: VegaClassRenderer.render(['v-pt-size-2', 'v-pb-size-2']),
 },
]);

After class registration, we can simply use it by classList={['your defined class']}. For example:

<VegaTouchableOpacity
      classList={[
        'vega-button',
        `vega-button-size-${props.size}`,
        `vega-button-variant-${props.variant}`,
        props.disabled ? 'vega-button-disabled' : '',
        props.danger ? 'vega-button-danger' : '',
        props.block ? 'v-w-full' : '',
      ].filter(Boolean)}
>
   {/** children... */} 
</VegaTouchableOpacity>

4.2 Style inheritance

In React Native, style inheritance is not supported, which means you shouldn't expect the child component using the same fontColor if you are setting it on its parent.

This gap is fixed by VegaNode by creating an internal used property inheritedClasses. And for a complete list of style property that can be inherited, please refer to this link.

Development Guide

1. Setting up the development environment

Please follow the setup guidance under section Installing dependencies in React Native official document.

Note: 1. Make sure you are under the tab React Native CLI Quickstart as vega-react-native is created by RN CLI instead of Expo CLI. 2. Please follow both target OS Android and IOS since we need to build the components onto both platforms.

2. Running your React Native application

First, you will need to start Metro, the JavaScript bundler that ships with React Native. Metro "takes in an entry file and various options, and returns a single JavaScript file that includes all your code and its dependencies."—Metro Docs

yarn run start

Once Metro is running, you can leave it open to re-build the application when code is changed.

3. Start the application on the virtual device

  • To run the app on Android device, please run
    yarn run android
  • To run the app on IOS device, please run
    yarn run ios

4. During debugging

  • Press R to reload your app manually
  • Press D to open the RN developer menu
  • If you want to use the chrome like inspector for inspecting your component node, please use react-devtools

For other information, please refer to this link

5. Testing

Currently, we have set up 2 levels testing specifications:

5.1 Component unit test

Component unit test is built on top of @testing-library/react-native. In order to run the component level testing, you can run:

yarn run test:unit

To rectify the snapshot testing, please run:

yarn run test:unit -- --updateSnapshot

Note: test coverage is collected in component unit test by jest

5.2 E2E Test

We are using detox to run the e2e tests on simulator device. In order to run the e2e test on your local workspace, please follow the Environment setup guidance first to make sure detox is ready to run locally, then run the following commands: 1. Run E2E test on IOS simulator (iPhone 11):

yarn run test:e2e:ios
  1. Run E2E test on Android emulator (Pixel_3a_API_32_arm64-v8a):
    yarn run test:e2e:android
    Note:
    1. Currently, we are not able to include the E2E tests into our pipeline due to the following reasons:

      • For IOS: xcodebuild is required in order to install the testing app onto the IOS simulator, however the current GitLab Runner we have is on UNIX system which doesn't support to install xcodebuild. (We need to create a separate runner in an AWS EC2 Mac Instance in future)
      • For Android: The Android Emulators require the KVM support which is also not available for our current GitLab runner env.

      Hence, we need to have QAs to conduct those E2E tests manually before we release the packages to our consumers.

    2. Sometimes, the test can get stuck and not proceeding in android device, in that case, you can simply terminate the current testing process and restart to resolve it.

6. Running Storybook

Our stories for components/screens are stored in ./stories

  1. To start the metro server that is boot up the RN storybook application, you can run
    yarn run start-storybook
    Note: make sure there is no other metro servers ongoing which is occupying the same ip address and port.
  2. Then you can run the app on the virtual device as you did previously like:
    yarn run android
    # or
    yarn run ios 

7. Raising the MR

Make sure you have run yarn run pre-release and yarn run release before raising the MR.

Best practices

1. Creating Vega Component

  • In general, all component classes (including the native one like View, Text) should be wrapped with VegaContainer, and rename the wrapped one with name VegaXXX (take VegaText for example). For vega components, you need to create an internal component class/function first, then wrapped it with VegaNodeContainer (take vega-icon for example)
  • While wrapping vega components, you should make sure the class properties is penetrated into the render() method to make sure the wrapped component is consuming the class controlled style from VegaNode properly (example regarding how to do that).

2. Class v.s. StyleSheet

  • StyleSheet should be avoided if it can be converted to a class registered within VegaClassRenderer.
  • For general class that can be used by multiple component, you should define them into src/stylesheet/general.ts

Troubleshooting

  1. Unable to resolve module @heartlandone/vega after opening the demo app. This is sometimes due to the symlink is not properly picked up by metro and what you can do is starting the metro with reset-cache flag enabled by:

    react-native start --reset-cache

    or you encounter this error during the metro release build for e2e tests, you can clean up the cache by running

    watchman watch-del '/Users/<username>/workspace/tiger' ; watchman watch-project '/Users/<username>/workspace/tiger'
  2. Encounter errors that can only be reproduced in release build bundle. Sometimes, the error is not able to reproduce in the debug (development) build, hence we need to manually create a release build to reproduce the issue.

    • Here We would like to suggest to use xcode (IOS platform) as it can easily print out the JS console output into xcode console as the debug tools is not available in release build.
    • In order to make the release build by xcode, you need to
      1. open the xcworkspace of VegaReactNative by
        open ios/VegaReactNative.xcworkspace/
      2. Open xcode tab and select Product > Scheme > Edit Scheme
      3. Change the Build Configuration from Debug to Release in Run section
      4. Re-run the workspace and now the app should be built with release configuration