Visitar URL original
fix(core): resolve percentage translate transforms against element size by aleclarson · Pull Request #11481 · NativeScript/NativeScript · GitHub
Skip to content

fix(core): resolve percentage translate transforms against element size - #11481

Open
aleclarson wants to merge 2 commits into
NativeScript:mainfrom
aleclarson:fix/css-translate-percent
Open

aleclarson wants to merge 2 commits into
NativeScript:mainfrom
aleclarson:fix/css-translate-percent

Conversation

@aleclarson

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

Percentage arguments to translate()/translateX()/translateY()/translate3d() in a transform declaration are parsed with parseFloat, which silently drops the % and applies the number as dips. transform: translate(-50%, -50%) moves a view by -50dip instead of -50% of its own size. The translateX/translateY longhand properties mis-parse the same way via FixedLength.parse, and keyframe transform declarations hit the same path.

What is the new behavior?

  • Translate values are parsed and stored as CoreTypes.PercentLengthType (the same mechanism width/height/margin already use) via PercentLength.parse, so %, dip, px, and plain numbers are all represented in the style value.
  • Percentages are resolved against the element's own size when the native transform is applied: resolveTranslate(value, basis) in css-transform.ts is used by iOS updateNativeTransform() and Android translateX/YProperty.setNative / layout (layoutNativeView). Resolution re-runs on every size change (_onSizeChanged on iOS, every native layout on Android), so translate(-50%, -50%) stays centered across resizes.
  • Duplicate translate functions in one transform (e.g. translateX(-50%) translateY(-50%), which both map to property: 'translate') are merged per-axis instead of going through the affine-matrix path. Percent units are preserved when an axis has a single contribution or same-unit contributions; unrepresentable mixed-unit sums (e.g. translate(10) translateX(-50%)) degrade to the previous numeric coercion rather than producing NaN, so nothing regressively breaks in the matrix path.
  • transform: inside @keyframes and view.animate({ translate }) normalize translate through PercentLength.parse, and the platform animators resolve percentages against the target's bounds — so @keyframes { transform: translate(0, -100%) } slide-in animations work.
  • view.translateX/translateY are typed CoreTypes.PercentLengthType (numbers still accepted); translateX: 10px, which previously produced NaN on Android, now resolves correctly.

Fixes #11457

Note on existing quirks kept intact: scale/rotate parsing is unchanged, and the matrix-composition path (multiple scale/rotate functions) keeps its prior semantics — percentage translates hitting it degrade to the old numeric behavior rather than erroring.

Test plan

  • New ui/styling/css-transform.spec.ts covers percent/dip/px translate parsing, translateX/translateY functions, duplicate-translate merging, mixed-unit degradation, the translateX/Y property converters, and resolveTranslate basis resolution
  • npx nx run core:test — 515 tests pass
  • npx nx run apps-automated:ios / :android — native transform application on device/simulator (not runnable in this environment; percentage re-resolution on resize happens in layoutNativeView (Android) / _onSizeChanged (iOS))

translate() arguments in `transform` declarations were parsed with
parseFloat, so `translate(-50%, -50%)` silently became -50dip instead of
-50% of the element's own size. translateX/translateY longhands had the
same problem via FixedLength.parse.

Translate values are now parsed and stored as PercentLengthType and
resolved against the view's own width/height when the native transform
is applied and on every subsequent layout, matching CSS semantics.
Duplicate translate functions in a transform list are merged per-axis
so percentage units survive where they don't mix with other units on
the same axis.

Fixes NativeScript#11457
@nx-cloud

nx-cloud Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 85d5f5b

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 4m 1s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded 6s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-30 18:10:51 UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nativescript/core@11481
npm i https://pkg.pr.new/@nativescript/vite@11481
npm i https://pkg.pr.new/@nativescript/webpack@11481

commit: 85d5f5b


const TRANSFORM_SPLITTER = new RegExp(/\s*(.+?)\((.*?)\)/g);
const TRANSFORMATIONS = Object.freeze<TransformationType[]>(['rotate', 'rotateX', 'rotateY', 'rotate3d', 'translate', 'translate3d', 'translateX', 'translateY', 'scale', 'scale3d', 'scaleX', 'scaleY']);
const TRANSLATE_TRANSFORMATIONS = Object.freeze<TransformationType[]>(['translate', 'translate3d', 'translateX', 'translateY']);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have translate array defined on top and concatenate/merge it with the other prop array to avoid keep track of props twice.

* against the element's own size.
*/
export function isPercentTranslate(value: CoreTypes.PercentLengthType): boolean {
return typeof value === 'object' && value !== null && value.unit === '%';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return value?.unit === '%'; should be enough

* @param value The translate axis value as a PercentLengthType.
* @param basis The element's size in dips along the translated axis.
*/
export function resolveTranslate(value: CoreTypes.PercentLengthType, basis: number): CoreTypes.dip {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldnt it be better to define PercentLength.toDpi or something?

@NathanWalker NathanWalker added this to the 9.2 milestone Oct 6, 2026
@NathanWalker

Copy link
Copy Markdown
Contributor

Thanks @aleclarson, percent translates are a feature we want. We're targeting 9.2 rather than the 9.1.3 patch because this changes public API and existing behavior. Here's what we found reviewing it against current main:

Breaking changes

  • Public types: View.translateX/translateY change from CoreTypes.dip (number) to PercentLengthType, and AnimationDefinition.translate/TransformFunctionsInfo.translate become TranslatePair. Existing app code stops compiling, e.g. the common pan-gesture idiom view.translateX += 10, const n: number = view.translateX, and Math.max(view.translateX, 0). The repo itself has this in apps/automated/src/ui/view/view-tests-layout-event.ts and apps/ui/src/scroll-view/scroll-enabled-page.ts.
  • px in the transform shorthand: transform: translate(10px, 20px) used to mean 10/20 dip. It now means device pixels, but only on the non-matrix path: translate(10px,0) scaleX(2) scaleY(2) still yields 10 dip.
  • Android rounding: translate now goes through PercentLength.toDevicePixels → layout.round, so every Android translate (gestures, animation end values) loses sub-pixel precision. It also adds getWidth()/getHeight() bridge calls on each set.

Correctness

  • Android: percentages are re-resolved in View.layoutNativeView, which only runs for views laid out from JS. Children of the native Java layouts never hit it, and the initial setNative runs before layout with a size of 0. So translate(-50%, -50%) resolves to 0 and stays there. iOS is fine via _onSizeChanged. Keyframe animations starting in onLoaded likely hit the same 0-size problem.
  • Parser state leak: PercentLength.parse can throw inside the TRANSFORM_SPLITTER /g exec loop, leaving lastIndex dirty. After transformConverter('rotate(10) translate(abc, 0)') throws, the next transformConverter('translate(5, 7)') returns identity.

For 9.2

  • Decide the type story: keep the getter numeric and add a separate way to set percentages, or document the break.
  • Resolve on Android via a layout-change listener or native hook.
  • Keep plain numbers unrounded.
  • Reset or guard the splitter regex.
  • Address the review comments from @CatchABus and @farfromrefug.
  • Add device coverage in apps/automated for both platforms.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSS transform: percentage translate values silently parsed as dips (translate(-50%) → -50dip)

4 participants