-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: System Bars Plugin #8180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: System Bars Plugin #8180
Changes from all commits
2b49827
b78f69f
edb5288
3bb5784
6ddb7b9
9e6b41a
2c93ccd
c4be18d
11c5590
07d6b3f
0698ca7
8bcd46d
15fab3a
bf2fe33
bbfb49b
c6664d1
55717fd
0f7d843
ba72a20
e6bd10b
929aa33
114367d
e8df7ae
7eb6aed
6fb8d70
a104cea
7ca256c
21f473b
be27917
cd2fb6c
cf0b1f9
ec05a18
984a254
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| package com.getcapacitor.plugin; | ||
|
|
||
| import android.content.pm.PackageInfo; | ||
| import android.content.res.Configuration; | ||
| import android.view.View; | ||
| import android.view.Window; | ||
| import androidx.core.graphics.Insets; | ||
| import androidx.core.view.ViewCompat; | ||
| import androidx.core.view.WindowCompat; | ||
| import androidx.core.view.WindowInsetsCompat; | ||
| import androidx.core.view.WindowInsetsControllerCompat; | ||
| import androidx.webkit.WebViewCompat; | ||
| import com.getcapacitor.Plugin; | ||
| import com.getcapacitor.PluginCall; | ||
| import com.getcapacitor.PluginMethod; | ||
| import com.getcapacitor.annotation.CapacitorPlugin; | ||
| import java.util.Locale; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
|
|
||
| @CapacitorPlugin | ||
| public class SystemBars extends Plugin { | ||
|
|
||
| static final String STYLE_LIGHT = "LIGHT"; | ||
| static final String STYLE_DARK = "DARK"; | ||
| static final String STYLE_DEFAULT = "DEFAULT"; | ||
| static final String INSET_TOP = "TOP"; | ||
| static final String INSET_BOTTOM = "BOTTOM"; | ||
|
|
||
| static final String viewportMetaJSFunction = | ||
| """ | ||
| function capacitorSystemBarsCheckMetaViewport() { | ||
| const meta = document.querySelectorAll("meta[name=viewport]"); | ||
| if (meta.length == 0) { | ||
| return false; | ||
| } | ||
| // get the last found meta viewport tag | ||
| const metaContent = meta[meta.length - 1].content; | ||
| return metaContent.includes("viewport-fit=cover"); | ||
| } | ||
| capacitorSystemBarsCheckMetaViewport(); | ||
| """; | ||
|
|
||
| @Override | ||
| public void load() { | ||
| super.load(); | ||
| initSystemBars(); | ||
| } | ||
|
|
||
| private boolean hasFixedWebView() { | ||
| PackageInfo packageInfo = WebViewCompat.getCurrentWebViewPackage(bridge.getContext()); | ||
| Pattern pattern = Pattern.compile("(\\d+)"); | ||
| Matcher matcher = pattern.matcher(packageInfo.versionName); | ||
|
|
||
| if (!matcher.find()) { | ||
| return false; | ||
| } | ||
|
|
||
| String majorVersionStr = matcher.group(0); | ||
| int majorVersion = Integer.parseInt(majorVersionStr); | ||
|
|
||
| return majorVersion >= 140; | ||
| } | ||
|
|
||
| private void initSystemBars() { | ||
| String style = getConfig().getString("style", STYLE_DEFAULT).toUpperCase(); | ||
| boolean hidden = getConfig().getBoolean("hidden", false); | ||
|
|
||
| this.bridge.getWebView() | ||
| .evaluateJavascript(viewportMetaJSFunction, res -> { | ||
| boolean hasMetaViewportCover = res.equals("true"); | ||
| setupSafeAreaInsets(this.hasFixedWebView(), hasMetaViewportCover); | ||
| }); | ||
|
|
||
| getBridge() | ||
| .executeOnMainThread(() -> { | ||
| setStyle(style, ""); | ||
| setHidden(hidden, ""); | ||
| }); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void setStyle(final PluginCall call) { | ||
| String inset = call.getString("inset", "").toUpperCase(Locale.US); | ||
| String style = call.getString("style", STYLE_DEFAULT); | ||
|
|
||
| getBridge() | ||
| .executeOnMainThread(() -> { | ||
| setStyle(style, inset); | ||
| call.resolve(); | ||
| }); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void show(final PluginCall call) { | ||
| String inset = call.getString("inset", "").toUpperCase(Locale.US); | ||
|
|
||
| getBridge() | ||
| .executeOnMainThread(() -> { | ||
| setHidden(false, inset); | ||
| call.resolve(); | ||
| }); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void hide(final PluginCall call) { | ||
| String inset = call.getString("inset", "").toUpperCase(Locale.US); | ||
|
|
||
| getBridge() | ||
| .executeOnMainThread(() -> { | ||
| setHidden(true, inset); | ||
| call.resolve(); | ||
| }); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void setAnimation(final PluginCall call) { | ||
| call.resolve(); | ||
| } | ||
|
|
||
| private void setupSafeAreaInsets(boolean hasFixedWebView, boolean hasMetaViewportCover) { | ||
| View decorView = getActivity().getWindow().getDecorView(); | ||
|
|
||
| ViewCompat.setOnApplyWindowInsetsListener(decorView, (v, insets) -> { | ||
| if (hasFixedWebView && hasMetaViewportCover) { | ||
| return insets; | ||
| } | ||
|
|
||
| Insets safeArea = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); | ||
| injectSafeAreaCSS(safeArea.top, safeArea.right, safeArea.bottom, safeArea.left); | ||
|
|
||
| return WindowInsetsCompat.CONSUMED; | ||
| }); | ||
| } | ||
|
|
||
| private void injectSafeAreaCSS(int top, int right, int bottom, int left) { | ||
| // Convert pixels to density-independent pixels | ||
| float density = getActivity().getResources().getDisplayMetrics().density; | ||
| float topPx = top / density; | ||
| float rightPx = right / density; | ||
| float bottomPx = bottom / density; | ||
| float leftPx = left / density; | ||
|
|
||
| // Execute JavaScript to inject the CSS | ||
| getBridge() | ||
| .executeOnMainThread(() -> { | ||
| if (bridge != null && bridge.getWebView() != null) { | ||
| String script = String.format( | ||
| Locale.US, | ||
| """ | ||
| try { | ||
| document.documentElement.style.setProperty("--safe-area-inset-top", "%dpx"); | ||
| document.documentElement.style.setProperty("--safe-area-inset-right", "%dpx"); | ||
| document.documentElement.style.setProperty("--safe-area-inset-bottom", "%dpx"); | ||
| document.documentElement.style.setProperty("--safe-area-inset-left", "%dpx"); | ||
| window.dispatchEvent(new CustomEvent('safeAreaChanged')); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this 'safeAreaChanged' event for? Something specific to Android WebView? |
||
| } catch(e) { console.error('Error injecting safe area CSS:', e); } | ||
| """, | ||
| (int) topPx, | ||
| (int) rightPx, | ||
| (int) bottomPx, | ||
| (int) leftPx | ||
| ); | ||
|
|
||
| bridge.getWebView().evaluateJavascript(script, null); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private void setStyle(String style, String inset) { | ||
| if (style.equals(STYLE_DEFAULT)) { | ||
| style = getStyleForTheme(); | ||
| } | ||
|
|
||
| Window window = getActivity().getWindow(); | ||
| WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView()); | ||
| if (inset.isEmpty() || inset.equals(INSET_TOP)) { | ||
| windowInsetsControllerCompat.setAppearanceLightStatusBars(!style.equals(STYLE_DARK)); | ||
| } | ||
|
|
||
| if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) { | ||
| windowInsetsControllerCompat.setAppearanceLightNavigationBars(!style.equals(STYLE_DARK)); | ||
| } | ||
| } | ||
|
|
||
| private void setHidden(boolean hide, String inset) { | ||
| Window window = getActivity().getWindow(); | ||
| WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView()); | ||
|
|
||
| if (hide) { | ||
| if (inset.isEmpty() || inset.equals(INSET_TOP)) { | ||
| windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.statusBars()); | ||
| } | ||
| if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) { | ||
| windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.navigationBars()); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (inset.isEmpty() || inset.equals(INSET_TOP)) { | ||
| windowInsetsControllerCompat.show(WindowInsetsCompat.Type.systemBars()); | ||
| } | ||
| if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) { | ||
| windowInsetsControllerCompat.show(WindowInsetsCompat.Type.navigationBars()); | ||
| } | ||
| } | ||
|
|
||
| private String getStyleForTheme() { | ||
| int currentNightMode = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; | ||
| if (currentNightMode != Configuration.UI_MODE_NIGHT_YES) { | ||
| return STYLE_LIGHT; | ||
| } | ||
| return STYLE_DARK; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -710,4 +710,31 @@ export interface PluginsConfig { | |
| */ | ||
| enabled?: boolean; | ||
| }; | ||
|
|
||
| /** | ||
| * System Bars plugin configuration | ||
| * | ||
| * @since 8.0.0 | ||
| */ | ||
| SystemBars?: { | ||
| /** | ||
| * The style of the text and icons of the system bars. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing what would be the default value? 'DEFAULT'? |
||
| */ | ||
| style?: string; | ||
|
|
||
| /** | ||
| * Hide the system bars on start. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing what would be the default value? "false"? |
||
| */ | ||
| hidden?: boolean; | ||
|
|
||
| /** | ||
| * The type of status bar animation used when showing or hiding. | ||
| * | ||
| * This option is only supported on iOS. | ||
| * | ||
| * @default 'FADE' | ||
| * | ||
| */ | ||
| animation?: 'FADE' | 'NONE'; | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case we should fallback to logic similar as to what
adjustMarginsForEdgeToEdgedoes.Also injecting the safe area insets as custom vars should be a toggleable option I think. And only respected if
hasMetaViewportCover==trueimo, so it stays consistent with the native inset behaviorThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are referring to adding margins directly to the native view? That was meant to be a quick and dirty fix - we generally try to avoid manipulating the native views style-wise as much as possible to leave the power and final decision making in the hands of the web developer.
Using CSS env vars (albeit a bit convoluted because of the Android bug:
var(--safe-area-inset-top, env(safe-area-inset-top, 0px));) would be the normal way of handling edge to edge behavior, and we want to stick as close to that as possible. Additionally, we have other requirements depending on these injected vars.This is something we can do, I'll work on adding a config option for disabling the inset injections altogether.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that's what I meant. It would align with iOS behavior. If a user does not enable edge-to-edge support through the
viewport-fit=covertag, the webview itself will already add margins for you. Otherwise every capacitor developer would need to add support for env variables. Which would be a major breaking change.Ideally, of course, this is something that the Android team will just natively support out of the box. But iirc a few years/months ago one of the devs mentioned that it's not something they were looking into. I cannot find the comment anymore. I'll link it if I come across it