Skip to content

feat(core): css env() safe area insets, smart padding and safe-area-padding - #11449

Open
triniwiz wants to merge 10 commits into
mainfrom
feat/css-env-safe-area
Open

triniwiz wants to merge 10 commits into
mainfrom
feat/css-env-safe-area

Conversation

@triniwiz

@triniwiz triniwiz commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

PR Checklist

Stacked on #11434 (Android edge-to-edge stabilization), which this builds on. The base is that branch so the diff shows only this change; retarget to main once #11434 lands.

What is the current behavior?

env() is not understood anywhere. padding-bottom: env(safe-area-inset-bottom), the most common line of safe-area CSS on the web, reaches Length.parse and the declaration is dropped. Safe-area layout is done with iosOverflowSafeArea / iosIgnoreSafeArea on iOS, androidOverflowEdge on Android, or computed in an androidOverflowInset handler. None of these are in the cascade, and when the framework's inset and the author's padding both apply, the edge is padded twice. calc() and var() written from JS/TS (view.style.width = 'calc(50% - 10dip)') also reach the converter unevaluated.

What is the new behavior?

Implements NativeScript/rfcs#55.

env() follows css-env-1: name, indices, fallback, invalid at computed-value time, and substitution before var() so an unread fallback cannot invalidate a declaration. It works in stylesheets, inline styles, shorthands and JS/TS writes, and values re-evaluate when the insets change.

.footer { padding-bottom: calc(12dip + env(safe-area-inset-bottom)); }
view.style.paddingBottom = 'calc(12dip + env(safe-area-inset-bottom))';

Variables:

  • safe-area-inset-* and safe-area-max-inset-*: window scoped, as in the spec.
  • ns-safe-area-inset-*: what is still left at the view after its ancestors, for nested pages and frames.
  • registerCssEnvironmentVariable lets plugins add their own.

SafeArea (@nativescript/core/safe-area) owns the window insets in dip, with an insetsChanged event. iOS reads the key window and refreshes from viewSafeAreaInsetsDidChange; Android reads the root WindowInsetsCompat (system bars and cutout, no IME) through a non-consuming listener on the decor view.

overflowSafeArea / overflow-safe-area (auto | none | all | <edge>+) is the cross-platform switch that hands edges to the author, the NativeScript form of viewport-fit=cover. auto, the default, keeps each platform's current behavior. androidOverflowEdge wins where both are set, with a trace warning.

Smart padding: a padding-* whose value names its own edge's inset takes that edge over. The framework stops insetting it on that view (iOS zeroes it in getSafeAreaInsets; Android consumes it in the LayoutBase flags without padding), and descendants see it as consumed. The footer above needs no overflow-safe-area. Only the edge's own variable counts, and a local write overrides the cascade per edge.

safe-area-padding takes the same 1 to 4 values as padding and adds the remaining inset to each edge:

.footer { safe-area-padding: 0 16dip 12dip; }
/* padding-bottom: calc(12dip + env(ns-safe-area-inset-bottom)), and so on */

Opt-in consumption tracking (SafeArea.trackConsumption, off by default) lets ns-safe-area-inset-* also use UIKit's own per-view propagation on iOS, which sees the layouts that inset without declaring it.

Verification

  • Unit tests: 41 files, 613 passing. New specs cover env() grammar and substitution, the SafeArea store, the edge parser, the consumption chain, smart padding, and the shorthand (css, js, var(), invalid values, re-evaluation, and a regression test for a re-evaluation loop).
  • tsc --noEmit on packages/core is clean.
  • Toolbox, Edge to Edge > Double padding (self-driving):
    • Android (Pixel 9 Pro emulator, 24dip nav bar): all six cases match. Smart padding on the host gives one inset (24dip), where it gave two before. ns-safe-area-inset-bottom under an insetting host adds 0.
    • iOS (iPhone 17e simulator): smart padding zeroes the padded edge in getSafeAreaInsets. The page's host is a GridLayout, which already drops the bottom inset and doubles its own bottom padding on iOS: a plain 34 padding also gives a 68dip gap. That is pre-existing, listed as unresolved in the RFC, and not addressed here.

Behavior changes

