[Huawei Developers] muraliameakula:
They're custom components!,...are you telling me...you've never heared of them..!?

Hi, my name's Younes Megaache, and in this tutorial, I will teach you about custom component on harmonyOS. or at least I wll try LOL!

Am I doing this right? The first sentence is always the hardest!.

We will be creating creating two (2) custom components, a compound component and a custmized one. After reading this article and checking the demo code, you should have learned enough to start creating you're own custome components, or may be not XD!.

You can find links below with more details on this topic.

The code is uploaded to github, the link can be found at the end of the article (bottom).

Table of content

Overview

Create component 1: SButton (compound component)

Define custom component class.

Define custom component layout.

Add component to layout.

Define custom attributes.

Apply custom attributes.

Finishing the components.

Create component 2: ShapeShiftingComponent

Define custom component class.

Add component to layout.

Define custom attributes.

Apply custom attributes.

Drawing a shape

Toggling between shapes

Finishing the component

Tips and tricks

Libraries

Github project (source code)

References

Overview​

HarmonyOS UI elements are all based on Component (single element on screen) and ComponentContainer (collection of elements on screen). There are many "widgets" and "layouts" built-in that can be used to build the UI such as components like Button and Text, and component containers like DirectionalLayout and StackLayout.

In some apps though we need to be able to customize components to suit our own needs. This might mean extending an existing component or creating your own Component subclass.

Customizing your own components involves extending Component or an existing subclass, overriding the component behavior by writing methods such as onDraw or onTouchEvent and then using your new component in an ability.

Creating custom components is centered around 4 primary aspects that we may need to control or modify:

Drawing: Control the rendering of the component on screen visually by adding draw tasks and overriding the onDraw method.

Interaction: Control the ways the user can interact with the component with the onTouchEvent and gestures.

Measurement: Control the content dimensions of the component on screen by overriding the onEstimateSize method.

Attributes: Defining custom XML attributes for your component and using them to control behavior with TypedArray

To take a closer look, we will create two components:

first, we will create a compound component where we will be putting together multiple components as one

second, suppose we want to create our own component control that allows the user to select between different shapes. The component will display a single shape (rectangle, circle or triangle) and clicking on the component will toggle the shape selected between the different options.

Before we start: some resources like colors, strings, backgrounds are not included in this article, visit the github project for full code

Component 1: SButton (compound component) ​

The first component that we'll be creating is a compound button, which will consist of an icon, text and an arrow, This will help us understand creating a reusable component by putting together multiple components .

Define Custom component Class​

To create our own custom component, we start by defining a SButton which extends from StackLayout, as it's a ComponentContainer that will host multiple components. then implement the required constructor:

public class SButton extends StackLayout {

public SButton(Context context, AttrSet attrSet) {
super(context, attrSet);
}

}

Define custom component layout​

Create a layout file component_sbutton.xml, which will hold 3 components as described earlier:

<?xml version="1.0" encoding="utf-8"?>
<DirectionalLayout
xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:height="match_content"
ohos:width="match_parent"
ohos:alignment="center"
ohos:orientation="horizontal"
ohos:padding="3vp"
>

<DirectionalLayout
ohos:id="$+id:button_root"
ohos:height="match_content"
ohos:width="match_parent"
ohos:alignment="center"
ohos:background_element="$graphic:bg_button_red"
ohos:orientation="horizontal"
>

<DirectionalLayout
ohos:height="match_content"
ohos:width="match_parent"
ohos:alignment="center"
ohos:bottom_margin="3vp"
ohos:left_margin="10vp"

ohos:min_height="40vp"
ohos:orientation="horizontal"
ohos:right_margin="10vp"
ohos:top_margin="3vp"
>

<Image
ohos:id="$+id:button_icon"
ohos:height="30vp"
ohos:width="28vp"
ohos:clip_alignment="center"
ohos:image_src="$media:icon"
ohos:layout_alignment="center"
ohos:scale_mode="clip_center"
ohos:visibility="visible"
ohos:weight="1.2"
/>

