diff --git a/packages/core/ui/button/index.android.ts b/packages/core/ui/button/index.android.ts index 1ddb829c8f..3425c5a9a3 100644 --- a/packages/core/ui/button/index.android.ts +++ b/packages/core/ui/button/index.android.ts @@ -1,6 +1,6 @@ import { ButtonBase } from './button-common'; import { PseudoClassHandler } from '../core/view'; -import { zIndexProperty, minWidthProperty, minHeightProperty, paddingInternalProperty } from '../styling/style-properties'; +import { zIndexProperty, minWidthProperty, minHeightProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; import { textAlignmentProperty } from '../text-base'; import { CoreTypes } from '../../core-types'; @@ -123,12 +123,78 @@ export class Button extends ButtonBase { return { value: dips, unit: 'px' }; } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingTop, unit: 'px' }; + } + + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeViewProtected, padding); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeViewProtected, padding); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeViewProtected, padding); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeViewProtected, padding); + } + } + [paddingInternalProperty.setNative](_value: string) { - const left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); - const top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); - const right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); - const bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); - this.nativeViewProtected.setPadding(left, top, right, bottom); + if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeViewProtected; + this._pendingPadding = { top: nativeView.getPaddingTop(), right: nativeView.getPaddingRight(), bottom: nativeView.getPaddingBottom(), left: nativeView.getPaddingLeft() }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.setPadding(this._pendingPadding.left, this._pendingPadding.top, this._pendingPadding.right, this._pendingPadding.bottom); + this._pendingPadding = null; } [zIndexProperty.setNative](value: number) { diff --git a/packages/core/ui/button/index.ios.ts b/packages/core/ui/button/index.ios.ts index 1f4164f805..4f2d32f91c 100644 --- a/packages/core/ui/button/index.ios.ts +++ b/packages/core/ui/button/index.ios.ts @@ -1,7 +1,7 @@ import { ControlStateChangeListener } from '../core/control-state-change'; import { ButtonBase } from './button-common'; import { View, PseudoClassHandler } from '../core/view'; -import { borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty } from '../styling/style-properties'; +import { borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { textAlignmentProperty, whiteSpaceProperty, textOverflowProperty } from '../text-base'; import { layout } from '../../utils'; import { CoreTypes } from '../../core-types'; @@ -147,13 +147,115 @@ export class Button extends ButtonBase { }); } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeViewProtected.contentEdgeInsets.top, + unit: 'px', + }; + } + + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.top = layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth); + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + const nativeView = this.nativeViewProtected; + const inset = nativeView.contentEdgeInsets; + nativeView.contentEdgeInsets = new UIEdgeInsets({ + top: layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth), + right: inset.right, + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeViewProtected.contentEdgeInsets.right, + unit: 'px', + }; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth); + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + const nativeView = this.nativeViewProtected; + const inset = nativeView.contentEdgeInsets; + nativeView.contentEdgeInsets = new UIEdgeInsets({ + top: inset.top, + right: layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth), + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeViewProtected.contentEdgeInsets.bottom, + unit: 'px', + }; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth); + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + const nativeView = this.nativeViewProtected; + const inset = nativeView.contentEdgeInsets; + nativeView.contentEdgeInsets = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth), + left: inset.left, + }); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeViewProtected.contentEdgeInsets.left, + unit: 'px', + }; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth); + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + const nativeView = this.nativeViewProtected; + const inset = nativeView.contentEdgeInsets; + nativeView.contentEdgeInsets = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: inset.bottom, + left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth), + }); + } + } + [paddingInternalProperty.setNative](_value: string) { - this.nativeViewProtected.contentEdgeInsets = new UIEdgeInsets({ - top: layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth), - left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth), - bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth), - right: layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth), - }); + if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeViewProtected; + const inset = nativeView.contentEdgeInsets; + this._pendingPadding = { top: inset.top, right: inset.right, bottom: inset.bottom, left: inset.left }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.contentEdgeInsets = new UIEdgeInsets(this._pendingPadding); + this._pendingPadding = null; } [textAlignmentProperty.setNative](value: CoreTypes.TextAlignmentType) { diff --git a/packages/core/ui/label/index.ios.ts b/packages/core/ui/label/index.ios.ts index 86e948b957..e4656c3199 100644 --- a/packages/core/ui/label/index.ios.ts +++ b/packages/core/ui/label/index.ios.ts @@ -1,6 +1,6 @@ import { Label as LabelDefinition } from '.'; import { Background } from '../styling/background'; -import { borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty } from '../styling/style-properties'; +import { borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { booleanConverter } from '../core/view-base'; import { View, CSSType } from '../core/view'; import { CoreTypes } from '../../core-types'; @@ -228,13 +228,87 @@ export class Label extends TextBase implements LabelDefinition { }); } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.top = layout.toDeviceIndependentPixels(this.effectivePaddingTop); + } else if (_hasPaddingSetNativeOverrides(this, Label.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.padding; + nativeView.padding = new UIEdgeInsets({ + top: layout.toDeviceIndependentPixels(this.effectivePaddingTop), + right: inset.right, + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = layout.toDeviceIndependentPixels(this.effectivePaddingRight); + } else if (_hasPaddingSetNativeOverrides(this, Label.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.padding; + nativeView.padding = new UIEdgeInsets({ + top: inset.top, + right: layout.toDeviceIndependentPixels(this.effectivePaddingRight), + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = layout.toDeviceIndependentPixels(this.effectivePaddingBottom); + } else if (_hasPaddingSetNativeOverrides(this, Label.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.padding; + nativeView.padding = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom), + left: inset.left, + }); + } + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = layout.toDeviceIndependentPixels(this.effectivePaddingLeft); + } else if (_hasPaddingSetNativeOverrides(this, Label.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.padding; + nativeView.padding = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: inset.bottom, + left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft), + }); + } + } + [paddingInternalProperty.setNative](_value: string) { - this.nativeTextViewProtected.padding = new UIEdgeInsets({ - top: layout.toDeviceIndependentPixels(this.effectivePaddingTop), - right: layout.toDeviceIndependentPixels(this.effectivePaddingRight), - bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom), - left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft), - }); + if (_hasPaddingSetNativeOverrides(this, Label.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeTextViewProtected; + const padding = nativeView.padding; + this._pendingPadding = { top: padding.top, right: padding.right, bottom: padding.bottom, left: padding.left }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.padding = new UIEdgeInsets(this._pendingPadding); + this._pendingPadding = null; } } diff --git a/packages/core/ui/layouts/layout-base.android.ts b/packages/core/ui/layouts/layout-base.android.ts index 49aad13037..67095df8e2 100644 --- a/packages/core/ui/layouts/layout-base.android.ts +++ b/packages/core/ui/layouts/layout-base.android.ts @@ -1,6 +1,7 @@ import { LayoutBaseCommon, clipToBoundsProperty, isPassThroughParentEnabledProperty } from './layout-base-common'; -import { paddingInternalProperty } from '../styling/style-properties'; +import { paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; +import { CoreTypes } from '../../core-types'; export * from './layout-base-common'; @@ -29,11 +30,77 @@ export class LayoutBase extends LayoutBaseCommon { this.nativeViewProtected.setPassThroughParent(value); } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingTop, unit: 'px' }; + } + + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeViewProtected, padding); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeViewProtected, padding); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeViewProtected, padding); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeViewProtected, padding); + } + } + [paddingInternalProperty.setNative](_value: string) { - const left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); - const top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); - const right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); - const bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); - this.nativeViewProtected.setPadding(left, top, right, bottom); + if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeViewProtected; + this._pendingPadding = { top: nativeView.getPaddingTop(), right: nativeView.getPaddingRight(), bottom: nativeView.getPaddingBottom(), left: nativeView.getPaddingLeft() }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.setPadding(this._pendingPadding.left, this._pendingPadding.top, this._pendingPadding.right, this._pendingPadding.bottom); + this._pendingPadding = null; } } diff --git a/packages/core/ui/styling/padding-native-protocol.spec.ts b/packages/core/ui/styling/padding-native-protocol.spec.ts new file mode 100644 index 0000000000..abd5331e11 --- /dev/null +++ b/packages/core/ui/styling/padding-native-protocol.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +import { Label } from '../label'; +import { Button } from '../button'; +import { TextField } from '../text-field'; +import { paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, paddingInternalProperty, _hasPaddingSetNativeOverrides } from './style-properties'; +import { CoreTypes } from '../../core-types'; + +const sideProperties = [paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty]; + +// The native view stub records writes to `padding`, the way TNSLabel receives them. +function stubNativeView(view: any) { + const writes: any[] = []; + const nativeView = { + padding: { top: 1, right: 2, bottom: 3, left: 4 }, + }; + Object.defineProperty(nativeView, 'padding', { + get: () => ({ top: 1, right: 2, bottom: 3, left: 4 }), + set: (value) => writes.push(value), + }); + Object.defineProperty(view, 'nativeTextViewProtected', { value: nativeView, configurable: true }); + + return writes; +} + +beforeAll(() => { + (globalThis as any).UIEdgeInsets = + (globalThis as any).UIEdgeInsets ?? + class { + constructor(value: any) { + Object.assign(this, value); + } + }; +}); + +describe('padding setNative protocol', () => { + it.each([ + ['Label', Label], + ['Button', Button], + ['TextField', TextField], + ])('%s defines a handler for every padding side', (_name, cls: any) => { + for (const property of sideProperties) { + expect(typeof cls.prototype[property.setNative]).toBe('function'); + } + }); + + it('detects per-side overrides on the subclass, not the core class', () => { + class PluginLabel extends Label { + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) {} + } + + expect(_hasPaddingSetNativeOverrides(new Label(), Label.prototype)).toBe(false); + expect(_hasPaddingSetNativeOverrides(new PluginLabel(), Label.prototype)).toBe(true); + }); + + it('a subclass handler can chain to super, like plugins do', () => { + class PluginLabel extends Label { + superCalls = 0; + + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + this.superCalls++; + super[paddingTopProperty.setNative](value); + } + } + + const label = new PluginLabel(); + const writes = stubNativeView(label); + + expect(() => (label as any)[paddingTopProperty.setNative](5)).not.toThrow(); + expect(label.superCalls).toBe(1); + // The chained base handler applies the side itself, keeping the others intact. + expect(writes).toHaveLength(1); + expect(writes[0]).toMatchObject({ right: 2, bottom: 3, left: 4 }); + }); + + it('the consolidated write stands down when a side is overridden', () => { + class PluginLabel extends Label { + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) { + // suppress + } + } + + const label = new PluginLabel(); + const writes = stubNativeView(label); + + (label as any)[paddingInternalProperty.setNative](''); + expect(writes).toHaveLength(0); + + (label as any)[paddingTopProperty.setNative](5); + expect(writes).toHaveLength(0); + }); + + it('the consolidated write applies once when nothing is overridden', () => { + const label = new Label(); + const writes = stubNativeView(label); + + (label as any)[paddingInternalProperty.setNative](''); + expect(writes).toHaveLength(1); + + // Standalone per-side invocations rely on the consolidated flush. + (label as any)[paddingTopProperty.setNative](5); + expect(writes).toHaveLength(1); + }); +}); diff --git a/packages/core/ui/styling/style-properties.ts b/packages/core/ui/styling/style-properties.ts index 62aeebc77a..112a68b600 100644 --- a/packages/core/ui/styling/style-properties.ts +++ b/packages/core/ui/styling/style-properties.ts @@ -410,6 +410,25 @@ export const paddingInternalProperty = new CssProperty({ }); paddingInternalProperty.register(Style); +const paddingSetNativeOverrides = new WeakMap(); + +/** + * Whether a subclass overrides any of the per-side [padding*Property.setNative] + * handlers `coreProto` defines. An override owns padding application - the + * consolidated paddingInternal write must stand down, or it would apply padding + * around handlers designed to intercept it. + */ +export function _hasPaddingSetNativeOverrides(view: unknown, coreProto: object): boolean { + const constructor = (view as object).constructor; + let overrides = paddingSetNativeOverrides.get(constructor); + if (overrides === undefined) { + overrides = [paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty].some((property) => view[property.setNative] !== coreProto[property.setNative]); + paddingSetNativeOverrides.set(constructor, overrides); + } + + return overrides; +} + const paddingProperty = new ShorthandProperty({ name: 'padding', cssName: 'padding', diff --git a/packages/core/ui/text-base/index.android.ts b/packages/core/ui/text-base/index.android.ts index 335c7d726b..d04e50f184 100644 --- a/packages/core/ui/text-base/index.android.ts +++ b/packages/core/ui/text-base/index.android.ts @@ -3,7 +3,7 @@ import { ShadowCSSValues } from '../styling/css-shadow'; import { Font } from '../styling/font'; import { TextBaseCommon, formattedTextProperty, textAlignmentProperty, textDecorationProperty, textProperty, textTransformProperty, textShadowProperty, textStrokeProperty, letterSpacingProperty, whiteSpaceProperty, lineHeightProperty, resetSymbol } from './text-base-common'; import { Color } from '../../color'; -import { colorProperty, fontSizeProperty, fontInternalProperty, directionProperty, paddingInternalProperty } from '../styling/style-properties'; +import { colorProperty, fontSizeProperty, fontInternalProperty, directionProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; import { StrokeCSSValues } from '../styling/css-stroke'; import { FormattedString } from './formatted-string'; @@ -488,12 +488,78 @@ export class TextBase extends TextBaseCommon { ); } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingTop, unit: 'px' }; + } + + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeTextViewProtected, padding); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeTextViewProtected, padding); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeTextViewProtected, padding); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); + if (this._pendingPadding) { + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeTextViewProtected, padding); + } + } + [paddingInternalProperty.setNative](_value: string) { - const left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); - const top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); - const right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); - const bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); - this.nativeTextViewProtected.setPadding(left, top, right, bottom); + if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeTextViewProtected; + this._pendingPadding = { top: nativeView.getPaddingTop(), right: nativeView.getPaddingRight(), bottom: nativeView.getPaddingBottom(), left: nativeView.getPaddingLeft() }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.setPadding(this._pendingPadding.left, this._pendingPadding.top, this._pendingPadding.right, this._pendingPadding.bottom); + this._pendingPadding = null; } [lineHeightProperty.getDefault](): number { diff --git a/packages/core/ui/text-field/index.ios.ts b/packages/core/ui/text-field/index.ios.ts index 8e9372ce47..5e5aa1a0b9 100644 --- a/packages/core/ui/text-field/index.ios.ts +++ b/packages/core/ui/text-field/index.ios.ts @@ -3,7 +3,7 @@ import { textOverflowProperty, textProperty, whiteSpaceProperty } from '../text- import { hintProperty, placeholderColorProperty, _updateCharactersInRangeReplacementString } from '../editable-text-base'; import { CoreTypes } from '../../core-types'; import { Color } from '../../color'; -import { colorProperty, directionProperty, paddingInternalProperty } from '../styling/style-properties'; +import { colorProperty, directionProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } from '../styling/style-properties'; import { layout, isEmoji } from '../../utils'; export * from './text-field-common'; @@ -331,6 +331,38 @@ export class TextField extends TextFieldBase { this.nativeTextViewProtected.attributedPlaceholder = attributedPlaceholder; } + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return CoreTypes.zeroLength; + } + + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) { + // Padding is realized via UITextFieldImpl.textRectForBounds method + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return CoreTypes.zeroLength; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + // Padding is realized via UITextFieldImpl.textRectForBounds method + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return CoreTypes.zeroLength; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + // Padding is realized via UITextFieldImpl.textRectForBounds method + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return CoreTypes.zeroLength; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + // Padding is realized via UITextFieldImpl.textRectForBounds method + } + [paddingInternalProperty.setNative](_value: string) { // Padding is realized via UITextFieldImpl.textRectForBounds method } diff --git a/packages/core/ui/text-view/index.ios.ts b/packages/core/ui/text-view/index.ios.ts index 28c13223a3..b23e7a4245 100644 --- a/packages/core/ui/text-view/index.ios.ts +++ b/packages/core/ui/text-view/index.ios.ts @@ -5,7 +5,7 @@ import { editableProperty, hintProperty, placeholderColorProperty, _updateCharac import { CoreTypes } from '../../core-types'; import { CSSType } from '../core/view'; import { Color } from '../../color'; -import { colorProperty, borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty } from '../styling/style-properties'; +import { colorProperty, borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProperty, borderLeftWidthProperty, directionProperty, paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty, _hasPaddingSetNativeOverrides } from '../styling/style-properties'; import { layout, isRealDevice } from '../../utils'; import { SDK_VERSION } from '../../utils/constants'; @@ -360,13 +360,115 @@ export class TextView extends TextViewBaseCommon { }); } + // When no subclass overrides the per-side handlers, they stage into + // _pendingPadding - which only exists while [paddingInternalProperty.setNative] + // runs - and all sides commit in one native write. An override takes ownership: + // the consolidated write stands down and each side applies individually, so an + // override that does not chain to super suppresses that side entirely. + private _pendingPadding: { top: number; right: number; bottom: number; left: number }; + + [paddingTopProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeTextViewProtected.textContainerInset.top, + unit: 'px', + }; + } + + [paddingTopProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.top = layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth); + } else if (_hasPaddingSetNativeOverrides(this, TextView.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.textContainerInset; + nativeView.textContainerInset = new UIEdgeInsets({ + top: layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth), + right: inset.right, + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeTextViewProtected.textContainerInset.right, + unit: 'px', + }; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth); + } else if (_hasPaddingSetNativeOverrides(this, TextView.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.textContainerInset; + nativeView.textContainerInset = new UIEdgeInsets({ + top: inset.top, + right: layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth), + bottom: inset.bottom, + left: inset.left, + }); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeTextViewProtected.textContainerInset.bottom, + unit: 'px', + }; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth); + } else if (_hasPaddingSetNativeOverrides(this, TextView.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.textContainerInset; + nativeView.textContainerInset = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth), + left: inset.left, + }); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { + value: this.nativeTextViewProtected.textContainerInset.left, + unit: 'px', + }; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth); + } else if (_hasPaddingSetNativeOverrides(this, TextView.prototype)) { + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.textContainerInset; + nativeView.textContainerInset = new UIEdgeInsets({ + top: inset.top, + right: inset.right, + bottom: inset.bottom, + left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth), + }); + } + } + [paddingInternalProperty.setNative](_value: string) { - this.nativeTextViewProtected.textContainerInset = new UIEdgeInsets({ - top: layout.toDeviceIndependentPixels(this.effectivePaddingTop + this.effectiveBorderTopWidth), - right: layout.toDeviceIndependentPixels(this.effectivePaddingRight + this.effectiveBorderRightWidth), - bottom: layout.toDeviceIndependentPixels(this.effectivePaddingBottom + this.effectiveBorderBottomWidth), - left: layout.toDeviceIndependentPixels(this.effectivePaddingLeft + this.effectiveBorderLeftWidth), - }); + if (_hasPaddingSetNativeOverrides(this, TextView.prototype)) { + // An override owns padding application; each side applies through its own handler. + return; + } + const nativeView = this.nativeTextViewProtected; + const inset = nativeView.textContainerInset; + this._pendingPadding = { top: inset.top, right: inset.right, bottom: inset.bottom, left: inset.left }; + (this)[paddingTopProperty.setNative](this.style.paddingTop); + (this)[paddingRightProperty.setNative](this.style.paddingRight); + (this)[paddingBottomProperty.setNative](this.style.paddingBottom); + (this)[paddingLeftProperty.setNative](this.style.paddingLeft); + nativeView.textContainerInset = new UIEdgeInsets(this._pendingPadding); + this._pendingPadding = null; } [iosWritingToolsBehaviorProperty.setNative](value: WritingToolsBehavior) {