Merge remote-tracking branch 'origin/main' into feat/list-view-sticky-headers

This commit is contained in:
Nathan Walker
2025-11-04 19:14:21 -08:00
51 changed files with 1737 additions and 256 deletions

View File

@@ -7,6 +7,10 @@ import { makeValidator, makeParser } from './validators';
import { CubicBezierAnimationCurve } from './animation-types';
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},${AndroidOverflowMultiple}`;
export type AndroidOverflow = AndroidOverflowSingle | AndroidOverflowStacked;
export type CSSWideKeywords = 'initial' | 'inherit' | 'unset' | 'revert';
/**

View File

Binary file not shown.

View File

@@ -6,11 +6,11 @@ import type { AnimationDefinition, AnimationPromise } from './animation-types';
* Defines a animation set.
*/
export class Animation {
constructor(animationDefinitions: Array<AnimationDefinition>, playSequentially?: boolean);
public play: (resetOnFinish?: boolean) => AnimationPromise;
public cancel: () => void;
public isPlaying: boolean;
public _resolveAnimationCurve(curve: any): any;
constructor(animationDefinitions: Array<AnimationDefinition>, playSequentially?: boolean);
public play: (resetOnFinish?: boolean) => AnimationPromise;
public cancel: () => void;
public isPlaying: boolean;
public _resolveAnimationCurve(curve: any): any;
}
export function _resolveAnimationCurve(curve: any): any;

View File

@@ -1,7 +1,7 @@
import type { Point, Position } from './view-interfaces';
import type { GestureTypes, GestureEventData } from '../../gestures';
import { getNativeScriptGlobals } from '../../../globals/global-utils';
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty, AndroidHelper, statusBarStyleProperty } from './view-common';
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty, AndroidHelper, androidOverflowEdgeProperty, statusBarStyleProperty } from './view-common';
import { paddingLeftProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty } from '../../styling/style-properties';
import { Length } from '../../styling/length-shared';
import { layout } from '../../../utils';
@@ -320,6 +320,121 @@ function getModalOptions(domId: number): DialogOptions {
return modalMap.get(domId);
}
const INSET_LEFT = 0;
const INSET_TOP = 4;
const INSET_RIGHT = 8;
const INSET_BOTTOM = 12;
const INSET_LEFT_CONSUMED = 16;
const INSET_TOP_CONSUMED = 20;
const INSET_RIGHT_CONSUMED = 24;
const INSET_BOTTOM_CONSUMED = 28;
const OverflowEdgeIgnore = -1;
const OverflowEdgeNone: number = 0;
const OverflowEdgeLeft: number = 1 << 1;
const OverflowEdgeTop: number = 1 << 2;
const OverflowEdgeRight: number = 1 << 3;
const OverflowEdgeBottom: number = 1 << 4;
const OverflowEdgeDontApply: number = 1 << 5;
const OverflowEdgeLeftDontConsume: number = 1 << 6;
const OverflowEdgeTopDontConsume: number = 1 << 7;
const OverflowEdgeRightDontConsume: number = 1 << 8;
const OverflowEdgeBottomDontConsume: number = 1 << 9;
const OverflowEdgeAllButLeft: number = 1 << 10;
const OverflowEdgeAllButTop: number = 1 << 11;
const OverflowEdgeAllButRight: number = 1 << 12;
const OverflowEdgeAllButBottom: number = 1 << 13;
class Inset {
private view: DataView;
private data: ArrayBuffer;
constructor(data: java.nio.ByteBuffer) {
this.data = (<any>ArrayBuffer).from(data);
this.view = new DataView(this.data);
}
public get left(): number {
return this.view.getInt32(INSET_LEFT, true);
}
public set left(value: number) {
this.view.setInt32(INSET_LEFT, value, true);
}
public get top(): number {
return this.view.getInt32(INSET_TOP, true);
}
public set top(value: number) {
this.view.setInt32(INSET_TOP, value, true);
}
public get right(): number {
return this.view.getInt32(INSET_RIGHT, true);
}
public set right(value: number) {
this.view.setInt32(INSET_RIGHT, value, true);
}
public get bottom(): number {
return this.view.getInt32(INSET_BOTTOM, true);
}
public set bottom(value: number) {
this.view.setInt32(INSET_BOTTOM, value, true);
}
public get leftConsumed(): boolean {
return this.view.getInt32(INSET_LEFT_CONSUMED, true) > 0;
}
public set leftConsumed(value: boolean) {
this.view.setInt32(INSET_LEFT_CONSUMED, value ? 1 : 0, true);
}
public get topConsumed(): boolean {
return this.view.getInt32(INSET_TOP_CONSUMED, true) > 0;
}
public set topConsumed(value: boolean) {
this.view.setInt32(INSET_TOP_CONSUMED, value ? 1 : 0, true);
}
public get rightConsumed(): boolean {
return this.view.getInt32(INSET_RIGHT_CONSUMED, true) > 0;
}
public set rightConsumed(value: boolean) {
this.view.setInt32(INSET_RIGHT_CONSUMED, value ? 1 : 0, true);
}
public get bottomConsumed(): boolean {
return this.view.getInt32(INSET_BOTTOM_CONSUMED, true) > 0;
}
public set bottomConsumed(value: boolean) {
this.view.setInt32(INSET_BOTTOM_CONSUMED, value ? 1 : 0, true);
}
toString() {
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}`;
}
toJSON() {
return {
left: this.left,
top: this.top,
right: this.right,
bottom: this.bottom,
leftConsumed: this.leftConsumed,
topConsumed: this.topConsumed,
rightConsumed: this.rightConsumed,
bottomConsumed: this.bottomConsumed,
};
}
}
export class View extends ViewCommon {
public static androidBackPressedEvent = androidBackPressedEvent;
@@ -330,6 +445,8 @@ export class View extends ViewCommon {
private layoutChangeListenerIsSet: boolean;
private layoutChangeListener: android.view.View.OnLayoutChangeListener;
private _rootManager: androidx.fragment.app.FragmentManager;
private insetListenerIsSet: boolean;
private needsInsetListener: boolean;
nativeViewProtected: android.view.View;
@@ -348,6 +465,12 @@ export class View extends ViewCommon {
if (this.isLoaded && !this.layoutChangeListenerIsSet && isLayoutEvent) {
this.setOnLayoutChangeListener();
}
const isInsetEvent = typeof eventNames === 'string' ? eventNames.indexOf(ViewCommon.androidOverflowInsetEvent) !== -1 : false;
// only avaiable on LayoutBase
if (!this.insetListenerIsSet && isInsetEvent) {
this.setInsetListener();
}
}
removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any) {
@@ -359,6 +482,43 @@ export class View extends ViewCommon {
this.nativeViewProtected.removeOnLayoutChangeListener(this.layoutChangeListener);
this.layoutChangeListenerIsSet = false;
}
const isInsetEvent = typeof eventNames === 'string' ? eventNames.indexOf(ViewCommon.androidOverflowInsetEvent) !== -1 : false;
if (this.insetListenerIsSet && isInsetEvent && this.nativeViewProtected && (this.nativeViewProtected as any).setInsetListener) {
(this.nativeViewProtected as any).setInsetListener(null);
this.insetListenerIsSet = false;
}
}
private setInsetListener() {
if (this.nativeViewProtected) {
if ((this.nativeViewProtected as any).setInsetListener) {
const ref = new WeakRef(this);
(this.nativeViewProtected as any).setInsetListener(
new org.nativescript.widgets.LayoutBase.WindowInsetListener({
onApplyWindowInsets(param0) {
const owner = ref.get();
if (!owner) {
return;
}
const inset = new Inset(param0);
const args = {
eventName: ViewCommon.androidOverflowInsetEvent,
object: this,
inset,
};
owner.notify(args);
},
}),
);
this.insetListenerIsSet = true;
}
this.needsInsetListener = false;
} else {
this.needsInsetListener = true;
}
}
public _getChildFragmentManager(): androidx.fragment.app.FragmentManager {
@@ -419,6 +579,35 @@ export class View extends ViewCommon {
return manager;
}
[androidOverflowEdgeProperty.setNative](value: CoreTypes.AndroidOverflow) {
const nativeView = this.nativeViewProtected as any;
if (typeof value !== 'string' || nativeView === null || nativeView == undefined) {
return;
}
if (!('setOverflowEdge' in nativeView)) {
return;
}
switch (value) {
case 'none':
nativeView.setOverflowEdge(OverflowEdgeNone);
break;
case 'ignore':
nativeView.setOverflowEdge(OverflowEdgeIgnore);
break;
default:
{
const edge = parseEdges(value);
if (edge != null) {
nativeView.setOverflowEdge(edge);
}
}
break;
}
}
@profile
public onLoaded() {
this._manager = null;
@@ -468,6 +657,10 @@ export class View extends ViewCommon {
if (this.needsOnLayoutChangeListener()) {
this.setOnLayoutChangeListener();
}
if (!this.insetListenerIsSet && this.needsInsetListener) {
this.setInsetListener();
}
}
public needsOnLayoutChangeListener() {
@@ -1356,8 +1549,43 @@ export class View extends ViewCommon {
}
}
const edgeMap: Record<string, number> = {
none: OverflowEdgeNone,
left: OverflowEdgeLeft,
top: OverflowEdgeTop,
right: OverflowEdgeRight,
bottom: OverflowEdgeBottom,
'dont-apply': OverflowEdgeDontApply,
'left-dont-consume': OverflowEdgeLeftDontConsume,
'top-dont-consume': OverflowEdgeTopDontConsume,
'right-dont-consume': OverflowEdgeRightDontConsume,
'bottom-dont-consume': OverflowEdgeBottomDontConsume,
'all-but-left': OverflowEdgeAllButLeft,
'all-but-top': OverflowEdgeAllButTop,
'all-but-right': OverflowEdgeAllButRight,
'all-but-bottom': OverflowEdgeAllButBottom,
};
function parseEdges(edges: string): number | null {
let result = 0;
const values = edges.split(',');
for (const raw of values) {
const value = edgeMap[raw.trim()];
if (value === undefined) continue;
// dont-apply overrides everything else
if (value === OverflowEdgeDontApply) return value;
result |= value;
}
return result === 0 ? null : result;
}
export class ContainerView extends View {
public iosOverflowSafeArea: boolean;
constructor() {
super();
this.androidOverflowEdge = 'none';
}
}
export class CustomLayoutView extends ContainerView {

View File

@@ -92,6 +92,13 @@ export abstract class View extends ViewCommon {
*/
public static accessibilityFocusChangedEvent: string;
/**
* String value used when hooking to androidOverflowInset event.
*
* @nsEvent {EventDataValue} androidOverflowInset
*/
public static androidOverflowInsetEvent: string;
/**
* Gets the android-specific native instance that lies behind this proxy. Will be available if running on an Android platform.
*/
@@ -802,6 +809,11 @@ export abstract class View extends ViewCommon {
*/
on(event: 'shownModally', callback: (args: ShownModallyData) => void, thisArg?: any);
/**
* Raised after the view is shown as a modal dialog.
*/
on(event: 'androidOverflowInset', callback: (args: ShownModallyData) => void, thisArg?: any);
/**
* Returns the current modal view that this page is showing (is parent of), if any.
*/

View File

@@ -80,6 +80,7 @@ export abstract class ViewCommon extends ViewBase {
public static accessibilityFocusEvent = accessibilityFocusEvent;
public static accessibilityFocusChangedEvent = accessibilityFocusChangedEvent;
public static accessibilityPerformEscapeEvent = accessibilityPerformEscapeEvent;
public static androidOverflowInsetEvent = 'androidOverflowInset';
public accessibilityIdentifier: string;
public accessibilityLabel: string;
@@ -994,6 +995,7 @@ export abstract class ViewCommon extends ViewBase {
public iosOverflowSafeArea: boolean;
public iosOverflowSafeAreaEnabled: boolean;
public iosIgnoreSafeArea: boolean;
public androidOverflowEdge: CoreTypes.AndroidOverflow;
get isLayoutValid(): boolean {
return this._isLayoutValid;
@@ -1344,6 +1346,12 @@ export const iosIgnoreSafeAreaProperty = new InheritedProperty({
});
iosIgnoreSafeAreaProperty.register(ViewCommon);
export const androidOverflowEdgeProperty = new Property<ViewCommon, CoreTypes.AndroidOverflow>({
name: 'androidOverflowEdge',
defaultValue: 'ignore',
});
androidOverflowEdgeProperty.register(ViewCommon);
/**
* Glass effects
*/

View File

@@ -59,3 +59,27 @@ export interface Size {
*/
height: number;
}
/**
* Defines the inset values for AndroidOverflowInsetData.
*/
export interface Inset {
top: number;
right: number;
bottom: number;
left: number;
topConsumed: boolean;
rightConsumed: boolean;
bottomConsumed: boolean;
leftConsumed: boolean;
}
/**
* Defines the data for the androidOverflowInset event.
*/
export interface AndroidOverflowInsetData extends EventData {
/**
* The inset values passed to the view to consume or update.
*/
inset?: Inset;
}

View File

@@ -2,7 +2,7 @@ import '../../globals';
import { setActivityCallbacks } from '.';
import { Application } from '../../application';
import { isEmbedded } from '../embedding';
import { enableEdgeToEdge } from '../../utils/native-helper-for-android';
const EMPTY_FN = () => {};
declare const com: any;
@@ -22,6 +22,7 @@ if (!isEmbedded()) {
// Set isNativeScriptActivity in onCreate.
// The JS constructor might not be called because the activity is created from Android.
this.isNativeScriptActivity = true;
enableEdgeToEdge(this);
if (!this._callbacks) {
setActivityCallbacks(this);
}
@@ -78,6 +79,7 @@ if (!isEmbedded()) {
// Set isNativeScriptActivity in onCreate.
// The JS constructor might not be called because the activity is created from Android.
activity.isNativeScriptActivity = true;
enableEdgeToEdge(this);
if (!activity._callbacks) {
setActivityCallbacks(activity);
}

View File

@@ -74,6 +74,8 @@ export class Frame extends FrameBase {
private _containerViewId = -1;
private _tearDownPending = false;
private _attachedToWindow = false;
_defaultOverflowEdge: number;
_defaultOverflowEdgeValue: string;
/**
* This property indicates that the view is to be reused as a root view or has been previously disposed.
*/
@@ -84,6 +86,7 @@ export class Frame extends FrameBase {
constructor() {
super();
this._android = new AndroidFrame(this);
this.androidOverflowEdge = 'ignore';
}
public static reloadPage(context?: ModuleContext): void {

View File

@@ -17,7 +17,7 @@ export { ViewBase, eachDescendant, getAncestor, getViewById, booleanConverter, q
export type { ShowModalOptions } from './core/view-base';
export { View, CSSType, ContainerView, ViewHelper, AndroidHelper, IOSHelper, isUserInteractionEnabledProperty, PseudoClassHandler, CustomLayoutView } from './core/view';
export type { Template, KeyedTemplate, AddArrayFromBuilder, AddChildFromBuilder, GlassEffectConfig, GlassEffectType, GlassEffectVariant } from './core/view';
export type { ShownModallyData, Size } from './core/view/view-interfaces';
export type { ShownModallyData, Size, AndroidOverflowInsetData } from './core/view/view-interfaces';
export { Property, CoercibleProperty, InheritedProperty, CssProperty, InheritedCssProperty, ShorthandProperty, CssAnimationProperty, makeParser, makeValidator } from './core/properties';
export { unsetValue } from './core/properties/property-shared';
export { addWeakEventListener, removeWeakEventListener } from './core/weak-event-listener';

View File

@@ -1,3 +1,4 @@
import { Color } from '../color';
import { numberHasDecimals, numberIs64Bit } from './types';
import { getNativeApp } from '../application/helpers-common';
import { androidGetCurrentActivity } from '../application/helpers';
@@ -294,3 +295,133 @@ export function isRealDevice(): boolean {
return fingerprint != null && (fingerprint.indexOf('vbox') > -1 || fingerprint.indexOf('generic') > -1);
}
const DefaultLightScrim = new Color(0xe6, 0xff, 0xff, 0xff);
const DefaultDarkScrim = new Color(0x80, 0x1b, 0x1b, 0x1b);
const DefaultStatusBarLight = new Color(0);
const DefaultStatusBarDark = new Color(0);
interface ISystemColor {
navigationBarLight: Color;
navigationBarDark: Color;
statusBarLight: Color;
statusBarDark: Color;
handler?: (bar: 'status' | 'navigation', resources: android.content.res.Resources) => boolean;
}
const systemColors = new WeakMap<androidx.appcompat.app.AppCompatActivity, ISystemColor>();
function setEnableEdgeToEdge(activity: androidx.appcompat.app.AppCompatActivity, existingColors: ISystemColor) {
enableEdgeToEdge(activity, {
statusBarLightColor: existingColors.statusBarLight,
statusBarDarkColor: existingColors.statusBarDark,
navigationBarLightColor: existingColors.navigationBarLight,
navigationBarDarkColor: existingColors.navigationBarDark,
handleDarkMode: existingColors?.handler ?? null,
});
}
export function setStatusBarColor(options?: { activity?: androidx.appcompat.app.AppCompatActivity; lightColor?: Color; darkColor?: Color }): void {
const statusBarLightColor = options?.lightColor ?? null;
const statusBarDarkColor = options?.darkColor ?? null;
const activity = options?.activity ?? getCurrentActivity();
if (activity) {
const existingColors = systemColors.get(activity) ?? {
navigationBarLight: DefaultLightScrim,
navigationBarDark: DefaultDarkScrim,
statusBarLight: DefaultStatusBarLight,
statusBarDark: DefaultStatusBarDark,
};
existingColors.statusBarLight ??= statusBarLightColor;
existingColors.statusBarDark ??= statusBarDarkColor;
systemColors.set(getCurrentActivity(), existingColors);
setEnableEdgeToEdge(activity, existingColors);
}
}
export function setNavigationBarColor(options?: { activity?: androidx.appcompat.app.AppCompatActivity; lightColor?: Color; darkColor?: Color }): void {
const navigationBarLightColor = options?.lightColor ?? null;
const navigationBarDarkColor = options?.darkColor ?? null;
const activity = options?.activity ?? getCurrentActivity();
if (activity) {
const existingColors = systemColors.get(activity) ?? {
navigationBarLight: DefaultLightScrim,
navigationBarDark: DefaultDarkScrim,
statusBarLight: DefaultStatusBarLight,
statusBarDark: DefaultStatusBarDark,
};
existingColors.navigationBarLight ??= navigationBarLightColor;
existingColors.navigationBarDark ??= navigationBarDarkColor;
systemColors.set(getCurrentActivity(), existingColors);
setEnableEdgeToEdge(activity, existingColors);
}
}
export function setDarkModeHandler(options?: { activity?: androidx.appcompat.app.AppCompatActivity; handler: (bar: 'status' | 'navigation', resources: android.content.res.Resources) => boolean }): void {
const darkModeHandler = options?.handler ?? null;
const activity = options?.activity ?? getCurrentActivity();
if (activity) {
const existingColors = systemColors.get(activity) ?? {
navigationBarLight: DefaultLightScrim,
navigationBarDark: DefaultDarkScrim,
statusBarLight: DefaultStatusBarLight,
statusBarDark: DefaultStatusBarDark,
};
existingColors.handler ??= darkModeHandler;
systemColors.set(getCurrentActivity(), existingColors);
setEnableEdgeToEdge(activity, existingColors);
}
}
export function enableEdgeToEdge(
activity: androidx.appcompat.app.AppCompatActivity,
options?: {
statusBarLightColor?: Color;
statusBarDarkColor?: Color;
navigationBarLightColor?: Color;
navigationBarDarkColor?: Color;
handleDarkMode?: (bar: 'status' | 'navigation', resources: android.content.res.Resources) => boolean;
},
): void {
let handleDarkMode: org.nativescript.widgets.Utils.HandleDarkMode;
let statusBarLight: number = 0;
let statusBarDark: number = 0;
let navigationBarLight: number = DefaultLightScrim.android;
let navigationBarDark: number = DefaultDarkScrim.android;
if (options) {
if (typeof options.handleDarkMode === 'function') {
handleDarkMode = new org.nativescript.widgets.Utils.HandleDarkMode({
onHandle(bar, resources) {
if (bar === 0) {
return options.handleDarkMode('status', resources);
} else {
return options.handleDarkMode('navigation', resources);
}
},
});
}
if (options.statusBarLightColor instanceof Color) {
statusBarLight = options.statusBarLightColor.android;
}
if (options.statusBarDarkColor instanceof Color) {
statusBarDark = options.statusBarDarkColor.android;
}
if (options.navigationBarLightColor instanceof Color) {
navigationBarLight = options.navigationBarLightColor.android;
}
if (options.navigationBarDarkColor instanceof Color) {
navigationBarDark = options.navigationBarDarkColor.android;
}
}
if (handleDarkMode) {
org.nativescript.widgets.Utils.enableEdgeToEdge(activity, java.lang.Integer.valueOf(statusBarLight), java.lang.Integer.valueOf(statusBarDark), java.lang.Integer.valueOf(navigationBarLight), java.lang.Integer.valueOf(navigationBarDark), handleDarkMode);
} else {
org.nativescript.widgets.Utils.enableEdgeToEdge(activity, java.lang.Integer.valueOf(statusBarLight), java.lang.Integer.valueOf(statusBarDark), java.lang.Integer.valueOf(navigationBarLight), java.lang.Integer.valueOf(navigationBarDark));
}
}