<Text
ohos:id="$+id:button_text"
ohos:height="match_content"
ohos:width="match_content"
ohos:left_margin="5vp"
ohos:multiple_lines="true"
ohos:text="Button"
ohos:text_alignment="center"
ohos:text_color="white"
ohos:text_size="18fp"
ohos:text_weight="700"
ohos:weight="5"
/>

<Text
ohos:id="$+id:button_arrow"
ohos:height="match_parent"
ohos:width="match_content"
ohos:left_margin="10vp"
ohos:text="→"
ohos:text_color="white"
ohos:text_size="20fp"
ohos:text_weight="800"
ohos:visibility="visible"
ohos:weight="1"
/>

</DirectionalLayout>
</DirectionalLayout>
</DirectionalLayout>

The component has a default red background defined in graphic/bg_button_red.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<shape xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:shape="rectangle">
<solid
ohos:color="$color:red"/>
<corners
ohos:radius="50"/>
</shape>

In the previewer you should see this:

Let's set set the layout and initialize the components before we move to the attributes :

public class SButton extends StackLayout {

//...

/**
* root layout, parent of other components {@link com.megaache.customcomponent.ui.components.SButton#textC} {@link com.megaache.customcomponent.ui.components.SButton#iconC} {@link com.megaache.customcomponent.ui.components.SButton#arrowC}
*/
private DirectionalLayout root;

/**
* button text, shown in the center of the button
*/
private Text textC;
/**
* icon component shown on the left side of the button's text
*/
private Image iconC;
/**
* arrow component shown on the rigth side of button's text
*/
private Text arrowC;

public SButton(Context context, AttrSet attrSet) {
super(context, attrSet);

LayoutScatter.getInstance(getContext())
.parse(ResourceTable.Layout_component_sbutton, this, true);

root = (DirectionalLayout) findComponentById(ResourceTable.Id_button_root);
textC = (Text) findComponentById(ResourceTable.Id_button_text);
arrowC = (Text) findComponentById(ResourceTable.Id_button_arrow);
iconC = (Image) findComponentById(ResourceTable.Id_button_icon);
}
}

Add Component to Layout​

Next, let's add this component to our ability layout:

<?xml version="1.0" encoding="utf-8"?>
<DirectionalLayout
xmlns:ohos="http://schemas.huawei.com/res/ohos"
xmlns:sbutton="http://huawei.com/res/ohos"

ohos:height="match_parent"
ohos:width="match_parent"
ohos:background_element="black"
ohos:orientation="vertical"
ohos:padding="20vp"
>

<com.megaache.customcomponent.ui.components.SButton
ohos:id="$+id:sbutton"
ohos:height="match_content"
ohos:width="200vp"
ohos:layout_alignment="horizontal_center"

/>

</DirectionalLayout>

Note: how we define a custom namespace SButton. This namespace allows you to allow Harmony to auto-resolve the namespace, avoiding the annoying IDE errors

this is the component in its default state:

Define Custom Attributes

Well-written custom components can be configured and styled via XML attributes. You need to ask yourself which aspects of your component should be customizable. For example, we might want to let the user set the background (color) of the button, set icon source, set the button's text as well as give the developer the option to hide or show an arrow icon. We might want the component to be configurable in XML as follows:

<com.megaache.customcomponent.ui.components.SButton
ohos:id="$+id:sbutton"
ohos:height="match_content"
ohos:width="200vp"
ohos:layout_alignment="horizontal_center"

sbutton:btn_bg="$graphic:button_violet"
sbutton:icon_src="$media:phone"
sbutton:show_arrow="true"
sbutton:text="$string:hello"
/>

Once you define the custom attributes, you can use them in layout XML files, You won't get similar experience like built-in attributes (autocomplete) and that's because DevEco studio does not support defining the attribute format yet!.

A difference between custom attributes and built-in attribute is that your custom attributes belong to a different namespace. You can define the namespace within the root component of the layout and configure the properties for the component. You would need to use the namespace http://huawei.com/res/ohos:

