fix(android): pad content root by WindowInsets.tappableElement so 3-button nav clears UI while gesture mode stays edge-to-edge
This commit is contained in:
parent
53acca3755
commit
61fdf06126
10 changed files with 103 additions and 70 deletions
|
|
@ -1,11 +1,16 @@
|
||||||
package chat.vojo.app;
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.view.View;
|
||||||
import androidx.activity.EdgeToEdge;
|
import androidx.activity.EdgeToEdge;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
import androidx.core.splashscreen.SplashScreen;
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
import androidx.core.view.WindowInsetsControllerCompat;
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
|
@ -86,6 +91,60 @@ public class MainActivity extends BridgeActivity {
|
||||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||||
controller.setAppearanceLightStatusBars(false);
|
controller.setAppearanceLightStatusBars(false);
|
||||||
controller.setAppearanceLightNavigationBars(false);
|
controller.setAppearanceLightNavigationBars(false);
|
||||||
|
|
||||||
|
// 3-button nav clearance. Reads `tappableElement` (= 0 in gesture
|
||||||
|
// mode, = nav-bar height in 3-button mode) and applies it as
|
||||||
|
// padding on the activity's content root — NOT on the WebView
|
||||||
|
// itself. The WebView is a child of the content root, so root
|
||||||
|
// padding shrinks the WebView's layout area; in 3-button mode
|
||||||
|
// the WebView ends above the nav bar and the activity
|
||||||
|
// windowBackground strip behind it paints `splash_bg` (#0d0e11)
|
||||||
|
// which matches the dark body bg, so system icons read as
|
||||||
|
// continuous with the chat surface. Gesture mode stays
|
||||||
|
// edge-to-edge (padding = 0). Left/right are included for
|
||||||
|
// landscape 3-button mode where the nav bar rotates to a side.
|
||||||
|
//
|
||||||
|
// CRITICAL: the listener MUST live on the content root, not on
|
||||||
|
// the WebView. Attaching it to the WebView replaces WebView's
|
||||||
|
// internal `OnApplyWindowInsetsListener` (the one Chromium uses
|
||||||
|
// to feed CSS `env(safe-area-inset-*)`), which silently breaks
|
||||||
|
// `env(safe-area-inset-top)` → `--vojo-safe-top` → every top-
|
||||||
|
// anchored UI clearance for the status bar. Padding the parent
|
||||||
|
// leaves WebView's window-insets pipeline untouched while still
|
||||||
|
// shrinking its visual area.
|
||||||
|
//
|
||||||
|
// Why `tappableElement` and not `systemBars()` / `navigationBars()`:
|
||||||
|
// both report ~24-32 dp in gesture mode too (the pill area),
|
||||||
|
// which would lift UI in fullscreen — exactly the regression
|
||||||
|
// commit 443213b4 had. `tappableElement` is the type defined as
|
||||||
|
// «system UI regions where the user can tap», which means 0 in
|
||||||
|
// gesture mode and the nav-bar in 3-button mode.
|
||||||
|
//
|
||||||
|
// Capacitor 8.3's bundled `SystemBars` plugin attaches its own
|
||||||
|
// inset listener on `webView.getParent()` (CoordinatorLayout one
|
||||||
|
// level deeper). Since `index.html` declares `viewport-fit=cover`,
|
||||||
|
// Capacitor takes the passthrough branch and doesn't pad — no
|
||||||
|
// compound-padding with our listener. If `viewport-fit=cover` is
|
||||||
|
// ever removed, set `plugins.SystemBars.insetsHandling = "disable"`
|
||||||
|
// in `capacitor.config.ts` to avoid double-lift.
|
||||||
|
//
|
||||||
|
// Fallback for API < 29 (Android 7-9): `tappableElement` did
|
||||||
|
// not exist before Q. AndroidX backports the call to API 24+ as
|
||||||
|
// `getSystemWindowInsets()` (≈ `navigationBars()`); the explicit
|
||||||
|
// gate makes the intent visible at the call site.
|
||||||
|
final View contentRoot = findViewById(android.R.id.content);
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(contentRoot, (v, windowInsets) -> {
|
||||||
|
final int typeMask = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
|
? WindowInsetsCompat.Type.tappableElement()
|
||||||
|
: WindowInsetsCompat.Type.navigationBars();
|
||||||
|
Insets ins = windowInsets.getInsets(typeMask);
|
||||||
|
v.setPadding(ins.left, 0, ins.right, ins.bottom);
|
||||||
|
// Do NOT consume — propagate to WebView (CSS env() pipeline)
|
||||||
|
// and Capacitor's SystemBars listener so they still see
|
||||||
|
// unmodified insets.
|
||||||
|
return windowInsets;
|
||||||
|
});
|
||||||
|
ViewCompat.requestApplyInsets(contentRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -238,17 +238,8 @@ export const handleBar = style({
|
||||||
// up over the inline form. The DirectSelfRow ending up immediately
|
// up over the inline form. The DirectSelfRow ending up immediately
|
||||||
// above the keyboard would block the user's view of the form they're
|
// above the keyboard would block the user's view of the form they're
|
||||||
// typing into.
|
// typing into.
|
||||||
//
|
|
||||||
// `paddingBottom: env(safe-area-inset-bottom)` lifts DirectSelfRow /
|
|
||||||
// WorkspaceFooter above Android 3-button nav in edge-to-edge mode.
|
|
||||||
// The inset zone paints in the curtain's `Background.Container` bg
|
|
||||||
// (curtain has `overflow: hidden`), so the system bar reads as a
|
|
||||||
// continuation of the curtain tone — no visible seam. The keyboard
|
|
||||||
// `height: 0; overflow: hidden` collapse above also clips the
|
|
||||||
// padding region — keyboard handling preserved.
|
|
||||||
export const bottomPinnedSlot = style({
|
export const bottomPinnedSlot = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment button (Direct / Channels / Bots).
|
// Segment button (Direct / Channels / Bots).
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,11 @@ export const silhouette = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top-anchored — handle + viewer body reveal top-down as silhouette
|
// Top-anchored — handle + viewer body reveal top-down as silhouette
|
||||||
// grows. `padding-bottom: env(safe-area-inset-bottom)` keeps the
|
// grows. Android nav-bar clearance is owned by `MainActivity.java`'s
|
||||||
// download / share row clear of the Android nav bar.
|
// WindowInsets listener (the WebView is already sized above the nav
|
||||||
|
// bar), so no `padding-bottom: env(...)` here — that would stack on
|
||||||
|
// top of the native padding and lift the download / share row above
|
||||||
|
// its visible host.
|
||||||
export const panelContent = style({
|
export const panelContent = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -65,7 +68,6 @@ export const panelContent = style({
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 20px drag-to-close band. The ONLY drag-to-close origin — touches
|
// 20px drag-to-close band. The ONLY drag-to-close origin — touches
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,6 @@ export const ChatComposer = style({});
|
||||||
// slide/fade transition driven by the `data-hidden` attribute set from
|
// slide/fade transition driven by the `data-hidden` attribute set from
|
||||||
// React state. CSS class (not inline `transition`) so the
|
// React state. CSS class (not inline `transition`) so the
|
||||||
// `prefers-reduced-motion` media query can disable the motion.
|
// `prefers-reduced-motion` media query can disable the motion.
|
||||||
//
|
|
||||||
// `env(safe-area-inset-bottom)` for Android 3-button-nav clearance lives
|
|
||||||
// on the INNER `ChatComposer` div (see `RoomView.tsx`), not here. The
|
|
||||||
// wrapper is the `ResizeObserver` target for `composerHeight`, and
|
|
||||||
// `entry.contentRect` returns content-box (excludes the observed node's
|
|
||||||
// own padding); padding here would inflate border-box without growing
|
|
||||||
// the reported height, letting the last message slip behind the inset.
|
|
||||||
export const ComposerOverlay = style({
|
export const ComposerOverlay = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useEditor } from '../../components/editor';
|
import { useEditor } from '../../components/editor';
|
||||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||||
import { RoomTimeline } from './RoomTimeline';
|
import { RoomTimeline } from './RoomTimeline';
|
||||||
import { RoomViewTyping } from './RoomViewTyping';
|
|
||||||
import { RoomTombstone } from './RoomTombstone';
|
import { RoomTombstone } from './RoomTombstone';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { Page } from '../../components/page';
|
import { Page } from '../../components/page';
|
||||||
|
|
@ -157,7 +156,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||||
bottomOverlayHeight={composerHeight}
|
bottomOverlayHeight={composerHeight}
|
||||||
onComposerHiddenChange={setComposerHidden}
|
onComposerHiddenChange={setComposerHidden}
|
||||||
/>
|
/>
|
||||||
<RoomViewTyping room={room} />
|
|
||||||
</Box>
|
</Box>
|
||||||
{!threadDrawerOpen && (
|
{!threadDrawerOpen && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -174,13 +172,8 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={css.ChatComposer}
|
className={css.ChatComposer}
|
||||||
// `calc(GAP + env(safe-area-inset-bottom))` on the bottom —
|
|
||||||
// see `ComposerOverlay` css for why the inset lives here
|
|
||||||
// and not on the wrapper.
|
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} calc(${toRem(
|
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
|
||||||
VOJO_HORSESHOE_GAP_PX
|
|
||||||
)} + env(safe-area-inset-bottom, 0px))`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tombstoneEvent ? (
|
{tombstoneEvent ? (
|
||||||
|
|
|
||||||
|
|
@ -228,15 +228,9 @@ export const ThreadCounterText = style({
|
||||||
// reapplied to the inner wrap in `ThreadDrawer.tsx` so the same
|
// reapplied to the inner wrap in `ThreadDrawer.tsx` so the same
|
||||||
// `globalStyle` rules (`Surface.Container` bg, 32px radius, dark
|
// `globalStyle` rules (`Surface.Container` bg, 32px radius, dark
|
||||||
// touch-hover gate) reach the Editor inside.
|
// touch-hover gate) reach the Editor inside.
|
||||||
//
|
|
||||||
// Bottom side adds `env(safe-area-inset-bottom)` so the Send button
|
|
||||||
// clears Android 3-button nav in edge-to-edge mode, mirroring the
|
|
||||||
// main `ChatComposer` in `RoomView.tsx`.
|
|
||||||
export const ThreadComposer = style({
|
export const ThreadComposer = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} calc(${toRem(
|
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
|
||||||
VOJO_HORSESHOE_GAP_PX
|
|
||||||
)} + env(safe-area-inset-bottom, 0px))`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bubble chrome itself lives in `Channel.css.ts` and applies via the
|
// Bubble chrome itself lives in `Channel.css.ts` and applies via the
|
||||||
|
|
|
||||||
|
|
@ -117,15 +117,13 @@ export const appBody = style({
|
||||||
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
|
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
|
||||||
// chat-pane tone, same as the Settings PageNav inside (set via
|
// chat-pane tone, same as the Settings PageNav inside (set via
|
||||||
// `surface="surfaceVariant"`). Load-bearing: the silhouette's bg
|
// `surface="surfaceVariant"`). Load-bearing: the silhouette's bg
|
||||||
// shows through every panel gap that Settings's PageNav doesn't
|
// shows through the 20px drag-handle band at the top so the seam
|
||||||
// cover — the 20px drag-handle band at the top and the
|
// matches the PageNav tone. With `Background.Container` (#0d0e11)
|
||||||
// `env(safe-area-inset-bottom)` padding inside `panelContent` at the
|
// here, the user saw a dark stripe at that seam on Samsung S24
|
||||||
// gesture-pill strip. With `Background.Container` (#0d0e11) here, the
|
// edge-to-edge; matching silhouette to the PageNav tone closes it.
|
||||||
// user saw dark stripes at both seams on Samsung S24 edge-to-edge;
|
// Same idea as commit 77bb72d which dynamically retunes
|
||||||
// matching silhouette to the PageNav tone closes them. Same idea as
|
// `--vojo-safe-area-bg` while a Room is mounted to keep the
|
||||||
// commit 77bb72d which dynamically retunes `--vojo-safe-area-bg`
|
// system-bar strips and the chat surface in lockstep.
|
||||||
// while a Room is mounted to keep the system-bar strips and the chat
|
|
||||||
// surface in lockstep.
|
|
||||||
export const silhouette = style({
|
export const silhouette = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -144,21 +142,17 @@ export const silhouette = style({
|
||||||
// are visible at the very first pixel of drag — gives the user
|
// are visible at the very first pixel of drag — gives the user
|
||||||
// immediate "I'm opening Settings" feedback.
|
// immediate "I'm opening Settings" feedback.
|
||||||
//
|
//
|
||||||
// `padding-bottom: env(safe-area-inset-bottom)` keeps the panel
|
// Android nav-bar clearance is owned by `MainActivity.java`'s
|
||||||
// content (Settings menu) clear of the Android nav bar in edge-to-edge
|
// WindowInsets listener (the WebView is already sized above the nav
|
||||||
// mode. `box-sizing: border-box` makes the inline `height: railHeightPx`
|
// bar). No `padding-bottom: env(...)` here — that would stack on top
|
||||||
// include the inset, so the panel's measured height includes the
|
// of the native padding and clip the Settings menu's bottom rows.
|
||||||
// reserved inset and the bottom of the visible content sits above the
|
|
||||||
// system icons.
|
|
||||||
export const panelContent = style({
|
export const panelContent = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
boxSizing: 'border-box',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag handle band — sits at the TOP of `panelContent` so it appears
|
// Drag handle band — sits at the TOP of `panelContent` so it appears
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,17 @@ const slide = keyframes({
|
||||||
|
|
||||||
// Container is taller than the visible bar so the drop-shadow halo isn't
|
// Container is taller than the visible bar so the drop-shadow halo isn't
|
||||||
// clipped by `overflow: hidden`. The bar itself sits flush to the bottom
|
// clipped by `overflow: hidden`. The bar itself sits flush to the bottom
|
||||||
// edge of the safe-area inset (the 26px above is a transparent
|
// edge of the WebView's visible area (the 26px above is a transparent
|
||||||
// pointer-events: none zone where only the glow renders).
|
// pointer-events: none zone where only the glow renders). Bottom inset
|
||||||
|
// is owned by `MainActivity.java`'s WindowInsets listener — the WebView
|
||||||
|
// is already sized to clear the 3-button nav bar, so `bottom: 0` lands
|
||||||
|
// flush above the nav rather than under it. Side insets stay `env(...)`
|
||||||
|
// for display-cutout clearance in landscape.
|
||||||
export const root = style({
|
export const root = style({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: 'env(safe-area-inset-left, 0px)',
|
left: 'env(safe-area-inset-left, 0px)',
|
||||||
right: 'env(safe-area-inset-right, 0px)',
|
right: 'env(safe-area-inset-right, 0px)',
|
||||||
bottom: 'env(safe-area-inset-bottom, 0px)',
|
bottom: 0,
|
||||||
height: '28px',
|
height: '28px',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
// Z400 sits above app chrome and below folds modals/popouts (which use
|
// Z400 sits above app chrome and below folds modals/popouts (which use
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,11 @@ export const silhouette = style({
|
||||||
willChange: 'height, border-top-left-radius, border-top-right-radius',
|
willChange: 'height, border-top-left-radius, border-top-right-radius',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top-anchored panel content. Padding-bottom reserves Android nav-bar
|
// Top-anchored panel content. Android nav-bar clearance is owned by
|
||||||
// inset so the create-space row never tucks under the gesture pill.
|
// `MainActivity.java`'s WindowInsets listener (the WebView is already
|
||||||
|
// sized above the nav bar), so no `padding-bottom: env(...)` here —
|
||||||
|
// that would stack on top of the native padding and clip the
|
||||||
|
// create-space row.
|
||||||
export const panelContent = style({
|
export const panelContent = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -99,7 +102,6 @@ export const panelContent = style({
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 20px drag-to-close band at the top of the silhouette. The ONLY
|
// 20px drag-to-close band at the top of the silhouette. The ONLY
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,20 @@ import { color } from 'folds';
|
||||||
// headers don't double-pad. On web `env()` resolves to `0px`, so the
|
// headers don't double-pad. On web `env()` resolves to `0px`, so the
|
||||||
// var is a no-op there.
|
// var is a no-op there.
|
||||||
//
|
//
|
||||||
// Bottom inset is owned per-component: surfaces that anchor
|
// Bottom inset is owned by `MainActivity.java::onCreate`'s
|
||||||
// interactive content at the screen bottom and need 3-button-nav /
|
// `OnApplyWindowInsetsListener` — it reads
|
||||||
// home-indicator clearance read `env(safe-area-inset-bottom)`
|
// `WindowInsetsCompat.Type.tappableElement()` (= 0 in gesture mode,
|
||||||
// themselves (e.g. `SyncIndicator.css.ts`,
|
// = nav-bar height in 3-button mode) and applies it as bottom
|
||||||
// `StreamHeader.bottomPinnedSlot` for DirectSelfRow / WorkspaceFooter,
|
// padding on the activity's content root (NOT on the WebView; that
|
||||||
// `RoomView.tsx` ChatComposer inner div, the `panelContent` rules in
|
// would replace WebView's internal listener and break
|
||||||
// MobileSettings / ChannelsWorkspace / MobileMediaViewer horseshoes).
|
// `env(safe-area-inset-top)`). The web side stays unaware: in
|
||||||
// The chat overlay wrapper (`ComposerOverlay`) itself stays `bottom: 0`
|
// gesture mode the WebView extends edge-to-edge, in 3-button mode
|
||||||
// flush — the inset lives on the inner card padding so
|
// it is sized above the nav bar and the activity windowBackground
|
||||||
// `ResizeObserver.contentRect` keeps `composerHeight` in sync with
|
// strip below paints the system icons over a tone-matched bg.
|
||||||
// the visible overlay height for `RoomTimeline.bottomOverlayHeight`.
|
// Don't add `padding-bottom: env(safe-area-inset-bottom)` to any
|
||||||
// Bot widgets are responsible for their own gesture-pill clearance
|
// component — Chromium maps that env() to `systemBars()` which is
|
||||||
// (see `BotShell.css.ts::Shell`).
|
// non-zero in gesture mode too, and it stacks on top of the native
|
||||||
|
// padding, double-lifting the UI.
|
||||||
globalStyle(':root', {
|
globalStyle(':root', {
|
||||||
vars: {
|
vars: {
|
||||||
'--vojo-safe-top': 'env(safe-area-inset-top, 0px)',
|
'--vojo-safe-top': 'env(safe-area-inset-top, 0px)',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue