Skip to content
Prev Previous commit
Next Next commit
feat(android): add a cutout token to androidOverflowEdge
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.
  • Loading branch information
triniwiz committed Sep 15, 2026
commit 688ca15e8b5ca44055ca3fb39b756c7f2a46e9b8
2 changes: 2 additions & 0 deletions apps/toolbox/src/pages/ete/edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const CASES: Case[] = [
{ edge: 'dont-apply', description: 'hands the insets to JS and pads nothing - expect no green ring' },
{ edge: 'none,none', description: 'a stacked value that resolves to none - must behave exactly like the first case' },
{ edge: 'ignore,bottom', description: 'ignore wins over anything after it - must behave exactly like ignore' },
{ edge: 'none,cutout', description: 'like none, but the display cutout counts too - rotate to landscape, where the camera sits on an edge with no system bar' },
{ edge: 'top,cutout', description: 'overflows the top while still keeping clear of the cutout on the other edges' },
];

class EdgeMatrix extends Observable {
Expand Down
9 changes: 8 additions & 1 deletion packages/core/core-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ export namespace CoreTypes {
type AndroidOverflowSingle = 'ignore' | 'none' | 'dont-apply';
type AndroidOverflowMultiple = 'left' | 'right' | 'top' | 'bottom' | 'left-dont-consume' | 'top-dont-consume' | 'right-dont-consume' | 'bottom-dont-consume' | 'all-but-left' | 'all-but-top' | 'all-but-right' | 'all-but-bottom';
type AndroidOverflowStacked = AndroidOverflowSingle | `${AndroidOverflowSingle},${AndroidOverflowSingle}` | `${AndroidOverflowSingle},${AndroidOverflowMultiple}` | `${AndroidOverflowMultiple},${AndroidOverflowSingle}` | `${AndroidOverflowMultiple},${AndroidOverflowMultiple}`;
export type AndroidOverflow = AndroidOverflowSingle | AndroidOverflowMultiple | AndroidOverflowStacked;
type AndroidOverflowBase = AndroidOverflowSingle | AndroidOverflowMultiple | AndroidOverflowStacked;
/**
* `cutout` is a modifier rather than an edge: append it to any value to fold the
* display cutout into the insets being distributed. Without it only the system bars
* are used, so in landscape a camera on an edge with no bar is not accounted for.
* It has no effect alongside `ignore` or `dont-apply`.
*/
export type AndroidOverflow = AndroidOverflowBase | 'cutout' | `${AndroidOverflowBase},cutout`;
export type CSSWideKeywords = 'initial' | 'inherit' | 'unset' | 'revert';

/**
Expand Down
Binary file modified packages/core/platforms/android/widgets-release.aar
Binary file not shown.
17 changes: 16 additions & 1 deletion packages/core/ui/core/view/overflow-edges.android.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';

import { OverflowEdgeAllButTop, OverflowEdgeBottom, OverflowEdgeBottomDontConsume, OverflowEdgeDontApply, OverflowEdgeIgnore, OverflowEdgeLeft, OverflowEdgeNone, OverflowEdgeRight, OverflowEdgeTop, parseEdges } from './overflow-edges.android';
import { OverflowEdgeAllButTop, OverflowEdgeBottom, OverflowEdgeBottomDontConsume, OverflowEdgeCutout, OverflowEdgeDontApply, OverflowEdgeIgnore, OverflowEdgeLeft, OverflowEdgeNone, OverflowEdgeRight, OverflowEdgeTop, parseEdges } from './overflow-edges.android';

describe('parseEdges', () => {
it('maps a single edge to its flag', () => {
Expand Down Expand Up @@ -45,4 +45,19 @@ describe('parseEdges', () => {
it('ignores unknown entries mixed in with known ones', () => {
expect(parseEdges('nope,top')).toBe(OverflowEdgeTop);
});

it('carries cutout alongside any edge', () => {
// cutout is a modifier, not an edge: it selects the inset source and leaves the
// edge bits to say which sides get padded.
expect(parseEdges('cutout')).toBe(OverflowEdgeCutout);
expect(parseEdges('none,cutout')).toBe(OverflowEdgeCutout);
expect(parseEdges('top,cutout')).toBe(OverflowEdgeTop | OverflowEdgeCutout);
expect(parseEdges('cutout,top,bottom')).toBe(OverflowEdgeCutout | OverflowEdgeTop | OverflowEdgeBottom);
});

it('drops cutout next to a sentinel, which distributes nothing of its own', () => {
expect(parseEdges('dont-apply,cutout')).toBe(OverflowEdgeDontApply);
expect(parseEdges('cutout,dont-apply')).toBe(OverflowEdgeDontApply);
expect(parseEdges('ignore,cutout')).toBe(OverflowEdgeIgnore);
});
});
11 changes: 10 additions & 1 deletion packages/core/ui/core/view/overflow-edges.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export const OverflowEdgeAllButLeft = 1 << 10;
export const OverflowEdgeAllButTop = 1 << 11;
export const OverflowEdgeAllButRight = 1 << 12;
export const OverflowEdgeAllButBottom = 1 << 13;
/**
* Not an edge: a modifier that folds the display cutout into the insets being
* distributed. Type.systemBars() leaves the cutout out, which only shows up once the
* device is rotated and the camera moves to an edge that has no bar.
*/
export const OverflowEdgeCutout = 1 << 14;

const edgeMap: Record<string, number> = {
none: OverflowEdgeNone,
Expand All @@ -37,6 +43,7 @@ const edgeMap: Record<string, number> = {
'all-but-top': OverflowEdgeAllButTop,
'all-but-right': OverflowEdgeAllButRight,
'all-but-bottom': OverflowEdgeAllButBottom,
cutout: OverflowEdgeCutout,
};

/**
Expand All @@ -55,7 +62,9 @@ export function parseEdges(edges: string): number | null {

matched = true;
// `dont-apply` and `ignore` are sentinels rather than bit flags, so OR-ing them
// with an edge would corrupt both. The first one wins outright.
// with an edge would corrupt both. The first one wins outright - which also means
// `cutout` alongside either of them is dropped. `dont-apply` hands the raw cutout
// values to JS regardless, and `ignore` distributes nothing at all.
if (value === OverflowEdgeDontApply || value === OverflowEdgeIgnore) {
return value;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
public static OverflowEdgeAllButTop: number;
public static OverflowEdgeAllButRight: number;
public static OverflowEdgeAllButBottom: number;
public static OverflowEdgeCutout: number;
constructor(context: android.content.Context);
public getOverflowEdge(): number;
public setOverflowEdge(value: number): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ public abstract class LayoutBase extends ViewGroup {
public static final int OverflowEdgeAllButTop = 1 << 11;
public static final int OverflowEdgeAllButRight = 1 << 12;
public static final int OverflowEdgeAllButBottom = 1 << 13;
/**
* Not an edge: a modifier that folds the display cutout into the insets being
* distributed. Type.systemBars() leaves the cutout out, which only shows up once
* the device is rotated and the camera moves to an edge that has no bar.
*/
public static final int OverflowEdgeCutout = 1 << 14;

// Layout (bytes):
// 0 - left inset (int)
Expand Down Expand Up @@ -308,7 +314,17 @@ public WindowInsetsCompat onApplyWindowInsets(
return insets;
}

Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
boolean includeCutout = (overflowEdge & OverflowEdgeCutout) != 0;
// OverflowEdgeCutout is a modifier rather than an edge, so it is masked off
// before anything below asks which edges were requested.
int edges = overflowEdge & ~OverflowEdgeCutout;

int barTypes = WindowInsetsCompat.Type.systemBars();
if (includeCutout) {
barTypes |= WindowInsetsCompat.Type.displayCutout();
}

Insets systemBars = insets.getInsets(barTypes);
Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime());
Insets cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout());

Expand All @@ -318,7 +334,7 @@ public WindowInsetsCompat onApplyWindowInsets(
int insetNavBottom = systemBars.bottom;
int insetImeBottom = ime.bottom;

if (overflowEdge == OverflowEdgeNone) {
if (edges == OverflowEdgeNone) {
int bottom = mPaddingBottom + Math.max(insetNavBottom, insetImeBottom);

base.applyingEdges = true;
Expand All @@ -333,37 +349,42 @@ public WindowInsetsCompat onApplyWindowInsets(
edgeInsets = Insets.of(insetLeft, insetTop, insetRight, insetNavBottom);
imeInsets = Insets.of(0, 0, 0, insetImeBottom);

return new WindowInsetsCompat.Builder(insets)
WindowInsetsCompat.Builder noneBuilder = new WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
.setInsets(WindowInsetsCompat.Type.ime(), Insets.NONE)
.build();
.setInsets(WindowInsetsCompat.Type.ime(), Insets.NONE);

if (includeCutout) {
noneBuilder.setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE);
}

return noneBuilder.build();
}

boolean[] apply = new boolean[4]; // L T R B
boolean[] consume = new boolean[4];
boolean[] defaultConsume = new boolean[4];
defaultConsume[0] = defaultConsume[1] = defaultConsume[2] = defaultConsume[3] = true;

consume[0] = (overflowEdge & OverflowEdgeLeft) != 0;
consume[1] = (overflowEdge & OverflowEdgeTop) != 0;
consume[2] = (overflowEdge & OverflowEdgeRight) != 0;
consume[3] = (overflowEdge & OverflowEdgeBottom) != 0;
consume[0] = (edges & OverflowEdgeLeft) != 0;
consume[1] = (edges & OverflowEdgeTop) != 0;
consume[2] = (edges & OverflowEdgeRight) != 0;
consume[3] = (edges & OverflowEdgeBottom) != 0;

if ((overflowEdge & OverflowEdgeLeftDontConsume) != 0)
if ((edges & OverflowEdgeLeftDontConsume) != 0)
defaultConsume[0] = consume[0] = false;
if ((overflowEdge & OverflowEdgeTopDontConsume) != 0)
if ((edges & OverflowEdgeTopDontConsume) != 0)
defaultConsume[1] = consume[1] = false;
if ((overflowEdge & OverflowEdgeRightDontConsume) != 0)
if ((edges & OverflowEdgeRightDontConsume) != 0)
defaultConsume[2] = consume[2] = false;
if ((overflowEdge & OverflowEdgeBottomDontConsume) != 0)
if ((edges & OverflowEdgeBottomDontConsume) != 0)
defaultConsume[3] = consume[3] = false;

apply[0] = !consume[0];
apply[1] = !consume[1];
apply[2] = !consume[2];
apply[3] = !consume[3];

if ((overflowEdge & OverflowEdgeAllButLeft) != 0) {
if ((edges & OverflowEdgeAllButLeft) != 0) {
for (int i = 0; i < 4; i++) {
consume[i] = true;
apply[i] = false;
Expand All @@ -372,7 +393,7 @@ public WindowInsetsCompat onApplyWindowInsets(
apply[0] = true;
}

if ((overflowEdge & OverflowEdgeAllButTop) != 0) {
if ((edges & OverflowEdgeAllButTop) != 0) {
for (int i = 0; i < 4; i++) {
consume[i] = true;
apply[i] = false;
Expand All @@ -381,7 +402,7 @@ public WindowInsetsCompat onApplyWindowInsets(
apply[1] = true;
}

if ((overflowEdge & OverflowEdgeAllButRight) != 0) {
if ((edges & OverflowEdgeAllButRight) != 0) {
for (int i = 0; i < 4; i++) {
consume[i] = true;
apply[i] = false;
Expand All @@ -390,7 +411,7 @@ public WindowInsetsCompat onApplyWindowInsets(
apply[2] = true;
}

if ((overflowEdge & OverflowEdgeAllButBottom) != 0) {
if ((edges & OverflowEdgeAllButBottom) != 0) {
for (int i = 0; i < 4; i++) {
consume[i] = true;
apply[i] = false;
Expand All @@ -401,7 +422,7 @@ public WindowInsetsCompat onApplyWindowInsets(

boolean consumeIme = consume[3];

if (overflowEdge == OverflowEdgeDontApply) {
if (edges == OverflowEdgeDontApply) {
resetInset();

putInset(BufferOffset.INSET_LEFT, insetLeft);
Expand Down Expand Up @@ -487,10 +508,23 @@ public WindowInsetsCompat onApplyWindowInsets(
consumeIme ? Insets.NONE
: Insets.of(0, 0, 0, insetImeBottom);

return new WindowInsetsCompat.Builder(insets)
WindowInsetsCompat.Builder builder = new WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), remainingSystemBars)
.setInsets(WindowInsetsCompat.Type.ime(), remainingIme)
.build();
.setInsets(WindowInsetsCompat.Type.ime(), remainingIme);

if (includeCutout) {
// The cutout was folded into the values above, so whatever was consumed
// of it is gone for children too - otherwise a nested view that also
// asked for the cutout would apply it a second time.
builder.setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.of(
defaultConsume[0] ? 0 : cutout.left,
defaultConsume[1] ? 0 : cutout.top,
defaultConsume[2] ? 0 : cutout.right,
defaultConsume[3] ? 0 : cutout.bottom
));
}

return builder.build();
}
};

Expand Down
4 changes: 2 additions & 2 deletions packages/ui-mobile-base/native-build-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"artifactHash": "sha256:71ac91f0e4290e8a38d253649a73cdeef87e5bfa10e013ead5f61eecd57aff70"
},
"android": {
"sourceHash": "sha256:f88afcb7563c5519239f0d10d655b3ce399f862efaf4ef82d52b30be9389f527",
"artifactHash": "sha256:0c8c180948f0cb8fbf25f24274466267e328aa62f2273c0df2c25be437d1249d"
"sourceHash": "sha256:205ba541f47e249772e24cf9b38bd1d4552c9fabfcaa089335d03bafb1900e59",
"artifactHash": "sha256:54ab3d40c9cf143717664c627ea810f0e8f3383b96eac2ef578fc3f78301f2d7"
}
}