/* * Copyright (c) 2024 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import display from '@ohos.display'; import window from '@ohos.window'; import hilog from '@ohos.hilog'; import { LengthMetrics, Position, Size } from '@ohos.arkui.node'; import curves from '@ohos.curves'; import { Callback } from '@ohos.base'; import mediaQuery from '@ohos.mediaquery'; interface Layout { size: Size; position: Position; } interface RegionLayout { primary: Layout; secondary: Layout; extra: Layout; } /** * Position enum of the extra region * * @enum { number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export enum ExtraRegionPosition { /** * The extra region position is in the top. * * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ TOP = 1, /** * The extra region position is in the bottom. * * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ BOTTOM = 2, } /** * The layout options for the container when the foldable screen is expanded. * * @interface ExpandedRegionLayoutOptions * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export interface ExpandedRegionLayoutOptions { /** * The ratio of the widths of two areas in the horizontal direction. * * @type { ?number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ horizontalSplitRatio?: number; /** * The ratio of the heights of two areas in the vertical direction. * * @type { ?number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ verticalSplitRatio?: number; /** * Does the extended area span from top to bottom within the container? * * @type { ?boolean } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ isExtraRegionPerpendicular?: boolean; /** * Specify the position of the extra area when the extra area does not vertically span the container. * * @type { ?ExtraRegionPosition } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ extraRegionPosition?: ExtraRegionPosition; } /** * The layout options for the container when the foldable screen is in hover mode. * * @interface SemiFoldedRegionLayoutOptions * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export interface HoverModeRegionLayoutOptions { /** * The ratio of the widths of two areas in the horizontal direction. * * @type { ?number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ horizontalSplitRatio?: number; /** * Does the foldable screen display an extra area when it's in the half-folded state? * * @type { ?boolean } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ showExtraRegion?: boolean; /** * Specify the position of the extra area when the foldable screen is in the half-folded state. * * @type { ?ExtraRegionPosition } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ extraRegionPosition?: ExtraRegionPosition; } /** * The layout options for the container when the foldable screen is folded. * * @interface FoldedRegionLayoutOptions * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export interface FoldedRegionLayoutOptions { /** * The ratio of the heights of two areas in the vertical direction. * * @type { ?number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ verticalSplitRatio?: number; } /** * Preset split ratio. * * @enum { number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export enum PresetSplitRatio { /** * 1:1 * * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ LAYOUT_1V1 = 1 / 1, /** * 2:3 * * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ LAYOUT_2V3 = 2 / 3, /** * 3:2 * * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ LAYOUT_3V2 = 3 / 2, } /** * The status of hover mode. * * @interface HoverStatus * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export interface HoverModeStatus { /** * The fold status of devices. * * @type { display.FoldStatus } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ foldStatus: display.FoldStatus; /** * Is the app currently in hover mode? * In hover mode, the upper half of the screen is used for display, and the lower half is used for operation. * * @type { boolean } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ isHoverMode: boolean; /** * The angle of rotation applied. * * @type { number } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ appRotation: number; /** * The status of window. * * @type { window.WindowStatusType } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ windowStatusType: window.WindowStatusType; } /** * The handler of onHoverStatusChange event * * @typedef { function } OnHoverStatusChangeHandler * @param { HoverModeStatus } status - The status of hover mode * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ export type OnHoverStatusChangeHandler = (status: HoverModeStatus) => void; function withDefaultValue(value: T | undefined | null, defaultValue: T): T { if (value === void 0 || value === null) { return defaultValue; } return value; } function getSplitRatio(ratio: number | undefined | null, defaultRatio: number): number { if (ratio === void 0 || ratio === null) { return defaultRatio; } if (ratio <= 0) { return defaultRatio; } return ratio; } class Logger { static debug(format: string, ...args: ESObject[]): void { return hilog.debug(0x3900, 'FoldSplitContainer', format, ...args); } static info(format: string, ...args: ESObject[]): void { return hilog.info(0x3900, 'FoldSplitContainer', format, ...args); } static error(format: string, ...args: ESObject[]): void { return hilog.error(0x3900, 'FoldSplitContainer', format, ...args); } } function initLayout(): Layout { return { size: { width: 0, height: 0 }, position: { x: 0, y: 0 }, }; } /** * Defines FoldSplitContainer container. * * @interface FoldSplitContainer * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @Component export struct FoldSplitContainer { /** * The builder function which will be rendered in the major region of container. * * @type { Callback } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @BuilderParam primary: Callback; /** * The builder function which will be rendered in the minor region of container. * * @type { Callback } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @BuilderParam secondary: Callback; /** * The builder function which will be rendered in the extra region of container. * * @type { ?Callback } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @BuilderParam extra?: Callback; /** * The layout options for the container when the foldable screen is expanded. * * @type { ExpandedRegionLayoutOptions } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @Prop @Watch('updateLayout') expandedLayoutOptions: ExpandedRegionLayoutOptions = { horizontalSplitRatio: PresetSplitRatio.LAYOUT_3V2, verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1, isExtraRegionPerpendicular: true, extraRegionPosition: ExtraRegionPosition.TOP }; /** * The layout options for the container when the foldable screen is in hover mode. * * @type { HoverModeRegionLayoutOptions } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @Prop @Watch('updateLayout') hoverModeLayoutOptions: HoverModeRegionLayoutOptions = { horizontalSplitRatio: PresetSplitRatio.LAYOUT_3V2, showExtraRegion: false, extraRegionPosition: ExtraRegionPosition.TOP }; /** * The layout options for the container when the foldable screen is folded. * * @type { FoldedRegionLayoutOptions } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @Prop @Watch('updateLayout') foldedLayoutOptions: FoldedRegionLayoutOptions = { verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1 }; /** * The animation options of layout * * @type { AnimateParam | null } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ @Prop animationOptions?: AnimateParam | null = undefined; /** * The callback function that is triggered when the foldable screen enters or exits hover mode. * In hover mode, the upper half of the screen is used for display, and the lower half is used for operation. * * @type { ?OnHoverStatusChangeHandler } * @syscap SystemCapability.ArkUI.ArkUI.Full * @crossplatform * @atomicservice * @since 12 */ public onHoverStatusChange?: OnHoverStatusChangeHandler = () => { }; @State primaryLayout: Layout = initLayout(); @State secondaryLayout: Layout = initLayout(); @State extraLayout: Layout = initLayout(); @State extraOpacity: number = 1; private windowStatusType: window.WindowStatusType = window.WindowStatusType.UNDEFINED; private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN; private windowInstance?: window.Window; private containerSize: Size = { width: 0, height: 0 }; private containerGlobalPosition: Position = { x: 0, y: 0 }; private listener?: mediaQuery.MediaQueryListener; private isSmallScreen: boolean = false; private isHoverMode: boolean | undefined = undefined; aboutToAppear() { this.listener = mediaQuery.matchMediaSync('(width<=600vp)'); this.isSmallScreen = this.listener.matches; this.listener.on('change', (result) => { this.isSmallScreen = result.matches; }); this.foldStatus = display.getFoldStatus(); display.on('foldStatusChange', (foldStatus) => { if (this.foldStatus !== foldStatus) { this.foldStatus = foldStatus; this.updateLayout(); this.updatePreferredOrientation(); } }); window.getLastWindow(this.getUIContext().getHostContext(), (error, windowInstance) => { if (error && error.code) { Logger.error('Failed to get window instance, error code: %{public}d', error.code); return; } const windowId = windowInstance.getWindowProperties().id; if (windowId < 0) { Logger.error('Failed to get window instance because the window id is invalid. window id: %{public}d', windowId); return; } this.windowInstance = windowInstance; this.updatePreferredOrientation(); this.windowInstance.on('windowStatusChange', (status) => { this.windowStatusType = status; }); }); } aboutToDisappear() { if (this.listener) { this.listener.off('change'); this.listener = undefined; } display.off('foldStatusChange'); if (this.windowInstance) { this.windowInstance.off('windowStatusChange'); } } build() { Stack() { Column() { if (this.primary) { this.primary(); } } .size(this.primaryLayout.size) .position({ start: LengthMetrics.vp(this.primaryLayout.position.x), top: LengthMetrics.vp(this.primaryLayout.position.y), }) .clip(true) Column() { if (this.secondary) { this.secondary(); } } .size(this.secondaryLayout.size) .position({ start: LengthMetrics.vp(this.secondaryLayout.position.x), top: LengthMetrics.vp(this.secondaryLayout.position.y), }) .clip(true) if (this.extra) { Column() { this.extra?.(); } .opacity(this.extraOpacity) .animation({ curve: Curve.Linear, duration: 250 }) .size(this.extraLayout.size) .position({ start: LengthMetrics.vp(this.extraLayout.position.x), top: LengthMetrics.vp(this.extraLayout.position.y), }) .clip(true) } } .id('$$FoldSplitContainer$Stack$$') .width('100%') .height('100%') .onSizeChange((_, size) => { this.updateContainerSize(size); this.updateContainerPosition(); this.updateLayout(); }) } private dispatchHoverStatusChange(isHoverMode: boolean) { if (this.onHoverStatusChange) { this.onHoverStatusChange({ foldStatus: this.foldStatus, isHoverMode: isHoverMode, appRotation: display.getDefaultDisplaySync().rotation, windowStatusType: this.windowStatusType, }); } } private hasExtraRegion(): boolean { return !!this.extra; } private async updatePreferredOrientation() { if (this.windowInstance) { try { if (this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED) { await this.windowInstance.setPreferredOrientation(window.Orientation.AUTO_ROTATION_PORTRAIT); } else { await this.windowInstance.setPreferredOrientation(window.Orientation.AUTO_ROTATION); } } catch (err) { Logger.error('Failed to update preferred orientation.'); } } } private updateContainerSize(size: SizeOptions) { this.containerSize.width = size.width as number; this.containerSize.height = size.height as number; } private updateContainerPosition() { const context = this.getUIContext(); const frameNode = context.getFrameNodeById('$$FoldSplitContainer$Stack$$'); if (frameNode) { this.containerGlobalPosition = frameNode.getPositionToWindow(); } } private updateLayout() { let isHoverMode: boolean = false; let regionLayout: RegionLayout; if (this.isSmallScreen) { regionLayout = this.getFoldedRegionLayouts(); } else { if (this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED) { regionLayout = this.getExpandedRegionLayouts(); } else if (this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED) { if (this.isPortraitOrientation()) { regionLayout = this.getExpandedRegionLayouts(); } else { regionLayout = this.getHoverModeRegionLayouts(); isHoverMode = true; } } else if (this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED) { regionLayout = this.getFoldedRegionLayouts(); } else { regionLayout = this.getExpandedRegionLayouts(); } } if (this.animationOptions === null) { this.primaryLayout = regionLayout.primary; this.secondaryLayout = regionLayout.secondary; this.extraLayout = regionLayout.extra; } else if (this.animationOptions === void 0) { animateTo({ curve: curves.springMotion(0.35, 1, 0) }, () => { this.primaryLayout = regionLayout.primary; this.secondaryLayout = regionLayout.secondary; this.extraLayout = regionLayout.extra; }); } else { animateTo(this.animationOptions, () => { this.primaryLayout = regionLayout.primary; this.secondaryLayout = regionLayout.secondary; this.extraLayout = regionLayout.extra; }); } if (this.isHoverMode !== isHoverMode) { this.dispatchHoverStatusChange(isHoverMode); this.isHoverMode = isHoverMode; } if (isHoverMode && !this.hoverModeLayoutOptions.showExtraRegion) { this.extraOpacity = 0; } else { this.extraOpacity = 1; } } private getExpandedRegionLayouts(): RegionLayout { const width = this.containerSize.width; const height = this.containerSize.height; const primaryLayout: Layout = initLayout(); const secondaryLayout: Layout = initLayout(); const extraLayout: Layout = initLayout(); const horizontalSplitRatio = getSplitRatio(this.expandedLayoutOptions.horizontalSplitRatio, PresetSplitRatio.LAYOUT_3V2); const verticalSplitRatio = getSplitRatio(this.expandedLayoutOptions.verticalSplitRatio, PresetSplitRatio.LAYOUT_1V1); if (this.hasExtraRegion()) { extraLayout.size.width = width / (horizontalSplitRatio + 1); } else { extraLayout.size.width = 0; } secondaryLayout.size.height = height / (verticalSplitRatio + 1); primaryLayout.size.height = height - secondaryLayout.size.height; primaryLayout.position.x = 0; secondaryLayout.position.x = 0; primaryLayout.position.y = 0; secondaryLayout.position.y = primaryLayout.size.height; const isExtraRegionPerpendicular = withDefaultValue(this.expandedLayoutOptions.isExtraRegionPerpendicular, true); if (isExtraRegionPerpendicular) { primaryLayout.size.width = width - extraLayout.size.width; secondaryLayout.size.width = width - extraLayout.size.width; extraLayout.size.height = height; extraLayout.position.x = primaryLayout.size.width; extraLayout.position.y = 0; } else { const extraRegionPosition = withDefaultValue(this.expandedLayoutOptions.extraRegionPosition, ExtraRegionPosition.TOP); if (extraRegionPosition === ExtraRegionPosition.BOTTOM) { primaryLayout.size.width = width; secondaryLayout.size.width = width - extraLayout.size.width; extraLayout.size.height = secondaryLayout.size.height; extraLayout.position.x = secondaryLayout.size.width; extraLayout.position.y = primaryLayout.size.height; } else { primaryLayout.size.width = width - extraLayout.size.width; secondaryLayout.size.width = width; extraLayout.size.height = primaryLayout.size.height; extraLayout.position.x = primaryLayout.size.width; extraLayout.position.y = 0; } } return { primary: primaryLayout, secondary: secondaryLayout, extra: extraLayout }; } private getHoverModeRegionLayouts(): RegionLayout { const width = this.containerSize.width; const height = this.containerSize.height; const primaryLayout: Layout = initLayout(); const secondaryLayout: Layout = initLayout(); const extraLayout: Layout = initLayout(); const creaseRegionRect = this.getCreaseRegionRect(); primaryLayout.position.x = 0; primaryLayout.position.y = 0; secondaryLayout.position.x = 0; secondaryLayout.position.y = creaseRegionRect.top + creaseRegionRect.height; secondaryLayout.size.height = height - secondaryLayout.position.y; primaryLayout.size.height = creaseRegionRect.top; const showExtraRegion = withDefaultValue(this.hoverModeLayoutOptions.showExtraRegion, false); if (!showExtraRegion) { primaryLayout.size.width = width; secondaryLayout.size.width = width; extraLayout.position.x = width; const isExpandedExtraRegionPerpendicular = withDefaultValue(this.expandedLayoutOptions.isExtraRegionPerpendicular, true); if (isExpandedExtraRegionPerpendicular) { extraLayout.size.height = this.extraLayout.size.height; } else { const expandedExtraRegionPosition = withDefaultValue(this.expandedLayoutOptions.extraRegionPosition, ExtraRegionPosition.TOP); if (expandedExtraRegionPosition === ExtraRegionPosition.BOTTOM) { extraLayout.size.height = secondaryLayout.size.height; extraLayout.position.y = secondaryLayout.position.y; } else { extraLayout.size.height = primaryLayout.size.height; extraLayout.position.y = 0; } } } else { const horizontalSplitRatio = getSplitRatio(this.hoverModeLayoutOptions.horizontalSplitRatio, PresetSplitRatio.LAYOUT_3V2); const extraRegionPosition = withDefaultValue(this.hoverModeLayoutOptions.extraRegionPosition, ExtraRegionPosition.TOP); if (this.hasExtraRegion()) { extraLayout.size.width = width / (horizontalSplitRatio + 1); } else { extraLayout.size.width = 0; } if (extraRegionPosition === ExtraRegionPosition.BOTTOM) { primaryLayout.size.width = width; secondaryLayout.size.width = width - extraLayout.size.width; extraLayout.size.height = secondaryLayout.size.height; extraLayout.position.x = secondaryLayout.size.width; extraLayout.position.y = secondaryLayout.position.y; } else { extraLayout.size.height = primaryLayout.size.height; primaryLayout.size.width = width - extraLayout.size.width; secondaryLayout.size.width = width; extraLayout.position.x = primaryLayout.position.x + primaryLayout.size.width; extraLayout.position.y = 0; } } return { primary: primaryLayout, secondary: secondaryLayout, extra: extraLayout }; } private getFoldedRegionLayouts(): RegionLayout { const width = this.containerSize.width; const height = this.containerSize.height; const primaryLayout: Layout = initLayout(); const secondaryLayout: Layout = initLayout(); const extraLayout: Layout = initLayout(); const verticalSplitRatio = getSplitRatio(this.foldedLayoutOptions.verticalSplitRatio, PresetSplitRatio.LAYOUT_1V1); secondaryLayout.size.height = height / (verticalSplitRatio + 1); primaryLayout.size.height = height - secondaryLayout.size.height; extraLayout.size.height = 0; primaryLayout.size.width = width; secondaryLayout.size.width = width; extraLayout.size.width = 0; primaryLayout.position.x = 0; secondaryLayout.position.x = 0; extraLayout.position.x = width; primaryLayout.position.y = 0; secondaryLayout.position.y = primaryLayout.size.height; extraLayout.position.y = 0; return { primary: primaryLayout, secondary: secondaryLayout, extra: extraLayout }; } private getCreaseRegionRect(): display.Rect { const creaseRegion = display.getCurrentFoldCreaseRegion(); const rects = creaseRegion.creaseRects; let left: number = 0; let top: number = 0; let width: number = 0; let height: number = 0; if (rects && rects.length) { const rect = rects[0]; left = px2vp(rect.left) - this.containerGlobalPosition.x; top = px2vp(rect.top) - this.containerGlobalPosition.y; width = px2vp(rect.width); height = px2vp(rect.height); } return { left, top, width, height }; } private isPortraitOrientation() { const defaultDisplay = display.getDefaultDisplaySync(); switch (defaultDisplay.orientation) { case display.Orientation.PORTRAIT: case display.Orientation.PORTRAIT_INVERTED: return true; case display.Orientation.LANDSCAPE: case display.Orientation.LANDSCAPE_INVERTED: default: return false; } } }