No existing layout moves unless it opts in: env() was a dropped declaration, and overflowSafeArea defaults to auto. The one change on an existing path is that a JS/TS style write containing calc(, var( or env( is now evaluated instead of reaching the converter as raw text.

NathanWalker and others added 10 commits September 3, 2026 10:35
…ange

Core enables edge-to-edge once, in onActivityCreated, with the auto system
bar style. The activity survives a dark-mode switch (uiMode is a handled
configuration change), so the status and navigation bar icons keep the
appearance chosen for the previous theme: dark icons over the app's dark
canvas after switching to night mode.

Add `refreshEdgeToEdge`, which re-applies the activity's styling with any
colors or handler registered through the existing setters, and call it
from the Android application when the system appearance changes.
Five defects in how androidOverflowEdge distributes window insets, all
reproduced on a real Android 16 device (SM-A536E, SDK 36):

- `ignore` was missing from the edge map, so a stacked value like
  `ignore,bottom` silently degraded to plain `bottom`.
- parseEdges returned null for any value resolving to none (`none,none`),
  and null means "leave the view alone" - so the property became a no-op
  that stranded the view on whatever edges it had before.
- A view switched back to `ignore` never gave back the padding it had
  baked in: the listener stays installed but passes insets straight
  through from then on, so nothing undid the earlier pass.
- setPadding re-added only edgeInsets.bottom, while the inset pass had
  applied max(navigation bar, ime). Any setPadding while the keyboard was
  open collapsed the keyboard gap.
- Removing one androidOverflowInset subscriber tore down the native
  listener for all of them, and disposeNativeView left insetListenerIsSet
  true, so a recycled native view never got the listener back and the
  event went quiet after navigating away and returning.

Also drops the shortcut that returned WindowInsetsCompat.CONSUMED when a
pass reported no system bars: it skipped the padding reset and never
notified `dont-apply` listeners when the bars went away.

parseEdges moves to its own internal module so it can be unit tested
without the whole view stack, and org.nativescript.widgets.d.ts picks up
OverflowEdgeIgnore, getEdgeInsets, getImeInsets and setInsetListener.
Seven pages under pages/ete, reachable from the ete/hub entry on the main
page. Each one is readable from a screenshot alone: a shared colour legend
means the ring you see around the content IS the padding the view applied,
so a wrong inset shows up as a wrong-sized band rather than a number
someone has to go looking for.

- edges     walks every androidOverflowEdge value, including the stacked
            `none,none` and `ignore,bottom` regressions
- nested    dont-apply parent whose JS handler rewrites and consumes the
            insets, with a child that pads by what survived
- ime       keyboard insets, and setPadding while the keyboard is open
- lifecycle the ignore reset sequence, and whether the inset event still
            fires after the native view has been recycled
- modals    fullscreen vs floating modal windows
- bars      statusBarStyle, bar colours, and live system theme switches
- scroll    the everyday pattern: list scrolling under the navigation bar

Pages that let the probe overflow keep their controls out of the
navigation bar's reach - a bottom-pinned button there is not tappable.
The bar icon appearance is chosen from the theme when the style is
applied, and the activity survives a dark mode switch, so without this
the bars keep the old theme's icons until the app restarts. Confirmed on
an Android 16 device before merging: a live switch to light mode left the
navigation bar dark-on-light-icons, while a fresh start in light mode got
it right.
Insets came from Type.systemBars() alone, which leaves the display cutout
out. In portrait the two coincide and nobody notices, but rotate the
device and the camera moves to an edge that has no bar - so `none`, which
reads as "keep my content clear of the system UI", let content slide
under it.

`cutout` appends to any existing value (`none,cutout`, `top,cutout`) and
folds displayCutout() into the insets being distributed. It is a modifier
rather than an edge: the bit is masked off before anything asks which
edges were requested, so it composes with the whole vocabulary instead of
multiplying it. Whatever is consumed of the cutout is withheld from
children too, so a nested view that also asked for it does not apply it
twice.

Left opt-in rather than made the default: folding the cutout in
everywhere would silently add padding to landscape layouts that have
already worked around this.

Measured on an Android 16 device in landscape, where systemBars is
0,84,0,42 and the cutout is 88,0,0,0: `none` pads 0,84,0,42 and
`none,cutout` pads 88,84,0,42.
…ange

refreshEdgeToEdge re-styles the activity's window, but a fullscreen modal
has its own dialog window styled separately when it opens. Nothing
re-applied that one, so a modal open across a dark-mode switch kept the
bar icons of whichever theme was active when it was shown - white icons
on a light navigation bar, and the reverse switching back.

The dialog fragment now watches its own configuration changes and
re-applies when the night mode bit actually moves, so the cost is one
comparison per configuration change rather than a restyle on every
rotation.

Verified on an Android 16 device with a fullscreen modal open across a
switch in both directions.
…adding

Adds the css env() function with the css-env-1 safe-area-inset-* and
safe-area-max-inset-* variables on iOS and Android, resolvable from
stylesheets, inline styles and JS/TS writes, and re-evaluated when the
insets change.

- SafeArea (@nativescript/core/safe-area) owns the window insets.
- overflowSafeArea / overflow-safe-area hands edges to the author.
- A padding that names its own edge's inset takes that edge over: the
  framework stops insetting it on the view, and descendants see it as
  consumed.
- env(ns-safe-area-inset-*) and View.getRemainingSafeAreaInsets report
  the inset still left at a view.
- safe-area-padding: padding plus the remaining inset on each edge.
@nx-cloud

nx-cloud Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 5e8a564

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 56s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-24 18:27:02 UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nativescript/core@11449
npm i https://pkg.pr.new/@nativescript/vite@11449
npm i https://pkg.pr.new/@nativescript/webpack@11449

commit: 5e8a564

@farfromrefug

Copy link
Copy Markdown
Collaborator

I will say I dont think this is the right implémentation for safe area.
I can understand wanting to be css compliant but using such an heavy implementation for something so small and easy feels really too much.
Also and most of all it means safe area is not available in js as a number. Means you cant use it in flavor store, you can't do simple math with it. Or, if I am not wrong, you can't even show the value in a label or log the value in the console.

@triniwiz

Copy link
Copy Markdown
Member Author

There is a JS API you can use

import { SafeArea } from '@nativescript/core';

console.log(SafeArea.insets.bottom); // e.g. 34 (dip)

SafeArea.on('insetsChanged', ({ insets }) => {
  // push into a store, recompute, etc.
});

@farfromrefug

Copy link
Copy Markdown
Collaborator

@triniwiz did not see it, missed it!
But i still believe the huge amount of code and complexity for such a simple feature is pretty insane and useless.

view._batchUpdate(() => {
for (const [p, v] of converter(value)) {
this[p.name] = v;
// Invalid at computed-value time unsets every longhand.

@CatchABus CatchABus Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@triniwiz I'm worried with this hardcoded padding handling.
I'd rather for ShorthandProperty class not to be tied to padding or any other property like this and have a general api that "also" let us handle padding instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only added this for sugar it can be removed

Base automatically changed from fix/android-edge-to-edge-stabilization to main October 2, 2026 19:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants