This topic uses gaming apps as a typical example to detail how the feature of one-time development for multi-device deployment is applied in actual development. Gaming apps focus on immersive user experience and feature strong interactivity and user engagement. On large-screen devices, these apps can provide a broader field of view and a more efficient interaction experience.
Multi-device adaptation: Dynamically adjust the page layout and elements by listening for breakpoint changes. You can obtain breakpoints from ArkTS and pass them to the native side so that the native side can adapt to different pages and effects based on the breakpoints.
Avoid area adaptation: In gaming apps, the navigation bar is automatically hidden to ensure an immersive user experience. This increases the available screen space and reduces visual interference, allowing users to focus more on the game itself.
Window rotation: Different games may have specific window orientation preferences. You can customize the window rotation direction based on app requirements. This is crucial for gaming apps.
Gaming apps should adapt to devices with different screen sizes, including smartphones, foldable phones, and tablets. The following sections provide reference examples from the perspectives of UX design and page development, helping you develop gaming apps.
Breakpoint Design
Game view is an important factor affecting user experience. An appropriate game view on different devices and screen sizes can enhance user experience. The following lists the game view adaptation strategies for different devices and screen sizes:
Basic field of view.
Bi-fold smartphones offer unique opportunities. In unfolded state, you can use the larger screen to expand the game view, thereby enhancing immersion and information display. For example, game records can be displayed more prominently.
Recommended strategies:
For casual games, especially those in vertical screen mode, you can properly enlarge the game vision on bi-fold smartphones in unfolded state and tablets to adapt to the larger screen.
With the preceding strategies, you can optimize the game view based on the characteristics of different devices, thereby improving the playability and user experience of the game. For details about the design suggestions, see the following.
Multi-Device Responsive Design for Gaming Applications
Click here to view the responsive design guideline.
This section describes how to use layout capabilities to adapt a set of pages in the gaming app across different devices.
The home page of a gaming app mainly displays the full-screen game area, which is the game rendering area. Generally, the XComponent is used to render the graphics drawn by OpenGL.
Item | sm | md | lg |
|---|---|---|---|
Final effect |
|
|
|
On HarmonyOS, the XComponent is often used to display camera preview streams and game scenes. It can be used together with NativeWindow to create an OpenGL development environment and render the graphics drawn by OpenGL for display. To ensure the adaptation effect of the game on different devices and screen sizes, you can use either of the following approaches:
Obtaining Breakpoint Information
Pass the breakpoint value obtained from ArkTS to the native side. The native side can then implement different page effects at different breakpoints based on game design requirements. This example only shows the process of passing breakpoint values. You can implement the adaptation as required on the native side.
export default class EntryAbility extends UIAbility {
public uiContext?: UIContext;
public onWindowSizeChange: (windowSize: window.Size) => void = (windowSize: window.Size) => {
let widthBp: WidthBreakpoint = this.uiContext!.getWindowWidthBreakpoint();
AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
let heightBp: HeightBreakpoint = this.uiContext!.getWindowHeightBreakpoint();
AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
AppStorage.setOrCreate('windowHeight', windowSize.height);
AppStorage.setOrCreate('windowWidth', windowSize.width);
};
// ...
onWindowStageCreate(windowStage: window.WindowStage) {
// Create the main window and set it for the UIAbility.
// ...
windowStage.loadContent('pages/Index', (err, data) => {
// ...
windowStage.getMainWindow((err: BusinessError, data) => {
if (err.code) {
Logger.error('Failed to obtain the main window. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
// Obtain the window size and listen for its changes.
let properties = data.getWindowProperties();
AppStorage.setOrCreate('windowHeight', properties.windowRect.height);
AppStorage.setOrCreate('windowWidth', properties.windowRect.width);
// Obtain the breakpoint and listen for its changes.
this.uiContext = data.getUIContext();
let widthBp: WidthBreakpoint = this.uiContext.getWindowWidthBreakpoint();
let heightBp: HeightBreakpoint = this.uiContext.getWindowHeightBreakpoint();
AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
AppStorage.setOrCreate('currentHeightBreakpoint', heightBp);
data.on('windowSizeChange', this.onWindowSizeChange);
// ...
})
});
}
// ...
}; @Entry
@Component
struct Index {
// ...
// Define the variables passed to the native side.
@State cutoutAreas: Areas = {
top: 0,
right: 0,
bottom: 0,
left: 0,
heightBreakpoint: 0,
widthBreakpoint: 0
};
// ...
// Listen for the changes to breakpoint values in landscape and portrait modes.
@StorageLink('currentHeightBreakpoint') @Watch('breakPointChange') heightBreakpoint: HeightBreakpoint =
HeightBreakpoint.HEIGHT_SM;
@StorageLink('currentWidthBreakpoint') @Watch('breakPointChange') widthBreakpoint: WidthBreakpoint =
WidthBreakpoint.WIDTH_XS;
// ...
// Trigger value passing when breakpoint changes.
breakPointChange() {
this.cutoutAreas.heightBreakpoint = this.heightBreakpoint;
this.cutoutAreas.widthBreakpoint = this.widthBreakpoint;
// Encapsulate the native method and pass in a breakpoint.
tetrahedron_napi.objectPassing(this.cutoutAreas);
}
// ...
} Listening for Surface Size Changes
The XComponent holds a surface, whose default position and size match the XComponent. Obtain a native XComponent instance at the native layer, which is used as the bridge between the ArkTS layer and the native layer. Register the lifecycle and event callbacks of the XComponent by using the APIs provided by the native XComponent, customize drawing content by calling APIs such as NativeWindow, and apply for and submit buffers to the graphics queue. In this way, the custom drawing content can be passed to the surface held by the XComponent. For details about the XComponent development process, see Custom Rendering.
In the event callback of the native XComponent, the OnSurfaceCreated() and OnSurfaceChanged() callbacks are invoked when the surface is created and the surface size changes, respectively. Therefore, you should pay attention to these two callbacks when adapting layouts to devices with different screen sizes.
In the OnSurfaceCreated() and OnSurfaceChanged() callbacks, use OH_NativeXComponent_GetXComponentSize() to obtain the size of the surface held by the XComponent. The width and height of the surface are obtained again each time the surface changes. Use the custom reSizeWindow() method to reset the size of the OpenGL drawing area, facilitating adaptation.
void AppNapi::OnSurfaceCreated(OH_NativeXComponent* component, void* window)
{
LOGE("AppNapi::OnSurfaceCreated");
OH_NativeXComponent_RegisterOnFrameCallback(component, OnFrameCB);
// Obtain the surface size.
int32_t ret = OH_NativeXComponent_GetXComponentSize(component, window, &width_, &height_);
// Draw the image to be displayed on the window and set the size of the drawing area.
LOGE("Offset : x = %{public}f, y = %{public}f ", x_, y_);
if (ret == OH_NATIVEXCOMPONENT_RESULT_SUCCESS) {
tetrahedron_->Init(window, width_, height_);
tetrahedron_->reSizeWindow(width_, height_);
tetrahedron_->Update(angleX_, angleY_);
isCreated_++;
xcHeight_ = height_;
xcWidth_ = width_;
LOGE("AppNapi::OnSurfaceCreated success ");
} else {
LOGE("AppNapi::OnSurfaceCreated failed");
}
}
void AppNapi::OnSurfaceChanged(OH_NativeXComponent* component, void* window)
{
LOGE("AppNapi::OnSurfaceChanged");
// Obtain the surface size again.
int32_t ret = OH_NativeXComponent_GetXComponentSize(component, window, &width_, &height_);
int32_t ret1;
// Set the size of the drawing area.
if (ret == OH_NATIVEXCOMPONENT_RESULT_SUCCESS) {
tetrahedron_->reSizeWindow(width_, height_);
xcHeight_ = height_;
xcWidth_ = width_;
LOGE("after width = %{public}d, height = %{public}d", xcWidth_, xcHeight_);
ret1= OH_NativeXComponent_GetXComponentOffset(component, window, &x_, &y_);
off_x = x_;
off_y = y_;
// ...
}
} Gaming apps mainly use an immersive UI design. To create an immersive experience, the display effects of the status bar, app UI, and navigation bar are adjusted to minimize visual distractions and enhance user experience.
In addition, gaming apps need to extend the layout and hide the avoid area, that is, hide the status bar and navigation bar (the navigation bar shown in the following figure is hidden in the actual use scenario). UI elements are displayed as below.

In such scenarios, the cutout area (for example, camera area) may block part of the page information or user operation buttons. To optimize user experience, the operation buttons need to be moved to the other side of the cutout area. In addition, to avoid large white spaces on the side, you should obtain the position of the cutout area and apply corresponding avoidance design. The procedure is as follows:
Extending the Layout with Hidden Avoid Areas
Call setWindowLayoutFullScreen() to set the window layout to the immersive mode, and then call setWindowSystemBarEnable() to set the visibility state of the status bar and navigation bar. In gaming apps, you should hide the status bar and navigation bar.
onWindowStageCreate(windowStage: window.WindowStage) {
// ...
try {
// Set the main window to immersive and hide the navigation bar.
windowStage.getMainWindowSync().setWindowLayoutFullScreen(true);
windowStage.getMainWindowSync().setWindowSystemBarEnable([]);
} catch (error) {
let err = error as BusinessError;
hilog.error(0x0000, 'EntryAbility',
`Failed to set the window state. Error code=${err.code}, message=${err.message}`);
}
// ...
} Obtaining the Cutout Area
public onAvoidAreaChange: (avoidArea: window.AvoidAreaOptions) => void = (avoidArea: window.AvoidAreaOptions) => {
if (avoidArea.type === window.AvoidAreaType.TYPE_CUTOUT) {
AppStorage.setOrCreate('cutout', avoidArea);
}
}
// ...
onWindowStageCreate(windowStage: window.WindowStage) {
// ...
windowStage.getMainWindow((err: BusinessError, data) => {
// ...
windowStage.loadContent('pages/Index', (err, data) => {
// ...
// Listen for changes to the position of the avoid area.
let avoidArea: window.AvoidArea = data.getWindowAvoidArea(window.AvoidAreaType.TYPE_CUTOUT);
this.onAvoidAreaChange({ type: window.AvoidAreaType.TYPE_CUTOUT, area: avoidArea });
data.on('avoidAreaChange', this.onAvoidAreaChange);
})
});
} @StorageLink('cutout') @Watch('cutoutChange') avoidAreas: window.AvoidAreaOptions | undefined = undefined;
@StorageLink('windowHeight') windowHeight: number = 0;
@StorageLink('windowWidth') windowWidth: number = 0;
// ...
cutoutChange() {
let topPX = getTop(this.avoidAreas);
let rightPX = getRight(this.avoidAreas, this.windowWidth);
let bottomPX = getBottom(this.avoidAreas, this.windowHeight);
let leftPX = getLeft(this.avoidAreas);
// ...
} function getTop(avoidArea: window.AvoidAreaOptions | undefined): number {
let result: number = 0;
if (avoidArea !== undefined) {
if (avoidArea.area.topRect.height) {
result = avoidArea.area.topRect.top + avoidArea.area.topRect.height;
}
} else {
hilog.error(0x0000, '3D', 'Can not get TopSafeAreaPixel, avoidArea visible false');
}
return result;
}
function getBottom(avoidArea: window.AvoidAreaOptions | undefined, windowHeight: number): number {
let result: number = 0;
if (avoidArea !== undefined) {
if (avoidArea.area.bottomRect.height) {
result = windowHeight - avoidArea.area.bottomRect.top;
}
} else {
hilog.error(0x0000, '3D', 'Can not get BottomSafeAreaPixel, avoidArea visible false');
}
return result;
}
function getLeft(avoidArea: window.AvoidAreaOptions | undefined): number {
let result: number = 0;
if (avoidArea !== undefined) {
if (avoidArea.area.leftRect.width) {
result = avoidArea.area.leftRect.left + avoidArea.area.leftRect.width;
}
} else {
hilog.error(0x0000, '3D', 'Can not get LeftSafeAreaPixel, avoidArea visible false');
}
return result;
}
function getRight(avoidArea: window.AvoidAreaOptions | undefined, windowWidth: number): number {
let result: number = 0;
if (avoidArea !== undefined) {
if (avoidArea.area.rightRect.width) {
result = windowWidth - avoidArea.area.rightRect.left;
}
} else {
hilog.error(0x0000, '3D', 'Can not get RightSafeAreaPixel, avoidArea visible false');
}
return result;
} Setting the Avoid Area
After obtaining the position of the avoid area, you can set it on the native surface or ArkTS for avoidance. This topic sets the avoid area on ArkTS and passes the values to the native side. You can adapt the avoid area on the native side using the input value based on actual requirements.
Set the value of padding of the page to the size of the avoid area obtained.
@State localPadding: LocalizedPadding = { top: LengthMetrics.vp(0), start: LengthMetrics.vp(0) };
// ...
@StorageLink('cutout') @Watch('cutoutChange') avoidAreas: window.AvoidAreaOptions | undefined = undefined;
// ...
cutoutChange() {
let topPX = getTop(this.avoidAreas);
let rightPX = getRight(this.avoidAreas, this.windowWidth);
let bottomPX = getBottom(this.avoidAreas, this.windowHeight);
let leftPX = getLeft(this.avoidAreas);
// ...
this.localPadding = {
top: LengthMetrics.px(topPX),
end: LengthMetrics.px(rightPX),
bottom: LengthMetrics.px(bottomPX),
start: LengthMetrics.px(leftPX)
}
// ArkTS2Native
tetrahedron_napi.objectPassing(this.cutoutAreas);
}
// ...
build() {
// ...
.padding(this.localPadding)
} Define a function for passing the data of the avoid area from ArkTS to native. You can customize the avoid area based on app requirements.
// Transfer data from ArkTS to native.
static napi_value objectPassingTs2Napi(napi_env env, napi_callback_info info)
{
size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, NULL, NULL);
if (argc < 1) {
napi_throw_error(env, NULL, "Wrong number of arguments");
return NULL;
}
napi_value obj = args[0];
napi_value keys;
napi_get_property_names(env, obj, &keys); // Obtain all property names.
uint32_t length;
napi_get_array_length(env, keys, &length); // Obtain the number of properties.
for (uint32_t i = 0; i < length; ++i) {
napi_value key;
napi_get_element(env, keys, i, &key); // Obtain the i-th property name.
// Convert property names to strings.
char keyStr[128];
size_t keyLen;
napi_get_value_string_utf8(env, key, keyStr, sizeof(keyStr), &keyLen);
// Obtain property values.
napi_value value;
napi_get_property(env, obj, key, &value);
// Determine the type of property value and process it.
napi_valuetype type;
napi_typeof(env, value, &type);
if (type == napi_string) {
char valueStr[4];
size_t valueLen;
napi_get_value_string_utf8(env, value, valueStr, sizeof(valueStr), &valueLen);
}
if (type == napi_number) {
double num;
napi_get_value_double(env, value, &num);
}
}
return NULL;
}
// Define a function for data transfer.
static napi_value objectPassing(napi_env env, napi_callback_info info)
{
objectPassingTs2Napi(env, info);
return nullptr;
} Games may have a specific window orientation preference, either in landscape or portrait. You can call setPreferredOrientation() to customize the window rotation direction based on app requirements. An appropriate window orientation is crucial for gaming apps. The following uses the portrait orientation as an example to describe how to rotate the window.
// Rotate the window to portrait or reverse portrait automatically following the sensor.
let orientation = window.Orientation.AUTO_ROTATION_PORTRAIT;
try {
windowClass.setPreferredOrientation(orientation, (err: BusinessError) => {
if (err.code) {
Logger.error('Failed to set window orientation. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
Logger.info('Succeeded in setting window orientation. Data: %{public}s');
})
} catch (exception) {
Logger.error('Failed to set window orientation. Cause: %{public}s', JSON.stringify(exception) ?? '');
}