<?xml version="1.0" encoding="utf-8"?>
<DirectionalLayout
xmlns:ohos="http://schemas.huawei.com/res/ohos"
xmlns:sbutton="http://huawei.com/res/ohos"

ohos:height="match_parent"
ohos:width="match_parent"
ohos:background_element="black"
ohos:orientation="vertical"
ohos:padding="20vp"
>

<com.megaache.customcomponent.ui.components.SButton
ohos:id="$+id:sbutton"
ohos:height="match_content"
ohos:width="200vp"
ohos:layout_alignment="horizontal_center"

sbutton:btn_bg="$graphic:button_violet"
sbutton:icon_src="$media:phone"
sbutton:show_arrow="true"
sbutton:text="$string:hello"
/>

</DirectionalLayout>

Apply Custom Attributes​

Now that we have set custom properties such as btn_bg, icon_src, show_arrow and text, we need to extract those properties to be used within our custom component within the constructor, from AttrSet parameter:

First, let's created an inner class that will hold the attribute name, to keep our code clean:

public class SButton extends StackLayout {
public static class SButtonAttrsConstants {
//if set to true, arrow will be shown on right side of the text
public static final String SHOW_ARROW = "show_arrow";
//if set, icon will be shown on left side of text
public static final String ICON_SRC = "icon_src";
//text to be shown in the button
public static final String BTN_TEXT = "text";
//background for the container
public static final String BTN_BG = "btn_bg";
}

//...

}

The attributes are passed to the custom component inside AttrSet parameter, We are gonna use the method ifPresent when extracing the attribute for safety:

attrSet.getAttr("attribute_name").ifPresent(attr -> attr.getStringValue() )

There are seven (7) types of attribute, make sure to use the right method to get the attribute value according to its format:

String: ex app:text="blah blah", app:text="$string:blah_blah"

attr.getStringValue();

Integer, ex app:number="1"

attr.getIntegerValue();

graphic or media, ex app:graphic="$graphic:bg_red", app:icon="$media:banana"

attr.getElement();

Boolean, ex app:hide="true"

attr.getBoolValue();

Dimension, ex app:dimen="2vp"

attr.getDimensionValue();

Float, ex app:float="22.34"

attr.getFloatValue();

Long, ex app:long="1234543212345"

attr.getLongValue();

extracting the attribute values:

public class SButton extends StackLayout {

//...

public SButton(Context context, AttrSet attrSet) {
super(context, attrSet);

//...

if (attrSet != null) {
attrSet.getAttr(SButtonAttrsConstants.SHOW_ARROW).ifPresent(attr -> setShowArrow(attr.getBoolValue()));
attrSet.getAttr(SButtonAttrsConstants.BTN_TEXT).ifPresent(attr -> setText(attr.getStringValue()));
attrSet.getAttr(SButtonAttrsConstants.BTN_TEXT).ifPresent(attr -> setText(attr.getIntegerValue()));
attrSet.getAttr(SButtonAttrsConstants.ICON_SRC).ifPresent(attr -> setIconRes(attr.getElement()));

attrSet.getAttr(SButtonAttrsConstants.BTN_BG).ifPresent(attr -> setBg(attr.getElement()));
}

}

//...

}

then we write some logic to update the component based on the attributes:

public class SButton extends StackLayout {

//...

/**
* set button text
*
* @param textRes resource id of text that will be shown inside button
*/
public void setText(int textRes) {
if (textRes != -1) {
this.textC.setText(textRes);
}
}

/**
* show icon on left side of the text
*
* @param imageElement image element ex: {@link ResourceTable#Media_phone}
* if imageElement is null, the icon component will be hidden
*/
public void setIconRes(Element imageElement) {
if (imageElement == null) {
UiUtils.hide(iconC);
} else {
imageElement.setBounds(0, 0, iconC.getRight(), iconC.getBottom());
iconC.setImageElement(imageElement);
UiUtils.visible(iconC);
}
}

/**
* set button text
*
* @param text will be shown inside button
*/
public void setText(String text) {
if (text != null && !text.isEmpty()) {
this.textC.setText(text);
}
}

/**
* show arrow on right side of the text
*
* @param showArrow true to show an arrow, false otherwise
*/
public void setShowArrow(boolean showArrow) {
if (showArrow)
UiUtils.visible(arrowC);
else
UiUtils.hide(arrowC);
}

/**
* change button background
*
* @param shapeElement element that will be set as background
*/
public void setBg(Element shapeElement) {
root.setBackground(shapeElement);
}

}

