@heartlandone/vega-react-native v0.2.0
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 ofvega-stencil
), but a complete standalone library to provide vega components that can be used in React Native apps.
Table of Contents
- About
React Native
- Why not re-use
@heartlandone/vega
within a React Native wrapper library? - High level picture about how to tackle this situation
- Development Guide
- Best practices
- Troubleshooting
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
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 byVegaNodeContainer
VegaNode
: the instance created from RN component constructor wrapped byVegaNodeContainer
.VegaNodeContainer
: the HOC component used for wrapping the Host Element and return aVegaNodeContained
as the creator of theVegaNode
VegaNodeContained
: Creator (constructor) created byVegaNodeContainer
VegaNodeInterface
: The interface used to describe the shape ofVegaNode
in terms of the public API exposed.VegaNodeRefObject
orVegaNodeRef
: The reference of theVegaNode
which is used to get the current linkedVegaNode
reference byVegaNodeRef#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:
Within the above diagram:
1. We have a state
constructed during constructor to include:
newProps
: to override the originalProps
passed to the host elements.addedClassNameSet
/removeClassNameSet
: to override the originalclassList
passed to the host elements which will be transferred toStylesheet
byVegaClassRenderer
children
: to maintain the references ofVegaNode
rendered output and passed to the host element as the actual children.
- 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. - Out of the
state
, we will have the private propertychildren
within theVegaNode
to contain the reference ofVegaNodeContained
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 invokinglinkChildNodeRef
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);
After you wrapped the component creator with
VegaNodeContainer
, all the component instance created byVegaView
will support all the methods inVegaNodeInterface
(Meanwhile it will behave exactly same as RN nativeView
), 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:
getInternalType()
This API is for getting the concrete component name wrapped withVegaNodeContainer
which can be used for example inquerySelector()
. You can take this API as the same one withElement.tagName
vegaViewRef.getInternalType() // 'VegaView' vegaButtonRef.getInternalType() // 'VegaButton'
getClassList(): string[]; addClass(className: string): Promise<void>; removeClass(className: string): Promise<void>;
Those APIs are the CRUD operation for theclassList
stored inVegaNode
, which will not only re-rendering the style for theVegaNode
if there is a class registered but can also be reflected in the query behavior on theVegaNode
.<VegaView ref={viewRef} classList={['classA']}></VegaView> viewRef.current.getClassList() // classList: ['classA'] viewRef.current.addClass('classB') // classList: ['classA', 'classB'] viewRef.current.removeClass('classA') // classList: ['classB']
setProperty(key: string, value: unknown): Promise<void>; getProperty<T>(key: string): T;
Update/retrieve the properties of the component wrapped inVegaNodeContainer
<VegaButton ref={buttonRef} danger={true} //... /> // flip the VegaButton#danger property await buttonRef.current.setProperty( 'danger', !buttonRef.current.getProperty('danger'), );
VegaNode#parent
andVegaNode#children
VegaNode#parent
andVegaNode#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 currentVegaNode
, this methods doesn't need to be called explicitly sinceVegaNode#appendChild/removeChild
already helps you to update the parent of the child.getParent(): VegaNodeInterface | null;
: get the parent of the currentVegaNode
appendChild(child: VegaNodeInterface | ReactNode): Promise<void>;
append the child node to the currentVegaNode
- 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
- 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
removeChild(child: VegaNodeInterface): Promise<void>;
: remove child of from the currentVegaNode
Note: this method will be automatically called when
appendChild()
is called with an existingVegaNode
: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 currentVegaNode
hasChild(child: VegaNodeInterface): boolean;
: check if theVegaNode
is one of the children of the currentVegaNode
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
- Run E2E test on Android emulator (Pixel_3a_API_32_arm64-v8a):
Note:yarn run test:e2e:android
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 installxcodebuild
. (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.
- For IOS:
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
- To start the metro server that is boot up the RN storybook application, you can run
Note: make sure there is no other metro servers ongoing which is occupying the same ip address and port.yarn run start-storybook
- 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 withVegaContainer
, and rename the wrapped one with nameVegaXXX
(takeVegaText
for example). For vega components, you need to create an internal component class/function first, then wrapped it withVegaNodeContainer
(takevega-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 fromVegaNode
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 withinVegaClassRenderer
.- For general class that can be used by multiple component, you should define them into
src/stylesheet/general.ts
Troubleshooting
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 withreset-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'
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- open the xcworkspace of
VegaReactNative
byopen ios/VegaReactNative.xcworkspace/
- Open
xcode
tab and selectProduct > Scheme > Edit Scheme
- Change the
Build Configuration
fromDebug
toRelease
in Run section - Re-run the workspace and now the app should be built with release configuration
- open the xcworkspace of
- Here We would like to suggest to use
2 years ago