Skip to content

Commit 14b4e62

Browse files
authored
Merge pull request #3307 from YodonTan/feat/mobile-statusbar
fix(mobile): status bar immersion adaptation
2 parents d047c43 + b451c79 commit 14b4e62

7 files changed

Lines changed: 129 additions & 21 deletions

File tree

‎src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,18 @@ export default class EntryAbility extends UIAbility {
129129
return;
130130
}
131131
const dark = colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
132-
const background = dark ? MobileDesignColors.pageBg.dark : MobileDesignColors.pageBg.light;
133132
const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light;
134133
try {
135134
this.mainWindow.setWindowSystemBarProperties({
136-
// The status bar keeps the page colour it has always had — the window
137-
// does not draw behind it on every device, and an opaque bar cannot
138-
// regress the header band either way. The navigation bar is transparent
139-
// instead: the chat page's bottom fade is what should be visible there.
140-
statusBarColor: background,
135+
// Both bars are transparent, because the window is laid out full-screen
136+
// and the strips they occupy are the page's own edges: the drawer's fill
137+
// and the chat page's blurred header band are what should show there.
138+
// An opaque status bar painted the page colour over both, which clipped
139+
// the sidebar's title, and it also sat over the pull-down notification
140+
// centre's frosted background as a white strip instead of blending into
141+
// it. `statusBarContentColor` is what keeps the clock and the icons
142+
// legible against whatever the page puts behind them.
143+
statusBarColor: MobileDesignColors.transparent.light,
141144
navigationBarColor: MobileDesignColors.transparent.light,
142145
statusBarContentColor: content,
143146
navigationBarContentColor: content

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export struct AppSidebar {
5353
* the screen edge for the compact drawer — and the session rows scroll under
5454
* the navigation indicator, so the strip is the list's own business now. The
5555
* floating footer keeps its distance from that edge itself, which is what
56-
* keeps the buttons above the bar while the rows run under it.
56+
* keeps the buttons above the bar while the rows run under it. The panel's top
57+
* edge is the screen edge too, so the status bar strip is the panel's own
58+
* padding rather than a band the framework reserved for it.
5759
*/
5860
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();
5961
/**
@@ -156,7 +158,12 @@ export struct AppSidebar {
156158
// the session rows are content that scrolls under the navigation indicator.
157159
// The footer, its fade and the list's tail are what keep the strip clear,
158160
// each against the edge this panel actually has.
159-
.padding({ left: 20, right: 20, top: 0 })
161+
//
162+
// The panel's top edge is the screen edge in both hosts, so its own first
163+
// row is what has to keep clear of the status bar: the fill still reaches
164+
// the top of the window, and the title, the search field and the session
165+
// rows start below the strip.
166+
.padding({ left: 20, right: 20, top: this.insets.top })
160167
.backgroundColor(SIDEBAR_BG)
161168
}
162169

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,12 @@ export struct FilePreviewSurface {
176176
})
177177
}
178178
.width('100%')
179-
.height(68)
180-
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
179+
.height(68 + this.insets.top)
180+
// The header band is the surface's top edge in both hosts — the compact
181+
// full-page overlay and the wide preview pane — so it carries the status bar
182+
// strip itself: the band's fill still reaches the top of the window and the
183+
// back control and title sit below the clock and the indicators.
184+
.padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 })
181185
.alignItems(VerticalAlign.Center)
182186
.border({ width: { bottom: 1 }, color: LINE })
183187
.backgroundColor(PAGE_BG)

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export struct MiniAppSurface {
2727

2828
build() {
2929
Column() {
30+
// The gallery is a full-screen surface, so its header band owns the top
31+
// edge of the window: the box grows by the status bar strip and the
32+
// controls keep the 56 the design gives them below it, instead of sitting
33+
// under the clock and the indicators.
3034
Row({ space: 8 }) {
3135
NavigationBackButton({ onBack: (): void => {
3236
if (this.state.appId) this.actions.back();
@@ -37,7 +41,7 @@ export struct MiniAppSurface {
3741
.fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
3842
.textAlign(TextAlign.Center).layoutWeight(1)
3943
Blank().width(48)
40-
}.width('100%').height(56).padding({ left: 12, right: 12 })
44+
}.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top })
4145
if (this.state.document) {
4246
MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request })
4347
.layoutWeight(1)

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './remote/RemoteSurfaceHost';
2828
import { SidebarToggleButton } from './SidebarToggleButton';
2929
import { SidebarWorkspaceSection } from './SidebarWorkspaceSection';
30+
import { WindowInsetsBinding } from '../../services/WindowSystemBarService';
3031
import {
3132
LINE,
3233
PAGE_BG,
@@ -63,6 +64,23 @@ export struct WideConversationHost {
6364
@Event onRestoreMasterPane: () => void = () => {};
6465
@Event onToggleFilePreviewExpanded: () => void = () => {};
6566
@Event onOpenRemoteViewSettings: () => void = () => {};
67+
/**
68+
* The window's own insets, in vp. Every pane in this host is pinned to the
69+
* window's edges, so the floating controls that are not part of a page's own
70+
* header band keep the status bar strip clear themselves.
71+
*/
72+
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();
73+
74+
aboutToAppear(): void {
75+
const context = this.getUIContext().getHostContext();
76+
if (context) {
77+
this.insets.bind(this.getUIContext(), context);
78+
}
79+
}
80+
81+
aboutToDisappear(): void {
82+
this.insets.unbind();
83+
}
6684

6785
build() {
6886
if (this.showsRemoteConversation() &&
@@ -117,8 +135,10 @@ export struct WideConversationHost {
117135
.width('100%').height('100%').justifyContent(FlexAlign.Start).backgroundColor(PAGE_BG)
118136

119137
if (this.wideMasterPaneCollapsed) {
138+
// The detail pane's top edge is the window's, so the floating control
139+
// keeps the status bar strip above it instead of sitting under it.
120140
SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane })
121-
.position({ x: 12, y: 12 }).zIndex(2)
141+
.position({ x: 12, y: 12 + this.insets.top }).zIndex(2)
122142
.hitTestBehavior(HitTestMode.Default)
123143
}
124144
}

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { RemoteSessionList } from '../RemoteSessionList';
1717
import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView';
1818
import { SidebarToggleButton } from '../SidebarToggleButton';
1919
import { SessionActionPresentation } from '../SessionActionSurface';
20+
import { WindowInsetsBinding } from '../../../services/WindowSystemBarService';
2021
import {
2122
SettingsPlacement,
2223
SettingsPlacementPolicy,
@@ -62,6 +63,13 @@ export struct RemoteSurfaceHost {
6263
@Param wideMasterPaneCollapsed: boolean = false;
6364
@Param sessionDetailsPlacement: SettingsPlacement =
6465
SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails);
66+
/**
67+
* The window's own insets, in vp. The home surfaces are full-screen pages —
68+
* the compact header and the wide placeholder are both the page's top edge —
69+
* so their header bands reserve the status bar themselves rather than being
70+
* handed a page that already ends below it.
71+
*/
72+
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();
6573
@Event onOpenSidebar: () => void = () => {};
6674
@Event onRestoreSidebar: () => void = () => {};
6775
@Event onCloseSettings: () => void = () => {};
@@ -71,11 +79,19 @@ export struct RemoteSurfaceHost {
7179
// down for a change that is only page state — the sidebar rebuild this used
7280
// to pay for. See AppShellViewModel.replaceRouteWithoutAnimation.
7381
aboutToAppear(): void {
82+
const context = this.getUIContext().getHostContext();
83+
if (context) {
84+
this.insets.bind(this.getUIContext(), context);
85+
}
7486
RemoteLogger.info(
7587
`remote surface mounted mode=${this.mode} connection=${this.remotePageState.connectionState} sessions=${this.remotePageState.visibleSessions().length}`
7688
);
7789
}
7890

91+
aboutToDisappear(): void {
92+
this.insets.unbind();
93+
}
94+
7995
build() {
8096
if (this.mode !== RemoteSurfaceMode.Settings &&
8197
RemoteCompactHomePolicy.shouldShowWelcomeHome(this.remotePageState.controlTargetType,
@@ -219,13 +235,17 @@ export struct RemoteSurfaceHost {
219235
@Builder
220236
private CompactHomeContent() {
221237
Column() {
238+
// The compact home is a page, not a sheet: its header band is the top edge
239+
// of the window, so the band reserves the status bar and the drawer
240+
// control and title start below the clock and the indicators.
222241
ConversationHeader({
223242
title: RemoteI18n.t('app.title'),
224243
subtitle: this.compactHeaderContext(),
225244
titleFallback: RemoteI18n.t('app.title'),
226245
showSidebarButton: true,
227246
onOpenSidebar: this.onOpenSidebar
228247
})
248+
.padding({ top: this.insets.top })
229249
if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) {
230250
this.ConnectionEntry()
231251
} else {
@@ -270,8 +290,11 @@ export struct RemoteSurfaceHost {
270290
// No hairline under the welcome header. The compact header does not draw
271291
// one and neither does the desktop client; the wide surface is the same
272292
// page, not a sheet, so it should not grow a divider just because it is
273-
// wider.
274-
.width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 })
293+
// wider. The band's box grows by the status bar strip for the same reason
294+
// the compact header's does: the wide detail pane's top edge is the
295+
// window's top edge too.
296+
.width('100%').height(76 + this.insets.top)
297+
.padding({ left: 16, right: 16, top: 14 + this.insets.top, bottom: 12 })
275298

276299
this.CompactEmptyState()
277300

‎src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const sessionActionSurface = source('entry/src/main/ets/pages/components/Session
3030
const sessionDetailsView = source('entry/src/main/ets/pages/components/SessionDetailsView.ets');
3131
const conversationViewSettings = source('entry/src/main/ets/pages/components/ConversationViewSettings.ets');
3232
const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets');
33+
const remoteSurfaceHost = source('entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets');
34+
const wideConversationHost = source('entry/src/main/ets/pages/components/WideConversationHost.ets');
3335

3436
// Reads one @Builder out of a component, so an assertion can name the layer it
3537
// is about instead of counting matches in the whole file.
@@ -78,13 +80,19 @@ test('the shell runs the window full-screen for every page', () => {
7880
'the welcome page must not switch the window layout any more');
7981
});
8082

81-
test('the navigation bar is transparent and the status bar keeps the page colour', () => {
82-
// The fade is what should be visible where the navigation bar sits, and the
83-
// header band keeps its opaque page colour so the top does not regress.
83+
test('both system bars are transparent so the page owns their strips', () => {
84+
// The window runs full-screen, so the status bar's strip is the page's own
85+
// top edge: the drawer's fill and the chat page's blurred header band are what
86+
// should show there. An opaque bar painted the page colour over both — it
87+
// clipped the sidebar's title — and it also sat over the pull-down
88+
// notification centre's frosted background as a white strip. The content
89+
// colour is what keeps the clock and the icons legible either way.
8490
assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/,
8591
'the navigation bar must be transparent so the page bottom shows through');
86-
assert.match(entryAbility, /statusBarColor: background/,
87-
'the status bar must keep the page colour');
92+
assert.match(entryAbility, /statusBarColor: MobileDesignColors\.transparent\.light/,
93+
'the status bar must be transparent so the page top shows through');
94+
assert.match(entryAbility, /statusBarContentColor: content/,
95+
'the status bar icons must keep following the colour mode');
8896
});
8997

9098
test('the window inset service is the only place the strip is measured', () => {
@@ -131,6 +139,45 @@ test('the chat page header band reserves the status bar itself', () => {
131139
'the band that carries the inset must stay the one that paints the header');
132140
});
133141

142+
test('every surface whose top edge is the screen edge reserves the status bar', () => {
143+
// Immersive layout moved every page's origin to the top of the window, so the
144+
// first row of a full-screen surface is what has to keep clear of the status
145+
// bar. The band that owns the surface's fill carries the padding, which is
146+
// what keeps that fill reaching the top of the window while the controls start
147+
// below the clock and the indicators: the drawer panel's title, the gallery's
148+
// back control, the preview's header and the home headers' drawer control are
149+
// all one strip lower than they were while the page area ended below it.
150+
assert.match(normalize(builderBody(appSidebar, 'SidebarContent')),
151+
/\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/,
152+
'the drawer panel must reserve the strip above its title');
153+
assert.match(normalize(miniAppSurface),
154+
/\.height\(56 \+ this\.insets\.top\)\.padding\(\{ left: 12, right: 12, top: this\.insets\.top \}\)/,
155+
'the mini-app gallery header must reserve the strip');
156+
assert.match(normalize(filePreviewSurface),
157+
/\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: 8, right: 8, top: 8 \+ this\.insets\.top, bottom: 8 \}\)/,
158+
'the file preview header must reserve the strip');
159+
assert.match(normalize(remoteSurfaceHost),
160+
/\.padding\(\{ top: this\.insets\.top \}\)/,
161+
'the compact home header must reserve the strip');
162+
assert.match(normalize(remoteSurfaceHost),
163+
/\.height\(76 \+ this\.insets\.top\) \.padding\(\{ left: 16, right: 16, top: 14 \+ this\.insets\.top, bottom: 12 \}\)/,
164+
'the wide home header must reserve the strip');
165+
// A floating control on a pane whose top edge is the window's keeps the strip
166+
// clear itself, the way the surfaces' fixed bottom controls keep the
167+
// navigation bar clear.
168+
assert.match(normalize(wideConversationHost),
169+
/\.position\(\{ x: 12, y: 12 \+ this\.insets\.top \}\)/,
170+
'the floating master-restore control must reserve the strip');
171+
for (const [name, text] of [['RemoteSurfaceHost', remoteSurfaceHost],
172+
['WideConversationHost', wideConversationHost]]) {
173+
assert.match(text, /@Local insets: WindowInsetsBinding = new WindowInsetsBinding\(\);/,
174+
`${name} must hold the shared inset binding`);
175+
assert.match(text, /this\.insets\.bind\(this\.getUIContext\(\), context\)/,
176+
`${name} must bind the insets while it is mounted`);
177+
assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`);
178+
}
179+
});
180+
134181
test('the settings sheet viewport reaches the screen edge and its rows end in a tail spacer', () => {
135182
// The sheet is a bindSheet, which the framework does not inset while the
136183
// window is immersive, so its own box decides where content can scroll. The
@@ -171,8 +218,8 @@ test('the sidebar list scrolls under the bar and its floating footer keeps the s
171218
// the footer keeps its distance from that edge itself, and the list ends in
172219
// a tail spacer so its last row rests above the navigation bar.
173220
const content = normalize(builderBody(appSidebar, 'SidebarContent'));
174-
assert.match(content, /\.padding\(\{ left: 20, right: 20, top: 0 \}\)/,
175-
'the panel root must not shrink its scrolling viewport');
221+
assert.match(content, /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/,
222+
'the panel root must reserve the status bar without shrinking its scrolling viewport');
176223
assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/,
177224
'the session list must end in a tail spacer');
178225
assert.match(content, /\.margin\(\{ bottom: this\.footerBottomPadding\(\) \}\)/,

0 commit comments

Comments
 (0)