Skip to content

Commit a32216a

Browse files
authored
feat: System Bars Plugin (#8180)
1 parent 06aeb9e commit a32216a

File tree

12 files changed

+827
-39
lines changed

12 files changed

+827
-39
lines changed

android/capacitor/src/main/java/com/getcapacitor/Bridge.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ private void registerAllPlugins() {
655655
this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class);
656656
this.registerPlugin(com.getcapacitor.plugin.WebView.class);
657657
this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class);
658+
this.registerPlugin(com.getcapacitor.plugin.SystemBars.class);
658659

659660
for (Class<? extends Plugin> pluginClass : this.initialPlugins) {
660661
this.registerPlugin(pluginClass);
@@ -1610,7 +1611,6 @@ public Bridge create() {
16101611

16111612
if (webView instanceof CapacitorWebView capacitorWebView) {
16121613
capacitorWebView.setBridge(bridge);
1613-
capacitorWebView.edgeToEdgeHandler(bridge);
16141614
}
16151615

16161616
bridge.setCordovaWebView(mockWebView);

android/capacitor/src/main/java/com/getcapacitor/CapConfig.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public class CapConfig {
5454
private String errorPath;
5555
private boolean zoomableWebView = false;
5656
private boolean resolveServiceWorkerRequests = true;
57-
private String adjustMarginsForEdgeToEdge = "disable";
57+
private String adjustMarginsForEdgeToEdge = "auto";
5858

5959
// Embedded
6060
private String startPath;
@@ -288,7 +288,7 @@ private void deserializeConfig(@Nullable Context context) {
288288
webContentsDebuggingEnabled = JSONUtils.getBoolean(configJSON, "android.webContentsDebuggingEnabled", isDebug);
289289
zoomableWebView = JSONUtils.getBoolean(configJSON, "android.zoomEnabled", JSONUtils.getBoolean(configJSON, "zoomEnabled", false));
290290
resolveServiceWorkerRequests = JSONUtils.getBoolean(configJSON, "android.resolveServiceWorkerRequests", true);
291-
adjustMarginsForEdgeToEdge = JSONUtils.getString(configJSON, "android.adjustMarginsForEdgeToEdge", "disable");
291+
adjustMarginsForEdgeToEdge = JSONUtils.getString(configJSON, "android.adjustMarginsForEdgeToEdge", "auto");
292292

293293
String logBehavior = JSONUtils.getString(
294294
configJSON,
@@ -589,7 +589,7 @@ public static class Builder {
589589
private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION;
590590
private boolean zoomableWebView = false;
591591
private boolean resolveServiceWorkerRequests = true;
592-
private String adjustMarginsForEdgeToEdge = "disable";
592+
private String adjustMarginsForEdgeToEdge = "auto";
593593

594594
// Embedded
595595
private String startPath = null;

android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -54,36 +54,4 @@ public boolean dispatchKeyEvent(KeyEvent event) {
5454
}
5555
return super.dispatchKeyEvent(event);
5656
}
57-
58-
public void edgeToEdgeHandler(Bridge bridge) {
59-
String configEdgeToEdge = bridge.getConfig().adjustMarginsForEdgeToEdge();
60-
61-
if (configEdgeToEdge.equals("disable")) return;
62-
63-
boolean autoMargins = false;
64-
boolean forceMargins = configEdgeToEdge.equals("force");
65-
66-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && configEdgeToEdge.equals("auto")) {
67-
TypedValue value = new TypedValue();
68-
boolean foundOptOut = getContext().getTheme().resolveAttribute(android.R.attr.windowOptOutEdgeToEdgeEnforcement, value, true);
69-
boolean optOutValue = value.data != 0; // value is set to -1 on true as of Android 15, so we have to do this.
70-
71-
autoMargins = !(foundOptOut && optOutValue);
72-
}
73-
74-
if (forceMargins || autoMargins) {
75-
ViewCompat.setOnApplyWindowInsetsListener(this, (v, windowInsets) -> {
76-
Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
77-
MarginLayoutParams mlp = (MarginLayoutParams) v.getLayoutParams();
78-
mlp.leftMargin = insets.left;
79-
mlp.bottomMargin = insets.bottom;
80-
mlp.rightMargin = insets.right;
81-
mlp.topMargin = insets.top;
82-
v.setLayoutParams(mlp);
83-
84-
// Don't pass window insets to children
85-
return WindowInsetsCompat.CONSUMED;
86-
});
87-
}
88-
}
8957
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.getcapacitor.plugin;
2+
3+
import android.content.pm.PackageInfo;
4+
import android.content.res.Configuration;
5+
import android.view.View;
6+
import android.view.Window;
7+
import androidx.core.graphics.Insets;
8+
import androidx.core.view.ViewCompat;
9+
import androidx.core.view.WindowCompat;
10+
import androidx.core.view.WindowInsetsCompat;
11+
import androidx.core.view.WindowInsetsControllerCompat;
12+
import androidx.webkit.WebViewCompat;
13+
import com.getcapacitor.Plugin;
14+
import com.getcapacitor.PluginCall;
15+
import com.getcapacitor.PluginMethod;
16+
import com.getcapacitor.annotation.CapacitorPlugin;
17+
import java.util.Locale;
18+
import java.util.regex.Matcher;
19+
import java.util.regex.Pattern;
20+
21+
@CapacitorPlugin
22+
public class SystemBars extends Plugin {
23+
24+
static final String STYLE_LIGHT = "LIGHT";
25+
static final String STYLE_DARK = "DARK";
26+
static final String STYLE_DEFAULT = "DEFAULT";
27+
static final String BAR_STATUS_BAR = "StatusBar";
28+
static final String BAR_GESTURE_BAR = "NavigationBar";
29+
30+
static final String viewportMetaJSFunction = """
31+
function capacitorSystemBarsCheckMetaViewport() {
32+
const meta = document.querySelectorAll("meta[name=viewport]");
33+
if (meta.length == 0) {
34+
return false;
35+
}
36+
// get the last found meta viewport tag
37+
const metaContent = meta[meta.length - 1].content;
38+
return metaContent.includes("viewport-fit=cover");
39+
}
40+
41+
capacitorSystemBarsCheckMetaViewport();
42+
""";
43+
44+
@Override
45+
public void load() {
46+
super.load();
47+
initSystemBars();
48+
}
49+
50+
private boolean hasFixedWebView() {
51+
PackageInfo packageInfo = WebViewCompat.getCurrentWebViewPackage(bridge.getContext());
52+
Pattern pattern = Pattern.compile("(\\d+)");
53+
Matcher matcher = pattern.matcher(packageInfo.versionName);
54+
55+
if (!matcher.find()) {
56+
return false;
57+
}
58+
59+
String majorVersionStr = matcher.group(0);
60+
int majorVersion = Integer.parseInt(majorVersionStr);
61+
62+
return majorVersion >= 140;
63+
}
64+
65+
private void initSystemBars() {
66+
String style = getConfig().getString("style", STYLE_DEFAULT).toUpperCase(Locale.US);
67+
boolean hidden = getConfig().getBoolean("hidden", false);
68+
boolean disableCSSInsets = getConfig().getBoolean("disableInsets", false);
69+
70+
this.bridge.getWebView().evaluateJavascript(viewportMetaJSFunction, (res) -> {
71+
boolean hasMetaViewportCover = res.equals("true");
72+
if (!disableCSSInsets) {
73+
setupSafeAreaInsets(this.hasFixedWebView(), hasMetaViewportCover);
74+
}
75+
});
76+
77+
getBridge().executeOnMainThread(() -> {
78+
setStyle(style, "");
79+
setHidden(hidden, "");
80+
});
81+
}
82+
83+
@PluginMethod
84+
public void setStyle(final PluginCall call) {
85+
String bar = call.getString("bar", "");
86+
String style = call.getString("style", STYLE_DEFAULT);
87+
88+
getBridge().executeOnMainThread(() -> {
89+
setStyle(style, bar);
90+
call.resolve();
91+
});
92+
}
93+
94+
@PluginMethod
95+
public void show(final PluginCall call) {
96+
String bar = call.getString("bar", "");
97+
98+
getBridge().executeOnMainThread(() -> {
99+
setHidden(false, bar);
100+
call.resolve();
101+
});
102+
}
103+
104+
@PluginMethod
105+
public void hide(final PluginCall call) {
106+
String bar = call.getString("bar", "");
107+
108+
getBridge().executeOnMainThread(() -> {
109+
setHidden(true, bar);
110+
call.resolve();
111+
});
112+
}
113+
114+
@PluginMethod
115+
public void setAnimation(final PluginCall call) {
116+
call.resolve();
117+
}
118+
119+
private void setupSafeAreaInsets(boolean hasFixedWebView, boolean hasMetaViewportCover) {
120+
ViewCompat.setOnApplyWindowInsetsListener((View) getBridge().getWebView().getParent(), (v, insets) -> {
121+
if (hasFixedWebView && hasMetaViewportCover) {
122+
return insets;
123+
}
124+
125+
Insets safeArea = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
126+
injectSafeAreaCSS(safeArea.top, safeArea.right, safeArea.bottom, safeArea.left);
127+
128+
return WindowInsetsCompat.CONSUMED;
129+
});
130+
}
131+
132+
private void injectSafeAreaCSS(int top, int right, int bottom, int left) {
133+
// Convert pixels to density-independent pixels
134+
float density = getActivity().getResources().getDisplayMetrics().density;
135+
float topPx = top / density;
136+
float rightPx = right / density;
137+
float bottomPx = bottom / density;
138+
float leftPx = left / density;
139+
140+
// Execute JavaScript to inject the CSS
141+
getBridge().executeOnMainThread(() -> {
142+
if (bridge != null && bridge.getWebView() != null) {
143+
String script = String.format(
144+
Locale.US,
145+
"""
146+
try {
147+
document.documentElement.style.setProperty("--safe-area-inset-top", "%dpx");
148+
document.documentElement.style.setProperty("--safe-area-inset-right", "%dpx");
149+
document.documentElement.style.setProperty("--safe-area-inset-bottom", "%dpx");
150+
document.documentElement.style.setProperty("--safe-area-inset-left", "%dpx");
151+
} catch(e) { console.error('Error injecting safe area CSS:', e); }
152+
""",
153+
(int) topPx,
154+
(int) rightPx,
155+
(int) bottomPx,
156+
(int) leftPx
157+
);
158+
159+
bridge.getWebView().evaluateJavascript(script, null);
160+
}
161+
});
162+
}
163+
164+
private void setStyle(String style, String bar) {
165+
if (style.equals(STYLE_DEFAULT)) {
166+
style = getStyleForTheme();
167+
}
168+
169+
Window window = getActivity().getWindow();
170+
WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView());
171+
if (bar.isEmpty() || bar.equals(BAR_STATUS_BAR)) {
172+
windowInsetsControllerCompat.setAppearanceLightStatusBars(!style.equals(STYLE_DARK));
173+
}
174+
175+
if (bar.isEmpty() || bar.equals(BAR_GESTURE_BAR)) {
176+
windowInsetsControllerCompat.setAppearanceLightNavigationBars(!style.equals(STYLE_DARK));
177+
}
178+
}
179+
180+
private void setHidden(boolean hide, String bar) {
181+
Window window = getActivity().getWindow();
182+
WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView());
183+
184+
if (hide) {
185+
if (bar.isEmpty() || bar.equals(BAR_STATUS_BAR)) {
186+
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.statusBars());
187+
}
188+
if (bar.isEmpty() || bar.equals(BAR_GESTURE_BAR)) {
189+
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.navigationBars());
190+
}
191+
return;
192+
}
193+
194+
if (bar.isEmpty() || bar.equals(BAR_STATUS_BAR)) {
195+
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.systemBars());
196+
}
197+
if (bar.isEmpty() || bar.equals(BAR_GESTURE_BAR)) {
198+
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.navigationBars());
199+
}
200+
}
201+
202+
private String getStyleForTheme() {
203+
int currentNightMode = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
204+
if (currentNightMode != Configuration.UI_MODE_NIGHT_YES) {
205+
return STYLE_LIGHT;
206+
}
207+
return STYLE_DARK;
208+
}
209+
}

cli/src/declarations.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,4 +710,43 @@ export interface PluginsConfig {
710710
*/
711711
enabled?: boolean;
712712
};
713+
714+
/**
715+
* System Bars plugin configuration
716+
*
717+
* @since 8.0.0
718+
*/
719+
SystemBars?: {
720+
/**
721+
* Disables the injection of device css insets into the web view.
722+
*
723+
* @default false
724+
*/
725+
disableInsets?: boolean;
726+
/**
727+
* The style of the text and icons of the system bars.
728+
*
729+
* This option is only supported on Android.
730+
*
731+
* @default `DEFAULT`
732+
*/
733+
style?: string;
734+
735+
/**
736+
* Hide the system bars on start.
737+
*
738+
* @default false
739+
*/
740+
hidden?: boolean;
741+
742+
/**
743+
* The type of status bar animation used when showing or hiding.
744+
*
745+
* This option is only supported on iOS.
746+
*
747+
* @default 'FADE'
748+
*
749+
*/
750+
animation?: 'FADE' | 'NONE';
751+
};
713752
}

core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"build": "npm run clean && npm run docgen && npm run transpile && npm run rollup",
2828
"build:nativebridge": "tsc native-bridge.ts --target es2017 --moduleResolution node --outDir build && rollup --config rollup.bridge.config.js",
2929
"clean": "rimraf dist",
30-
"docgen": "docgen --api CapacitorCookiesPlugin --output-readme cookies.md && docgen --api CapacitorHttpPlugin --output-readme http.md",
30+
"docgen": "docgen --api CapacitorCookiesPlugin --output-readme cookies.md && docgen --api CapacitorHttpPlugin --output-readme http.md && docgen --api SystemBarsPlugin --output-readme systembars.md",
3131
"prepublishOnly": "npm run build",
3232
"rollup": "rollup --config rollup.config.js",
3333
"transpile": "tsc",

0 commit comments

Comments
 (0)