FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(android): stabilize edge-to-edge inset distribution · NativeScript/NativeScript@f8a2053 · GitHub

Repository navigation

Commit f8a2053

Browse files
committed
fix(android): stabilize edge-to-edge inset distribution
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.
1 parent 8096d29 commit f8a2053

8 files changed

Lines changed: 201 additions & 69 deletions

File tree

219 Bytes
Binary file not shown.

‎packages/core/ui/core/view/index.android.ts‎

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Point, Position } from './view-interfaces';
22
import type { GestureTypes, GestureEventData } from '../../gestures';
33
import { getNativeScriptGlobals } from '../../../globals/global-utils';
44
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty, AndroidHelper, androidOverflowEdgeProperty, statusBarStyleProperty } from './view-common';
5+
import { OverflowEdgeIgnore, OverflowEdgeNone, parseEdges } from './overflow-edges.android';
56
import { directionProperty } from '../../styling/style-properties';
67
import { layout } from '../../../utils';
78
import { Trace } from '../../../trace';
@@ -396,22 +397,6 @@ const INSET_CUTOUT_TOP_CONSUMED = 60;
396397
const INSET_CUTOUT_RIGHT_CONSUMED = 64;
397398
const INSET_CUTOUT_BOTTOM_CONSUMED = 68;
398399

399-
const OverflowEdgeIgnore = -1;
400-
const OverflowEdgeNone: number = 0;
401-
const OverflowEdgeLeft: number = 1 << 1;
402-
const OverflowEdgeTop: number = 1 << 2;
403-
const OverflowEdgeRight: number = 1 << 3;
404-
const OverflowEdgeBottom: number = 1 << 4;
405-
const OverflowEdgeDontApply: number = 1 << 5;
406-
const OverflowEdgeLeftDontConsume: number = 1 << 6;
407-
const OverflowEdgeTopDontConsume: number = 1 << 7;
408-
const OverflowEdgeRightDontConsume: number = 1 << 8;
409-
const OverflowEdgeBottomDontConsume: number = 1 << 9;
410-
const OverflowEdgeAllButLeft: number = 1 << 10;
411-
const OverflowEdgeAllButTop: number = 1 << 11;
412-
const OverflowEdgeAllButRight: number = 1 << 12;
413-
const OverflowEdgeAllButBottom: number = 1 << 13;
414-
415400
class Inset {
416401
private view: DataView;
417402
private data: ArrayBuffer;
@@ -565,7 +550,14 @@ class Inset {
565550
}
566551

567552
toString() {
568-
return `Inset: left=${this.left}, top=${this.top}, right=${this.right}, bottom=${this.bottom}, ` + `leftConsumed=${this.leftConsumed}, topConsumed=${this.topConsumed}, ` + `rightConsumed=${this.rightConsumed}, bottomConsumed=${this.bottomConsumed}, ` + `cutoutLeft=${this.cutoutLeft}, cutoutTop=${this.cutoutTop}, cutoutRight=${this.cutoutRight}, cutoutBottom=${this.cutoutBottom}, ` + `cutoutLeftConsumed=${this.cutoutLeftConsumed}, cutoutTopConsumed=${this.cutoutTopConsumed}, ` + `cutoutRightConsumed=${this.cutoutRightConsumed}, cutoutBottomConsumed=${this.cutoutBottomConsumed}`;
553+
return (
554+
`Inset: left=${this.left}, top=${this.top}, right=${this.right}, bottom=${this.bottom}, imeBottom=${this.imeBottom}, ` +
555+
`leftConsumed=${this.leftConsumed}, topConsumed=${this.topConsumed}, ` +
556+
`rightConsumed=${this.rightConsumed}, bottomConsumed=${this.bottomConsumed}, imeBottomConsumed=${this.imeBottomConsumed}, ` +
557+
`cutoutLeft=${this.cutoutLeft}, cutoutTop=${this.cutoutTop}, cutoutRight=${this.cutoutRight}, cutoutBottom=${this.cutoutBottom}, ` +
558+
`cutoutLeftConsumed=${this.cutoutLeftConsumed}, cutoutTopConsumed=${this.cutoutTopConsumed}, ` +
559+
`cutoutRightConsumed=${this.cutoutRightConsumed}, cutoutBottomConsumed=${this.cutoutBottomConsumed}`
560+
);
569561
}
570562

571563
toJSON() {
@@ -574,10 +566,12 @@ class Inset {
574566
top: this.top,
575567
right: this.right,
576568
bottom: this.bottom,
569+
imeBottom: this.imeBottom,
577570
leftConsumed: this.leftConsumed,
578571
topConsumed: this.topConsumed,
579572
rightConsumed: this.rightConsumed,
580573
bottomConsumed: this.bottomConsumed,
574+
imeBottomConsumed: this.imeBottomConsumed,
581575
cutoutLeft: this.cutoutLeft,
582576
cutoutTop: this.cutoutTop,
583577
cutoutRight: this.cutoutRight,
@@ -601,7 +595,6 @@ export class View extends ViewCommon {
601595
private layoutChangeListener: android.view.View.OnLayoutChangeListener;
602596
private _rootManager: androidx.fragment.app.FragmentManager;
603597
private insetListenerIsSet: boolean;
604-
private needsInsetListener: boolean;
605598

606599
nativeViewProtected: android.view.View;
607600

@@ -640,9 +633,10 @@ export class View extends ViewCommon {
640633

641634
const isInsetEvent = typeof eventNames === 'string' ? eventNames.indexOf(ViewCommon.androidOverflowInsetEvent) !== -1 : false;
642635

643-
if (this.insetListenerIsSet && isInsetEvent && this.nativeViewProtected && (this.nativeViewProtected as any).setInsetListener) {
644-
(this.nativeViewProtected as any).setInsetListener(null);
645-
this.insetListenerIsSet = false;
636+
// Same rule as the layout-changed listener above: one subscriber leaving must not
637+
// silence the native listener while others are still subscribed.
638+
if (this.insetListenerIsSet && isInsetEvent && !this.needsInsetListener()) {
639+
this.clearInsetListener();
646640
}
647641
}
648642

@@ -670,12 +664,21 @@ export class View extends ViewCommon {
670664
);
671665
this.insetListenerIsSet = true;
672666
}
673-
this.needsInsetListener = false;
674-
} else {
675-
this.needsInsetListener = true;
676667
}
677668
}
678669

670+
private clearInsetListener() {
671+
this.insetListenerIsSet = false;
672+
const nativeView = this.nativeViewProtected as any;
673+
if (nativeView?.setInsetListener) {
674+
nativeView.setInsetListener(null);
675+
}
676+
}
677+
678+
private needsInsetListener() {
679+
return this.hasListeners(ViewCommon.androidOverflowInsetEvent);
680+
}
681+
679682
public _getChildFragmentManager(): androidx.fragment.app.FragmentManager {
680683
return null;
681684
}
@@ -837,7 +840,7 @@ export class View extends ViewCommon {
837840
this.setOnLayoutChangeListener();
838841
}
839842

840-
if (!this.insetListenerIsSet && this.needsInsetListener) {
843+
if (!this.insetListenerIsSet && this.needsInsetListener()) {
841844
this.setInsetListener();
842845
}
843846
}
@@ -860,6 +863,9 @@ export class View extends ViewCommon {
860863
this.layoutChangeListener = null;
861864
}
862865
}
866+
if (this.insetListenerIsSet) {
867+
this.clearInsetListener();
868+
}
863869
super.disposeNativeView();
864870
}
865871

@@ -1773,36 +1779,6 @@ export class View extends ViewCommon {
17731779
}
17741780
}
17751781

