Skip to content

fix(android): compose element CTM in the right order - #3091

Open
Danyalo wants to merge 2 commits into
software-mansion:mainfrom
Danyalo:fix/android-nested-ctm-order
Open

Danyalo wants to merge 2 commits into
software-mansion:mainfrom
Danyalo:fix/android-nested-ctm-order

Conversation

@Danyalo

@Danyalo Danyalo commented Oct 19, 2026 •

Copy link
Copy Markdown

Summary

Fixes #3090.

On Android, VirtualView.saveAndSetupCanvas built an element's CTM as mMatrix × ctm (mCTM.preConcat(ctm)). With Android's Matrix, that applies the parent's CTM to a point first and the element's own transform second, so under nested transforms, or a transform under a scaled viewBox, the order is reversed.

Drawing was unaffected, because the canvas is concatenated separately, and so were touches, because hit-testing walks each element's inverse matrix. Everything that reads mCTM was wrong:

  • vectorEffect="non-scaling-stroke" paths, which draw with mCTM after canvas.setMatrix(null)
  • getScreenCTM() and getCTM()
  • element client rects, which set the element views' bounds (used for onLayout and accessibility)

iOS reads the CTM from the drawing context (CGContextGetCTM), so it was already correct.

Changes

  • VirtualView.saveAndSetupCanvas: mCTM.preConcat(ctm) → mCTM.postConcat(ctm), i.e. ctm × mMatrix.
  • RNSVGRenderableManager.getCTM: ctm.preConcat(invViewBoxMatrix) → ctm.postConcat(invViewBoxMatrix). The old order compensated for the reversed CTM and was only right for a single level of transform. With the CTM fixed it has to undo the viewBox from the left.

The VirtualView line is the same change that was part of #2802 and was reverted with it in #2832, which was about #2802's performance cost (#2831). This PR contains only the matrix order, plus the getCTM change #2802 didn't include.

Behaviour change

getScreenCTM() now returns the correct matrix for any element with a transform under a scaled viewBox, and both getScreenCTM() and getCTM() are correct for nested transforms. Code that worked around the old values on Android will see different numbers. getCTM() for a single transform level is unchanged.

Test Plan

What's required for testing (prerequisites)?

Android emulator or device. I used a Pixel 9 emulator (API 36, pixel ratio 2.625, which the Android E2E references use) with apps/fabric-example.

What are the steps to reproduce (after prerequisites)?

1. Test example Test3090

  1. In apps/common/index.tsx import ./test, and in apps/common/test/index.tsx render <Test3090 />.
  2. Run apps/fabric-example on Android.
  3. The blue non-scaling-stroke triangle should lie on the red reference, and the printed matrices should match the expected values.
main This PR
non-scaling-stroke path offset up and left of the reference on the reference
getScreenCTM() a=1.5 d=1.5 e=25 f=10 a=1.5 d=1.5 e=90.06 f=45.03
getCTM() a=0.5 d=0.5 e=25 f=10 a=0.5 d=0.5 e=30 f=15
Screenshot image image

(The .06/.03 come from the 300 dp <Svg> being 787.5 px wide at this density, which rounds to 788. The a and d values are off by the same factor on main too.)

2. E2E case e2e/cases/5.svg

non-scaling-stroke outline on a square, under two nested <g transform> and a viewBox scaled ×2. References generated with yarn generateE2eReferences. I committed only the new 5.png files, not the regenerated existing ones.

yarn e2e   # Android, fabric-example, E2E tab
  • main: 5.svg fails, 7448 differing pixels (limit 525). The other cases are unchanged.
  • This PR: all 5 cases pass.
image

3. Touch events still work

Example app → TouchEvents, with this PR:

  • "Bind touch events callback on Group element with viewBox" (<G scale="1.4"> under a scaled viewBox, <Circle scale="1.2"> inside it): pressing the green circle and the yellow rect gives "Pressed on G", pressing "H" gives "Pressed on Text", and pressing empty space gives nothing.
  • First example: pressing the red circle gives "Press on Circle".

4. Checks

yarn lint and yarn tsc pass (after building lib/).

Compatibility

OS Implemented
iOS n/a (unchanged)
MacOS n/a (unchanged)
Android ✅
Web n/a (unchanged)

Not run locally: iOS E2E (the new ios/5.png reference is generated at pixel ratio 3 like the others) and the old architecture (Paper). The change is in Java code shared by both architectures.

Checklist

  • I have tested this on a device and a simulator (Android emulator only)
  • I added documentation in README.md (not needed)
  • I updated the typed files (typescript) (no API change)
  • I added a test for the API in the __tests__ folder (added apps/common/test/Test3090.tsx and E2E case 5.svg instead)

Danylo Labenskyi and others added 2 commits October 19, 2026 22:46
saveAndSetupCanvas built mCTM as mMatrix x ctm, so under nested transforms
(or a transform under a scaled viewBox) the parent's matrix was applied
before the element's own. Drawing was unaffected because the canvas is
concatenated separately, but everything that reads mCTM was wrong:
non-scaling-stroke paths, getScreenCTM()/getCTM() and the element client
rects. Use postConcat, and have getCTM() undo the viewBox on the left to
match.

Fixes software-mansion#3090

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fails on Android before the CTM order fix (7448 differing pixels) and
passes after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

[Android] Nested transforms are composed in reverse order

1 participant