Finishing the Component​

Finally, lets add some animation to our custom component which is totally optional:

public class SButton extends StackLayout {

//...

@Override
public void setClickedListener(ClickedListener listener) {
super.setClickedListener((c) -> {
animateTouchDown();
getContext().getUITaskDispatcher().delayDispatch(() -> {
animateTouchUp();
listener.onClick(c);
}, 300);
});
}

/**
* scale button up when clicked, called when the button is clicked
*/
private void animateTouchDown() {
setScale(1.1f, 1.1f);
setAlpha(0.5f);
}

/**
* scale button down to normal size, called when the button is clicked
*/
private void animateTouchUp() {
setScale(1, 1);
setAlpha(1f);
}
}

then within the ability, we can display a toast when the button is clicked:

public class MainAbility extends Ability {
@Override
public void onStart(Intent intent) {
super.onStart(intent);

setUIContent(ResourceTable.Layout_ability_main);

SButton sButton = (SButton) findComponentById(ResourceTable.Id_sbutton);
sButton.setClickedListener(c -> toast("you clicked the SButton!"));

}

private void toast(String msg) {
new ToastDialog(this).setText(msg).setAlignment(LayoutAlignment.CENTER).show();
}
}

Run the app:

Component 2: ShapeShiftingButton​

The second component that we will be creating is a fully custom “shape shifting”‘component that changes it’s shape when it’s clicked.

Define Custom Component Class​

We start by defining a ShapeShiftingComponent which extends from Component and implements the required constructor:

public class ShapeShiftingComponent extends Component {

public ShapeShiftingComponent(Context context, AttrSet attrSet) {
super(context, attrSet);
}

}

Add Component to Layout​

Next, let's add this component to our ability layout:

<?xml version="1.0" encoding="utf-8"?>
<DirectionalLayout
xmlns:ohos="http://schemas.huawei.com/res/ohos"
xmlns:app="http://huawei.com/res/ohos"
ohos:height="match_parent"
ohos:width="match_parent"
ohos:background_element="black"
ohos:orientation="vertical"
ohos:padding="20vp"
>

<com.megaache.customcomponent.ui.components.ShapeShiftingComponent
ohos:id="$+id:ssbutton"
ohos:height="80vp"
ohos:width="160vp"
ohos:layout_alignment="horizontal_center"
ohos:text_color="white"
ohos:text_size="18fp"
ohos:top_margin="20vp"
/>

</DirectionalLayout>

Define Custom Attributes​

for this component, we can let the user set the initial shape which can be done as follow:

<com.megaache.customcomponent.ui.components.ShapeShiftingComponent
ohos:id="$+id:ssbutton"
ohos:height="80vp"
ohos:width="160vp"
ohos:layout_alignment="horizontal_center"
ohos:text_color="white"
ohos:text_size="18fp"
ohos:top_margin="20vp"

app:shape="rectangle"
app:shape_color="#ff0000"
/>

Apply Custom Attributes​

We have set custom attribute shape and shape_color lets extract this attribute from AttrSet parameter.

Lets declare the attribute name in separate class:

public class ShapeShiftingComponent extends Component {

public static class CSButtonAttrsConstants {
public static final String CSBUTTON_SHAPE = "shape";
public static final String CSBUTTON_SHAPE_COLOR = "shape_color";
}

}

Lets extract the values of attributes which will be used within the constructor of our component:

public class ShapeShiftingComponent extends Component {

//...

public static class CSButtonAttrsConstants {
public static final String CSBUTTON_SHAPE = "shape";
public static final String CSBUTTON_SHAPE_COLOR = "shape_color";
}

/**
* button shape, can be set inside layout file, ex: app:shape="rectangle"
* must be one of: rectangle, circle, triangle
*/

private String shape;

/**
* the color of the shape, can be set using attribute inside layout (xml) file
*/
private Color shapeColor;

public ShapeShiftingComponent(Context context, AttrSet attrSet) {
super(context, attrSet);

if (attrSet != null) {
attrSet.getAttr(CSButtonAttrsConstants.CSBUTTON_SHAPE)
.ifPresent(attr -> shape = attr.getStringValue());
attrSet.getAttr(CSButtonAttrsConstants.CSBUTTON_SHAPE_COLOR)
.ifPresent(attr -> shapeColor = attr.getColorValue());
}

}

//...

}

Drawing a shape​

Next, let's actually draw a rectangle taking into account shape color. we will use addDrawTask to draw the shape for now, in future releases of HarmonyOs SDK, the components will have method onDraw where you can draw shapes:

public class ShapeShiftingComponent extends Component {

//...

/**
* the color of the shape, can be set using attribute inside layout (xml) file
*/
private Color shapeColor;

/**
* WIDTH of the button, used to draw shape using canvas in {@link ShapeShiftingComponent#drawShape}
*/
public static final int WIDTH = 160;
/**
* HEIGHT of the button, used to draw shape using canvas in {@link ShapeShiftingComponent#drawShape}
*/
public static final int HEIGHT = 80;

/**
* the y offset of the text that's shown inside the component (button)
*/
public static final int TEXT_Y_OFFSET = HEIGHT / 2 + 5;
/**
* size of the text that's shown inside the component (button)
*/
public static final int TEXT_SIZE_FP = 20;

/**
* paint used to draw the shapes, defines the color of the shape
* used in {@link ShapeShiftingComponent#drawShape()}
*/
private Paint shapePaint;
/**
* paint used to draw the text (shape name), it defines the color and font size of the text
* used in {@link ShapeShiftingComponent}
*/
private Paint textPaint;

public ShapeShiftingComponent(Context context, AttrSet attrSet) {
super(context, attrSet);

//...

preparePaint();
drawShape();
}

/**
* add draw task to draw shape and it's name (shape)
*/
private void drawShape() {
addDrawTask((component, canvas) -> {
//draw rectangle
canvas.drawRect(0, 0, toPx(WIDTH), toPx(HEIGHT), shapePaint);

//draw text 'rectangle' inside shape
canvas.drawText(textPaint, shape, toPx(WIDTH / 2 - (9 * shape.length() / 2)), toPx(TEXT_Y_OFFSET));
});

}

/**
* convert VP (visual/density pixel) to pixels
*
* @param vp value in VP (density pixel)
* @return value in pixels
*/
private float toPx(int vp) {
return AttrHelper.vp2px(vp, getContext());
}

/**
* initial paint objects, that are used to draw the shape and text (shape name)
*/
private void preparePaint() {
shapePaint = new Paint();
shapePaint.setStyle(Paint.Style.FILL_STYLE);
shapePaint.setColor(shapeColor);

textPaint = new Paint();
textPaint.setStyle(Paint.Style.FILL_STYLE);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(AttrHelper.fp2px(TEXT_SIZE_FP, getContext()));
}

}

this will draw the shape based on the attributes shape and shape_color defined in XML layout file:

you can set the attribute shape to circle:

or triangle:

Toggling between shapes​

Now, to make our custom component stand to its name, we have to make it change shape each time it's clicked.

Let's setup a "clicked listener" to invalidate the layout to force re-draw the component:

public class ShapeShiftingComponent extends Component {
//...
public static final String CIRCLE = "circle";
public static final String RECTANGLE = "rectangle";
public static final String TRIANGLE = "triangle";

private final String[] shapes = new String[]{RECTANGLE, TRIANGLE, CIRCLE};
/**
* button shape, can one of {@link ShapeShiftingComponent#shapes}
*/
private String shape;

/**
* used to loop through the array of shapes {@link ShapeShiftingComponent#shapes}
*/
private int shapeIndex;

public ShapeShiftingComponent(Context context, AttrSet attrSet) {
super(context, attrSet);

//...

registerListener();
}

/**
* register click listener to change shape everytime the component is clicked
*/
private void registerListener() {
this.setClickedListener(c -> {
shapeIndex++;

shape = shapes[shapeIndex % shapes.length];

invalidate();
postLayout();
});
}
//...
}

Now whenever the shape is clicked, the selected shape is changed and the OS should call our drawing task once again.

Let's update our draw task to handle drawing other shapes (circle, triangle):

public class ShapeShiftingComponent extends Component {

//...

public static final int CIRCLE_RADIUS = 120;

/**
* add draw task to draw the next shape of the component, and it's name (shape)
* also show toast for the current shape
*/
private void drawShape() {
addDrawTask((component, canvas) -> {

new ToastDialog(getContext())
.setAlignment(LayoutAlignment.CENTER)
.setText("shape:" + shape).show();

switch (shape) {
case CIRCLE: {
Point center = new Point(WIDTH * 1.5f, HEIGHT * 1.5f);
canvas.drawCircle(center, CIRCLE_RADIUS, shapePaint);
break;
}
case TRIANGLE: {
Path trianglePath = new Path();

trianglePath.moveTo(0, HEIGHT * 2); //bottom left

trianglePath.lineTo(WIDTH * 3, HEIGHT * 2); //bottom right

trianglePath.lineTo(WIDTH * 1.5f, 0); //center

canvas.drawPath(trianglePath, shapePaint);
break;
}
default: {
canvas.drawRect(0, 0, toPx(WIDTH), toPx(HEIGHT), shapePaint);
}
}

canvas.drawText(textPaint, shape, toPx(WIDTH / 2 - (9 * shape.length() / 2)), toPx(TEXT_Y_OFFSET));
});

}

//...
}

Finishing the component​

The final touch is to add the default values: or may be this should have been the first step LOL!

public class ShapeShiftingComponent extends Component {

//...

public ShapeShiftingComponent(Context context, AttrSet attrSet) {
super(context, attrSet);
setDefaultValues();

//...

}

/**
* set the default values, in case the developer didn't specify the values in layout file (xml)
*/
private void setDefaultValues() {
shape = RECTANGLE;
shapeColor = new Color(getContext().getColor(ResourceTable.Color_ssbutton_default));
shapeIndex = 0;
}

//...

}

Run the app, click the component to loop throught the shapes:

Tips and tricks​

There are many other things to cosider when creating custom component, like measurements or touch events, check out the custom component guide on the official website.

If you have an existing custome component that's similar to what you want to create, you can simlply extend that custom component and just override the behavior that you want and get the rest for free

You can also create a custom layout by extending ComponetContainer, to act as a container for your other components

To run the project you're advised to HVD manager to test on a remote device, If you want to run the project on a physical device then you have to configure the signin config (last link in references)

Libraries​

there are some libraries for harmonyOs that have custom comopnents, you can check some picks below. the libraries are open source so you may wanna have a look at the source code to learn more:

BadgeView: https://gitee.com/openharmony-tpc/BadgeView

CircleImageView: https://gitee.com/openharmony-tpc/CircleImageView

ArcProgresssStackView: https://gitee.com/openharmony-tpc/ArcProgressStackView

Github project ​

Click here

References​

https://developer.harmonyos.com/en/docs/documentation/doc-guides/ui-java-custom-components-0000001139369661

https://developer.harmonyos.com/en/docs/documentation/doc-guides/ui-java-custom-layouts-0000001092683918

https://developer.harmonyos.com/en/docs/documentation/doc-references/component-0000001054678683

https://developer.harmonyos.com/en/docs/documentation/doc-guides/build_hap-0000001053342418

sorry If the links are not clickable, it's not on me :)

Original Source

Thanks for reading!