1776-
const edgeMap: Record<string, number> = {
1777-
none: OverflowEdgeNone,
1778-
left: OverflowEdgeLeft,
1779-
top: OverflowEdgeTop,
1780-
right: OverflowEdgeRight,
1781-
bottom: OverflowEdgeBottom,
1782-
'dont-apply': OverflowEdgeDontApply,
1783-
'left-dont-consume': OverflowEdgeLeftDontConsume,
1784-
'top-dont-consume': OverflowEdgeTopDontConsume,
1785-
'right-dont-consume': OverflowEdgeRightDontConsume,
1786-
'bottom-dont-consume': OverflowEdgeBottomDontConsume,
1787-
'all-but-left': OverflowEdgeAllButLeft,
1788-
'all-but-top': OverflowEdgeAllButTop,
1789-
'all-but-right': OverflowEdgeAllButRight,
1790-
'all-but-bottom': OverflowEdgeAllButBottom,
1791-
};
1792-
1793-
function parseEdges(edges: string): number | null {
1794-
let result = 0;
1795-
const values = edges.split(',');
1796-
for (const raw of values) {
1797-
const value = edgeMap[raw.trim()];
1798-
if (value === undefined) continue;
1799-
// dont-apply overrides everything else
1800-
if (value === OverflowEdgeDontApply) return value;
1801-
result |= value;
1802-
}
1803-
return result === 0 ? null : result;
1804-
}
1805-
18061782
export class ContainerView extends View {
18071783
public iosOverflowSafeArea: boolean;
18081784

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { OverflowEdgeAllButTop, OverflowEdgeBottom, OverflowEdgeBottomDontConsume, OverflowEdgeDontApply, OverflowEdgeIgnore, OverflowEdgeLeft, OverflowEdgeNone, OverflowEdgeRight, OverflowEdgeTop, parseEdges } from './overflow-edges.android';
4+
5+
describe('parseEdges', () => {
6+
it('maps a single edge to its flag', () => {
7+
expect(parseEdges('top')).toBe(OverflowEdgeTop);
8+
expect(parseEdges('bottom')).toBe(OverflowEdgeBottom);
9+
expect(parseEdges('left')).toBe(OverflowEdgeLeft);
10+
expect(parseEdges('right')).toBe(OverflowEdgeRight);
11+
expect(parseEdges('all-but-top')).toBe(OverflowEdgeAllButTop);
12+
});
13+
14+
it('ORs stacked edges together', () => {
15+
expect(parseEdges('top,bottom')).toBe(OverflowEdgeTop | OverflowEdgeBottom);
16+
expect(parseEdges('left,right,bottom-dont-consume')).toBe(OverflowEdgeLeft | OverflowEdgeRight | OverflowEdgeBottomDontConsume);
17+
});
18+
19+
it('tolerates whitespace around each entry', () => {
20+
expect(parseEdges(' top , bottom ')).toBe(OverflowEdgeTop | OverflowEdgeBottom);
21+
});
22+
23+
it('treats dont-apply and ignore as sentinels rather than flags', () => {
24+
// OR-ing either into a bitmask would corrupt both, so the first one wins outright.
25+
expect(parseEdges('dont-apply')).toBe(OverflowEdgeDontApply);
26+
expect(parseEdges('dont-apply,bottom')).toBe(OverflowEdgeDontApply);
27+
expect(parseEdges('ignore')).toBe(OverflowEdgeIgnore);
28+
expect(parseEdges('ignore,bottom')).toBe(OverflowEdgeIgnore);
29+
expect(parseEdges('bottom,ignore')).toBe(OverflowEdgeIgnore);
30+
});
31+
32+
it('returns none for a value that resolves to none, not null', () => {
33+
// null means "leave the view alone"; a real none has to be applied so the view
34+
// gives back any edges it was handling before.
35+
expect(parseEdges('none')).toBe(OverflowEdgeNone);
36+
expect(parseEdges('none,none')).toBe(OverflowEdgeNone);
37+
});
38+
39+
it('returns null when nothing in the value is recognized', () => {
40+
expect(parseEdges('')).toBeNull();
41+
expect(parseEdges('nope')).toBeNull();
42+
expect(parseEdges('nope,alsonope')).toBeNull();
43+
});
44+
45+
it('ignores unknown entries mixed in with known ones', () => {
46+
expect(parseEdges('nope,top')).toBe(OverflowEdgeTop);
47+
});
48+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Internal helper for the `androidOverflowEdge` property. Kept out of index.android.ts
3+
* so the parsing can be unit tested without pulling the whole view stack in.
4+
*
5+
* The values mirror org.nativescript.widgets.LayoutBase.
6+
*/
7+
8+
export const OverflowEdgeIgnore = -1;
9+
export const OverflowEdgeNone = 0;
10+
export const OverflowEdgeLeft = 1 << 1;
11+
export const OverflowEdgeTop = 1 << 2;
12+
export const OverflowEdgeRight = 1 << 3;
13+
export const OverflowEdgeBottom = 1 << 4;
14+
export const OverflowEdgeDontApply = 1 << 5;
15+
export const OverflowEdgeLeftDontConsume = 1 << 6;
16+
export const OverflowEdgeTopDontConsume = 1 << 7;
17+
export const OverflowEdgeRightDontConsume = 1 << 8;
18+
export const OverflowEdgeBottomDontConsume = 1 << 9;
19+
export const OverflowEdgeAllButLeft = 1 << 10;
20+
export const OverflowEdgeAllButTop = 1 << 11;
21+
export const OverflowEdgeAllButRight = 1 << 12;
22+
export const OverflowEdgeAllButBottom = 1 << 13;
23+
24+
const edgeMap: Record<string, number> = {
25+
none: OverflowEdgeNone,
26+
ignore: OverflowEdgeIgnore,
27+
left: OverflowEdgeLeft,
28+
top: OverflowEdgeTop,
29+
right: OverflowEdgeRight,
30+
bottom: OverflowEdgeBottom,
31+
'dont-apply': OverflowEdgeDontApply,
32+
'left-dont-consume': OverflowEdgeLeftDontConsume,
33+
'top-dont-consume': OverflowEdgeTopDontConsume,
34+
'right-dont-consume': OverflowEdgeRightDontConsume,
35+
'bottom-dont-consume': OverflowEdgeBottomDontConsume,
36+
'all-but-left': OverflowEdgeAllButLeft,
37+
'all-but-top': OverflowEdgeAllButTop,
38+
'all-but-right': OverflowEdgeAllButRight,
39+
'all-but-bottom': OverflowEdgeAllButBottom,
40+
};
41+
42+
/**
43+
* Turns a comma separated `androidOverflowEdge` value into the flags LayoutBase expects.
44+
* Returns null when nothing in the value was recognized, which means "leave the view alone".
45+
*/
46+
export function parseEdges(edges: string): number | null {
47+
let result = 0;
48+
let matched = false;
49+
50+
for (const raw of edges.split(',')) {
51+
const value = edgeMap[raw.trim()];
52+
if (value === undefined) {
53+
continue;
54+
}
55+
56+
matched = true;
57+
// `dont-apply` and `ignore` are sentinels rather than bit flags, so OR-ing them
58+
// with an edge would corrupt both. The first one wins outright.
59+
if (value === OverflowEdgeDontApply || value === OverflowEdgeIgnore) {
60+
return value;
61+
}
62+
63+
result |= value;
64+
}
65+
66+
// A value that legitimately resolves to none (0) still has to be applied, otherwise
67+
// setting the property to e.g. 'none,none' would be a silent no-op that strands the
68+
// view on the edges it had before.
69+
return matched ? result : null;
70+
}

‎packages/core/ui/frame/index.android.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ export class Frame extends FrameBase {
7070
private _containerViewId = -1;
7171
private _tearDownPending = false;
7272
private _attachedToWindow = false;
73-
_defaultOverflowEdge: number;
74-
_defaultOverflowEdgeValue: string;
7573
/**
7674
* This property indicates that the view is to be reused as a root view or has been previously disposed.
7775
*/

‎packages/types-android/src/lib/android/org.nativescript.widgets.d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@
251251
}
252252

253253
export class LayoutBase extends android.view.ViewGroup {
254+
public static OverflowEdgeIgnore: number;
254255
public static OverflowEdgeNone: number;
255256
public static OverflowEdgeLeft: number;
256257
public static OverflowEdgeTop: number;
@@ -268,6 +269,9 @@
268269
constructor(context: android.content.Context);
269270
public getOverflowEdge(): number;
270271
public setOverflowEdge(value: number): void;
272+
public getEdgeInsets(): androidx.core.graphics.Insets;
273+
public getImeInsets(): androidx.core.graphics.Insets;
274+
public setInsetListener(listener: org.nativescript.widgets.LayoutBase.WindowInsetListener): void;
271275
public getPassThroughParent(): boolean;
272276
public setPassThroughParent(value: boolean): void;
273277
}

‎packages/ui-mobile-base/android/widgets/src/main/java/org/nativescript/widgets/LayoutBase.java‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,10 @@ public void setPadding(int left, int top, int right, int bottom) {
232232
appliedLeft += edgeInsets.left;
233233
appliedTop += edgeInsets.top;
234234
appliedRight += edgeInsets.right;
235-
appliedBottom += edgeInsets.bottom;
235+
// The inset pass applies max(navigation bar, ime) at the bottom, so re-adding only
236+
// edgeInsets.bottom here would drop the keyboard gap on any setPadding() while the
237+
// keyboard is open.
238+
appliedBottom += Math.max(edgeInsets.bottom, imeInsets.bottom);
236239
}
237240
super.setPadding(appliedLeft, appliedTop, appliedRight, appliedBottom);
238241
}
@@ -246,13 +249,43 @@ private int getInset(int offset) {
246249
}
247250

248251
private void resetInset() {
249-
getInsetBuffer().position(0);
250-
getInsetBuffer().put(EMPTY_INSETS, 0, EMPTY_INSETS.length);
252+
ByteBuffer buffer = getInsetBuffer();
253+
buffer.position(0);
254+
buffer.put(EMPTY_INSETS, 0, EMPTY_INSETS.length);
255+
buffer.position(0);
256+
}
257+
258+
/**
259+
* Drops the insets this view baked into its padding and restores the padding the
260+
* owner asked for. Used when the view stops taking part in inset distribution, or
261+
* when a pass reports no insets at all.
262+
*/
263+
private void clearEdgeInsets() {
264+
if (edgeInsets == Insets.NONE && imeInsets == Insets.NONE) {
265+
return;
266+
}
267+
268+
edgeInsets = Insets.NONE;
269+
imeInsets = Insets.NONE;
270+
271+
applyingEdges = true;
272+
setPadding(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom);
273+
applyingEdges = false;
251274
}
252275

253276
public void setOverflowEdge(int value) {
277+
int previous = overflowEdge;
254278
overflowEdge = value;
255279

280+
if (value == OverflowEdgeIgnore) {
281+
// The listener stays installed but returns insets untouched from here on, so
282+
// nothing would ever give back the padding an earlier pass applied.
283+
if (previous != OverflowEdgeIgnore) {
284+
clearEdgeInsets();
285+
}
286+
return;
287+
}
288+
256289
if (windowInsetsListener == null) {
257290
windowInsetsListener = new androidx.core.view.OnApplyWindowInsetsListener() {
258291
@NonNull
@@ -261,21 +294,24 @@ public WindowInsetsCompat onApplyWindowInsets(
261294
@NonNull View v,
262295
@NonNull WindowInsetsCompat insets
263296
) {
264-
if (insets.isConsumed() || overflowEdge == OverflowEdgeIgnore) {
297+
if (!(v instanceof LayoutBase)) return insets;
298+
LayoutBase base = (LayoutBase) v;
299+
300+
if (overflowEdge == OverflowEdgeIgnore) {
265301
return insets;
266302
}
267303

268-
if (!(v instanceof LayoutBase)) return insets;
269-
LayoutBase base = (LayoutBase) v;
304+
if (insets.isConsumed()) {
305+
// An ancestor took everything, so there is nothing left to hand out -
306+
// give back the padding an earlier pass applied here.
307+
base.clearEdgeInsets();
308+
return insets;
309+
}
270310

271311
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
272312
Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime());
273313
Insets cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout());
274314

275-
if (systemBars == Insets.NONE && ime == Insets.NONE) {
276-
return WindowInsetsCompat.CONSUMED;
277-
}
278-
279315
int insetLeft = systemBars.left;
280316
int insetRight = systemBars.right;
281317
int insetTop = systemBars.top;

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL