Skip to content

[Android] androidOverflowInset listener is lost after the native view is recreated #11460

Description

@modos189

Issue Description

On Android, once a layout's native view is recreated, its androidOverflowInset handler never fires again. This is a runtime issue, and nothing is logged.

This affects apps that handle insets themselves: a Page or layout with androidOverflowEdge="dont-apply" and an androidOverflowInset handler that applies the insets. After the activity is recreated, for example after switching between gesture and 3-button navigation, the handler is not called anymore and the page keeps the insets it had before. In 3-button mode the bottom inset stays at the gesture bar height, so content ends up under the navigation buttons. It stays that way until the app is restarted.

Expected: the handler fires again for the new native view and receives the current insets.

Cause. View.disposeNativeView() in packages/core/ui/core/view/index.android.ts resets the touch and layout-change listener flags but not insetListenerIsSet. For the new native view, initNativeView() sees insetListenerIsSet === true and needsInsetListener === false, so it skips setInsetListener(). The new LayoutBase keeps insetListener = null, and in the OverflowEdgeDontApply branch nothing calls the handler.

Proposed fix: reset the flag in disposeNativeView() so initNativeView() attaches the listener to the new native view:

--- a/packages/core/ui/core/view/index.android.ts
+++ b/packages/core/ui/core/view/index.android.ts
@@ -860,6 +860,14 @@ export class View extends ViewCommon {
 				this.layoutChangeListener = null;
 			}
 		}
+		if (this.insetListenerIsSet) {
+			// Mark the listener as needed so initNativeView() attaches it to the new native view.
+			this.insetListenerIsSet = false;
+			this.needsInsetListener = true;
+			if (this.nativeViewProtected && (this.nativeViewProtected as any).setInsetListener) {
+				(this.nativeViewProtected as any).setInsetListener(null);
+			}
+		}
 		super.disposeNativeView();
 	}

Alternative, also verified with the same reproduction: attach the listener in initNativeView() when there are subscribers, the same way as the layout-change listener. This makes needsInsetListener unnecessary:

--- a/packages/core/ui/core/view/index.android.ts
+++ b/packages/core/ui/core/view/index.android.ts
@@ -601,7 +601,6 @@ export class View extends ViewCommon {
 	private layoutChangeListener: android.view.View.OnLayoutChangeListener;
 	private _rootManager: androidx.fragment.app.FragmentManager;
 	private insetListenerIsSet: boolean;
-	private needsInsetListener: boolean;
 
 	nativeViewProtected: android.view.View;
 
@@ -670,9 +669,6 @@ export class View extends ViewCommon {
 				);
 				this.insetListenerIsSet = true;
 			}
-			this.needsInsetListener = false;
-		} else {
-			this.needsInsetListener = true;
 		}
 	}
 
@@ -837,7 +833,7 @@ export class View extends ViewCommon {
 			this.setOnLayoutChangeListener();
 		}
 
-		if (!this.insetListenerIsSet && this.needsInsetListener) {
+		if (!this.insetListenerIsSet && this.hasListeners(ViewCommon.androidOverflowInsetEvent)) {
 			this.setInsetListener();
 		}
 	}
@@ -860,6 +856,12 @@ export class View extends ViewCommon {
 				this.layoutChangeListener = null;
 			}
 		}
+		if (this.insetListenerIsSet) {
+			this.insetListenerIsSet = false;
+			if (this.nativeViewProtected && (this.nativeViewProtected as any).setInsetListener) {
+				(this.nativeViewProtected as any).setInsetListener(null);
+			}
+		}
 		super.disposeNativeView();
 	}

Both versions behave the same when the handler is added at runtime and when it is set in XML, before the native view exists. The first one is what I ship in production in my app. I'm happy to use either in the PR.

Verified that #11434 fixes this: with it applied, the same reproduction shows a new event with bottom 144px after switching to 3-button navigation and bottom 72px after switching back.

Reproduction

The apps/toolbox ete page already has manual inset handling, but its handler never updated the padding binding: it assigned this.padding before calling this.set('padding', this.padding), so set() saw no change. The diff below fixes that and shows each androidOverflowInset event under the buttons:

--- a/apps/toolbox/src/pages/ete.ts
+++ b/apps/toolbox/src/pages/ete.ts
@@ -47,6 +47,8 @@ export class ETEData extends Observable {
 	}
 
 	padding = '0';
+	insetEvents = 0;
+	insetInfo = 'androidOverflowInset: no events';
 
 	inset = {
 		top: 0,
@@ -67,8 +69,11 @@ export class ETEData extends Observable {
 		this.inset.left = args.inset.left;
 		this.inset.right = args.inset.right;
 		this.inset.bottom = args.inset.bottom;
-		this.padding = `${this.inset.top}px ${this.inset.right}px ${this.inset.bottom}px ${this.inset.left}px`;
-		this.set('padding', this.padding);
+		this.set('insetEvents', this.insetEvents + 1);
+		this.set('insetInfo', `androidOverflowInset #${this.insetEvents}: top ${this.inset.top}px, bottom ${this.inset.bottom}px`);
+		if (this.overflowManual) {
+			this.set('padding', `${this.inset.top}px ${this.inset.right}px ${this.inset.bottom}px ${this.inset.left}px`);
+		}
 	}
 
 	toggleManualInsets(args) {
@@ -77,7 +82,9 @@ export class ETEData extends Observable {
 		this.overflowManual = !this.overflowManual;
 		page.off(View.androidOverflowInsetEvent, this.onInset, this);
 		page.on(View.androidOverflowInsetEvent, this.onInset, this);
-		this.overflowManual ? (this.padding = '0') : (this.padding = `${this.inset.top}px ${this.inset.right}px ${this.inset.bottom}px ${this.inset.left}px`);
+		if (!this.overflowManual) {
+			this.set('padding', '0');
+		}
 		page.androidOverflowEdge = this.overflowManual ? 'dont-apply' : 'none';
 	}
 }
--- a/apps/toolbox/src/pages/ete.xml
+++ b/apps/toolbox/src/pages/ete.xml
@@ -10,6 +10,7 @@
        <Button tap="{{randomColor}}" text="Update BG to a random color" />
        <Button tap="{{toggleOverflowTopBottom}}" text="Toggle Overflow Top and Bottom" />
        <Button tap="{{toggleManualInsets}}" text="Toggle manual overflow handling" />
+       <Label text="{{ insetInfo }}" textWrap="true" textAlignment="center" padding="16" />
       </StackLayout>
     </ScrollView>
 </Page>
  1. Apply the toolbox diff above and run apps/toolbox on Android with gesture navigation enabled.
  2. Open ete and tap Toggle manual overflow handling. The label shows androidOverflowInset #1: top 156px, bottom 72px.
  3. Switch to 3-button navigation (Settings → System → Navigation mode, or adb shell cmd overlay enable-exclusive --category com.android.internal.systemui.navbar.threebutton). This recreates the activity.
  4. The label still shows #1 … bottom 72px: the handler is not called, although the navigation bar is now taller.
  5. Switch back to gesture navigation (adb shell cmd overlay enable-exclusive --category com.android.internal.systemui.navbar.gestural). The label still shows #1.

With either fix applied, step 4 shows a new event with bottom 144px, and step 5 shows another one with bottom 72px.

Before the fix Image
After the fix Image

Relevant log output (if applicable)

Environment

Reproduced in apps/toolbox on main (a032c22), Android 16 (API 36) emulator, sdk_gphone64_arm64.
Also reproduced in a production app on @nativescript/core 9.0.18 on a physical device.

OS: macOS 15.7.3
CPU: (10) arm64 Apple M1 Pro
Shell: /opt/homebrew/bin/fish
node: 26.10.0
npm: 11.19.1
nativescript: 9.1.1

# android
java: 17.0.20.1
ndk: Not Found
apis: 33, 34, 35, 36, 36
build_tools: 33.0.1, 34.0.0, 35.0.0, 35.0.1, 36.0.0, 36.1.0
system_images:
  - android-35 | Google Play ARM 64 v8a
  - android-36 | Google Play ARM 64 v8a

# ios
xcode: 26.3/17C529
cocoapods: 1.16.2
python: Not Found
python3: 3.9.6
ruby: 3.3.12
platforms:
  - DriverKit 25.2
  - iOS 26.2
  - macOS 26.2
  - tvOS 26.2
  - visionOS 26.2
  - watchOS 26.2

Dependencies

"dependencies": {
  "@valor/nativescript-websockets": "^2.0.3",
  "nativescript-theme-core": "^1.0.4"
},
"devDependencies": {
  "@analogjs/vite-plugin-angular": "2.1.3",
  "@angular/build": "^21.0.0",
  "@angular/compiler-cli": "^21.0.0",
  "@csstools/css-calc": "~2.1.2",
  "@csstools/css-color-parser": "^3.0.8",
  "@csstools/css-parser-algorithms": "^3.0.4",
  "@csstools/css-tokenizer": "^3.0.3",
  "@nativescript/hook": "^3.0.4",
  "@nativescript/nx": "^22.0.0",
  "@nstudio/focus": "^20.0.2",
  "@nstudio/nps-i": "~2.0.0",
  "@nx/devkit": "22.5.4",
  "@nx/eslint-plugin": "22.5.4",
  "@nx/jest": "22.5.4",
  "@nx/js": "22.5.4",
  "@nx/node": "22.5.4",
  "@nx/plugin": "22.5.4",
  "@nx/vite": "22.5.4",
  "@nx/vitest": "22.5.4",
  "@nx/web": "22.5.4",
  "@nx/workspace": "22.5.4",
  "@prettier/plugin-xml": "^3.4.1",
  "@rollup/plugin-alias": "^6.0.0",
  "@rollup/plugin-commonjs": "^29.0.0",
  "@rollup/plugin-replace": "^6.0.3",
  "@swc-node/register": "1.11.1",
  "@swc/core": "1.15.8",
  "@swc/helpers": "0.5.19",
  "@types/jest": "30.0.0",
  "@types/node": "^20.0.0",
  "@types/ws": "^8.18.1",
  "@typescript-eslint/eslint-plugin": "^8.46.4",
  "@typescript-eslint/parser": "^8.46.4",
  "@vitejs/plugin-vue": "^6.0.5",
  "@vitejs/plugin-vue-jsx": "^5.1.5",
  "@vitest/coverage-v8": "4.0.9",
  "@vitest/ui": "4.0.9",
  "@vue/compiler-sfc": "^3.5.24",
  "acorn": "^8.15.0",
  "acorn-stage3": "^4.0.0",
  "copy-webpack-plugin": "^13.0.0",
  "copyfiles": "^2.4.0",
  "css": "^3.0.0",
  "css-tree": "^3.1.0",
  "css-what": "^6.1.0",
  "dotenv": "~16.4.0",
  "dotenv-webpack": "^7.0.0",
  "emoji-regex": "^10.3.0",
  "enhanced-resolve": "^5.18.3",
  "esbuild": "^0.27.4",
  "eslint": "~8.57.0",
  "eslint-config-prettier": "^10.0.0",
  "fork-ts-checker-webpack-plugin": "^7.0.0",
  "form-data": ">=4.0.4",
  "gonzales": "^1.0.7",
  "husky": "^9.0.0",
  "jest": "30.0.5",
  "jest-environment-jsdom": "30.0.5",
  "jest-util": "30.0.5",
  "jiti": "2.4.2",
  "jsdom": "~22.1.0",
  "lint-staged": "^15.2.0",
  "loader-utils": "^2.0.0 || ^3.0.0",
  "module-alias": "^2.2.2",
  "nativescript": "9.1.0-alpha.17",
  "nativescript-typedoc-theme": "1.1.0",
  "nx": "22.5.4",
  "parse-css": "git+https://github.com/tabatkins/parse-css.git",
  "parserlib": "^1.1.1",
  "plist": "^5.0.0",
  "postcss": "^8.0.0",
  "postcss-import": "^16.0.0",
  "postcss-loader": "^8.0.0",
  "prettier": "^3.2.5",
  "react-reconciler": "^0.33.0",
  "sass": "^1.72.0",
  "sass-loader": "^16.0.0",
  "shady-css-parser": "^0.1.0",
  "terser-webpack-plugin": "^5.0.0",
  "tree-kill": "^1.2.2",
  "ts-dedent": "^2.2.0",
  "ts-jest": "29.4.5",
  "ts-loader": "^9.0.0",
  "ts-node": "10.9.2",
  "ts-patch": "^3.0.0",
  "tslib": "^2.6.0",
  "typedoc": "^0.28.14",
  "typescript": "5.9.3",
  "vite": "^8.0.0",
  "vite-plugin-solid": "^2.11.11",
  "vite-plugin-static-copy": "^4.1.1",
  "vitest": "4.0.9",
  "vue-loader": "^15.0.0 <= 15.9.8",
  "vue-tsc": "^3.2.5",
  "webpack-bundle-analyzer": "^4.0.0",
  "webpack-chain": "^6.0.0",
  "webpack-merge": "^6.0.0",
  "webpack-virtual-modules": "^0.4.0",
  "zx": "^8.3.0"
}

Please accept these terms

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug-pending-triageReported bug, pending triage to confirm.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions