From e6b6429bc502f3d93f2c8b0598d1f12ae04e707c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 18:42:46 -0300 Subject: [PATCH 1/2] fix(core): restore the per-side padding setNative protocol Consolidating padding into paddingInternal (#11216) deleted the [padding*Property.setNative] and [padding*Property.getDefault] handlers from Button, Label, LayoutBase, TextBase, TextField and TextView. The per-property native handlers are the extension surface plugins build on: a subclass that overrides one and chains with super[paddingTopProperty.setNative](value) now throws, and one that overrides to suppress core's padding application is silently bypassed, since padding flows through paddingInternal around it. The handlers are back, restructured so the single-native-write goal of the consolidation still holds: each per-side handler stages its side into a pending struct, and [paddingInternalProperty.setNative] seeds that struct from the current native padding, drives the four per-side handlers - subclass overrides included - and commits one native write. A side whose override does not chain to super keeps its current native value, which is what suppression looked like before. The getDefault handlers return the same values they used to. Standalone invocations of a per-side handler are no-ops: every padding change also updates paddingInternal, which performs the flush. --- packages/core/ui/button/index.android.ts | 61 +++++++++++++-- packages/core/ui/button/index.ios.ts | 75 +++++++++++++++++-- packages/core/ui/label/index.ios.ts | 47 ++++++++++-- .../core/ui/layouts/layout-base.android.ts | 62 +++++++++++++-- .../styling/padding-native-protocol.spec.ts | 36 +++++++++ packages/core/ui/text-base/index.android.ts | 61 +++++++++++++-- packages/core/ui/text-field/index.ios.ts | 34 ++++++++- packages/core/ui/text-view/index.ios.ts | 75 +++++++++++++++++-- 8 files changed, 411 insertions(+), 40 deletions(-) create mode 100644 packages/core/ui/styling/padding-native-protocol.spec.ts diff --git a/packages/core/ui/button/index.android.ts b/packages/core/ui/button/index.android.ts index 1ddb829c8f..745fedc848 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 } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; import { textAlignmentProperty } from '../text-base'; import { CoreTypes } from '../../core-types'; @@ -123,12 +123,61 @@ export class Button extends ButtonBase { return { value: dips, unit: 'px' }; } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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) { + if (this._pendingPadding) { + this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + } + } + [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); + 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..de20c2f097 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 } from '../styling/style-properties'; import { textAlignmentProperty, whiteSpaceProperty, textOverflowProperty } from '../text-base'; import { layout } from '../../utils'; import { CoreTypes } from '../../core-types'; @@ -147,13 +147,74 @@ export class Button extends ButtonBase { }); } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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); + } + } + + [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); + } + } + + [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); + } + } + + [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); + } + } + [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), - }); + 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..b8b54b162e 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 } from '../styling/style-properties'; import { booleanConverter } from '../core/view-base'; import { View, CSSType } from '../core/view'; import { CoreTypes } from '../../core-types'; @@ -228,13 +228,46 @@ export class Label extends TextBase implements LabelDefinition { }); } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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); + } + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = layout.toDeviceIndependentPixels(this.effectivePaddingRight); + } + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = layout.toDeviceIndependentPixels(this.effectivePaddingBottom); + } + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.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), - }); + 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..03b3e504d9 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 } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; +import { CoreTypes } from '../../core-types'; export * from './layout-base-common'; @@ -29,11 +30,60 @@ export class LayoutBase extends LayoutBaseCommon { this.nativeViewProtected.setPassThroughParent(value); } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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) { + if (this._pendingPadding) { + this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + } + } + [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); + 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..af3599f6b1 --- /dev/null +++ b/packages/core/ui/styling/padding-native-protocol.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; + +import { Label } from '../label'; +import { Button } from '../button'; +import { TextField } from '../text-field'; +import { paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } from './style-properties'; +import { CoreTypes } from '../../core-types'; + +const sideProperties = [paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty]; + +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('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(); + expect(() => (label as any)[paddingTopProperty.setNative](5)).not.toThrow(); + expect(label.superCalls).toBe(1); + }); +}); diff --git a/packages/core/ui/text-base/index.android.ts b/packages/core/ui/text-base/index.android.ts index 335c7d726b..86e79d3344 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 } from '../styling/style-properties'; import { Length } from '../styling/length-shared'; import { StrokeCSSValues } from '../styling/css-stroke'; import { FormattedString } from './formatted-string'; @@ -488,12 +488,61 @@ export class TextBase extends TextBaseCommon { ); } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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) { + if (this._pendingPadding) { + this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + } + } + + [paddingRightProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingRight, unit: 'px' }; + } + + [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + } + } + + [paddingBottomProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingBottom, unit: 'px' }; + } + + [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + } + } + + [paddingLeftProperty.getDefault](): CoreTypes.LengthType { + return { value: this._defaultPaddingLeft, unit: 'px' }; + } + + [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + if (this._pendingPadding) { + this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + } + } + [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); + 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..f63731f171 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 } from '../styling/style-properties'; import { layout, isRealDevice } from '../../utils'; import { SDK_VERSION } from '../../utils/constants'; @@ -360,13 +360,74 @@ export class TextView extends TextViewBaseCommon { }); } + // The per-side handlers stage into _pendingPadding, which only exists while + // [paddingInternalProperty.setNative] runs - it drives them so subclass + // overrides participate, then commits all sides in one native write. A side + // whose override does not chain to super keeps its current native value. + 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); + } + } + + [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); + } + } + + [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); + } + } + + [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); + } + } + [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), - }); + 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) { From d333b18213675994c9b9e3ac671184975471f7e5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 18:58:19 -0300 Subject: [PATCH 2/2] fix(core): let per-side padding overrides own the application An override of a per-side [padding*Property.setNative] handler exists to intercept padding application - suppress it, transform the value, or redirect it to a different native mechanism. The consolidated paddingInternal write applied padding around such overrides, so the interception never mattered. When a subclass overrides any of the four handlers (detected once per constructor), the consolidated write now stands down and the property machinery drives the per-side handlers directly, each applying its own side - the pre-consolidation behavior, including not touching the native view at all for a suppressed side. Without overrides the staged single-write path is unchanged. The android handlers also honor the value argument again instead of reading the effective value, so chaining super with a transformed value applies that value - as it did before the consolidation. The ios handlers keep reading effective values, which is what they always did. --- packages/core/ui/button/index.android.ts | 43 +++++++---- packages/core/ui/button/index.ios.ts | 51 +++++++++++-- packages/core/ui/label/index.ios.ts | 51 +++++++++++-- .../core/ui/layouts/layout-base.android.ts | 43 +++++++---- .../styling/padding-native-protocol.spec.ts | 72 ++++++++++++++++++- packages/core/ui/styling/style-properties.ts | 19 +++++ packages/core/ui/text-base/index.android.ts | 43 +++++++---- packages/core/ui/text-view/index.ios.ts | 51 +++++++++++-- 8 files changed, 317 insertions(+), 56 deletions(-) diff --git a/packages/core/ui/button/index.android.ts b/packages/core/ui/button/index.android.ts index 745fedc848..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, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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,19 +123,23 @@ export class Button extends ButtonBase { return { value: dips, unit: 'px' }; } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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) { + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); if (this._pendingPadding) { - this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeViewProtected, padding); } } @@ -143,9 +147,12 @@ export class Button extends ButtonBase { return { value: this._defaultPaddingRight, unit: 'px' }; } - [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); if (this._pendingPadding) { - this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeViewProtected, padding); } } @@ -153,9 +160,12 @@ export class Button extends ButtonBase { return { value: this._defaultPaddingBottom, unit: 'px' }; } - [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); if (this._pendingPadding) { - this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeViewProtected, padding); } } @@ -163,13 +173,20 @@ export class Button extends ButtonBase { return { value: this._defaultPaddingLeft, unit: 'px' }; } - [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); if (this._pendingPadding) { - this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, Button.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeViewProtected, padding); } } [paddingInternalProperty.setNative](_value: string) { + 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); diff --git a/packages/core/ui/button/index.ios.ts b/packages/core/ui/button/index.ios.ts index de20c2f097..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, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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,10 +147,11 @@ export class Button extends ButtonBase { }); } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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 { @@ -163,6 +164,15 @@ export class Button extends ButtonBase { [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, + }); } } @@ -176,6 +186,15 @@ export class Button extends ButtonBase { [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, + }); } } @@ -189,6 +208,15 @@ export class Button extends ButtonBase { [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, + }); } } @@ -202,10 +230,23 @@ export class Button extends ButtonBase { [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) { + 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 }; diff --git a/packages/core/ui/label/index.ios.ts b/packages/core/ui/label/index.ios.ts index b8b54b162e..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, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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,37 +228,78 @@ export class Label extends TextBase implements LabelDefinition { }); } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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.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) { + 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 }; diff --git a/packages/core/ui/layouts/layout-base.android.ts b/packages/core/ui/layouts/layout-base.android.ts index 03b3e504d9..67095df8e2 100644 --- a/packages/core/ui/layouts/layout-base.android.ts +++ b/packages/core/ui/layouts/layout-base.android.ts @@ -1,5 +1,5 @@ import { LayoutBaseCommon, clipToBoundsProperty, isPassThroughParentEnabledProperty } from './layout-base-common'; -import { paddingInternalProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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'; @@ -30,19 +30,23 @@ export class LayoutBase extends LayoutBaseCommon { this.nativeViewProtected.setPassThroughParent(value); } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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) { + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); if (this._pendingPadding) { - this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeViewProtected, padding); } } @@ -50,9 +54,12 @@ export class LayoutBase extends LayoutBaseCommon { return { value: this._defaultPaddingRight, unit: 'px' }; } - [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); if (this._pendingPadding) { - this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeViewProtected, padding); } } @@ -60,9 +67,12 @@ export class LayoutBase extends LayoutBaseCommon { return { value: this._defaultPaddingBottom, unit: 'px' }; } - [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); if (this._pendingPadding) { - this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeViewProtected, padding); } } @@ -70,13 +80,20 @@ export class LayoutBase extends LayoutBaseCommon { return { value: this._defaultPaddingLeft, unit: 'px' }; } - [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); if (this._pendingPadding) { - this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, LayoutBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeViewProtected, padding); } } [paddingInternalProperty.setNative](_value: string) { + 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); diff --git a/packages/core/ui/styling/padding-native-protocol.spec.ts b/packages/core/ui/styling/padding-native-protocol.spec.ts index af3599f6b1..abd5331e11 100644 --- a/packages/core/ui/styling/padding-native-protocol.spec.ts +++ b/packages/core/ui/styling/padding-native-protocol.spec.ts @@ -1,13 +1,38 @@ -import { describe, it, expect } from 'vitest'; +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 } from './style-properties'; +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], @@ -19,6 +44,15 @@ describe('padding setNative protocol', () => { } }); + 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; @@ -30,7 +64,41 @@ describe('padding setNative protocol', () => { } 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 86e79d3344..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, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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,19 +488,23 @@ export class TextBase extends TextBaseCommon { ); } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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) { + [paddingTopProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderTopWidth, 0); if (this._pendingPadding) { - this._pendingPadding.top = this.effectivePaddingTop + Length.toDevicePixels(this.style.borderTopWidth, 0); + this._pendingPadding.top = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingTop(this.nativeTextViewProtected, padding); } } @@ -508,9 +512,12 @@ export class TextBase extends TextBaseCommon { return { value: this._defaultPaddingRight, unit: 'px' }; } - [paddingRightProperty.setNative](_value: CoreTypes.LengthType) { + [paddingRightProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderRightWidth, 0); if (this._pendingPadding) { - this._pendingPadding.right = this.effectivePaddingRight + Length.toDevicePixels(this.style.borderRightWidth, 0); + this._pendingPadding.right = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingRight(this.nativeTextViewProtected, padding); } } @@ -518,9 +525,12 @@ export class TextBase extends TextBaseCommon { return { value: this._defaultPaddingBottom, unit: 'px' }; } - [paddingBottomProperty.setNative](_value: CoreTypes.LengthType) { + [paddingBottomProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderBottomWidth, 0); if (this._pendingPadding) { - this._pendingPadding.bottom = this.effectivePaddingBottom + Length.toDevicePixels(this.style.borderBottomWidth, 0); + this._pendingPadding.bottom = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingBottom(this.nativeTextViewProtected, padding); } } @@ -528,13 +538,20 @@ export class TextBase extends TextBaseCommon { return { value: this._defaultPaddingLeft, unit: 'px' }; } - [paddingLeftProperty.setNative](_value: CoreTypes.LengthType) { + [paddingLeftProperty.setNative](value: CoreTypes.LengthType) { + const padding = Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0); if (this._pendingPadding) { - this._pendingPadding.left = this.effectivePaddingLeft + Length.toDevicePixels(this.style.borderLeftWidth, 0); + this._pendingPadding.left = padding; + } else if (_hasPaddingSetNativeOverrides(this, TextBase.prototype)) { + org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeTextViewProtected, padding); } } [paddingInternalProperty.setNative](_value: string) { + 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); diff --git a/packages/core/ui/text-view/index.ios.ts b/packages/core/ui/text-view/index.ios.ts index f63731f171..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, paddingTopProperty, paddingRightProperty, paddingBottomProperty, paddingLeftProperty } 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,10 +360,11 @@ export class TextView extends TextViewBaseCommon { }); } - // The per-side handlers stage into _pendingPadding, which only exists while - // [paddingInternalProperty.setNative] runs - it drives them so subclass - // overrides participate, then commits all sides in one native write. A side - // whose override does not chain to super keeps its current native 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 { @@ -376,6 +377,15 @@ export class TextView extends TextViewBaseCommon { [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, + }); } } @@ -389,6 +399,15 @@ export class TextView extends TextViewBaseCommon { [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, + }); } } @@ -402,6 +421,15 @@ export class TextView extends TextViewBaseCommon { [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, + }); } } @@ -415,10 +443,23 @@ export class TextView extends TextViewBaseCommon { [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) { + 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 };