Skip to content

Commit 87827cb

Browse files
authored
fix(ios): re-apply layer.transform after super.invalidateLayer (#41)
`-[RCTViewComponentView invalidateLayer]` re-applies the View's style layer-properties to the underlying CALayer — including transform. Since EaseView's JS shim strips `style.transform` whenever `animate` includes any transform component, super resets `layer.transform = identity` on every invalidateLayer call. The override here re-applied opacity, cornerRadius, backgroundColor, border, and shadow — but missed transform. For static or already-settled views this was invisible. Views in the middle of a transform CAAnimation hit it as a hard-to-reproduce visual bug: 1. updateProps' "subsequent updates" branch sets `layer.transform = targetTransform` and queues a CABasicAnimation with `fromValue=current, toValue=target, fillMode=removed`. 2. Mid-animation, a sibling re-render or layout settle triggers `invalidateLayer` on this view. `[super invalidateLayer]` resets `layer.transform` to identity from the empty style.transform. 3. The override re-applies other animated properties but leaves the model layer's transform at identity. 4. The CAAnimation finishes. With `fillMode=removed`, presentation reverts to model — which is now identity. The view snaps to its un-transformed position/scale. Backgrounding and reopening the app worked around the bug because foregrounding triggers a window-wide displayIfNeeded after the animation has long since completed; at that point the override's re-apply branch executes with no in-flight animation and the model can be corrected. This was first surfaced in a hero ↔ list morphing card with FlashList + LayoutAnimation, where mid-flight invalidateLayer calls during the layout settle were the trigger. The fix: * Add `kMaskAnyTransform` to the early-return gate so transform-only animated views still hit the re-apply block. * Re-apply `self.layer.transform = [self targetTransformFromProps:...]` unconditionally inside the existing `setDisableActions:YES` CATransaction. The re-apply is intentionally NOT gated on "no animation in flight". A running CABasicAnimation interpolates the *presentation* layer between its own fromValue/toValue and ignores the model layer for its lifetime. With actions disabled, writing to the model does not start an implicit animation. The model needs to be at target so that when the explicit animation removes itself (fillMode=removed), presentation reverts to the correct resting state — not identity. Verified on RN 0.85.1 / Fabric / iOS 17 sim and iPhone (real device).
1 parent e3621ba commit 87827cb

1 file changed

Lines changed: 19 additions & 3 deletions

File tree

ios/EaseView.mm

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,20 +1162,36 @@ - (void)didMoveToWindow {
11621162
- (void)invalidateLayer {
11631163
[super invalidateLayer];
11641164

1165-
// super resets layer.opacity, layer.cornerRadius, and layer.backgroundColor
1166-
// from style props. Re-apply our animated values.
1165+
// super resets layer.opacity, layer.cornerRadius, layer.backgroundColor,
1166+
// AND layer.transform from style props. Re-apply our animated values.
1167+
//
1168+
// The transform re-apply is intentionally unconditional (not gated on
1169+
// "no animation in flight"). A running CABasicAnimation interpolates
1170+
// the *presentation* layer between its own fromValue/toValue and ignores
1171+
// the model layer for its lifetime. We're already inside
1172+
// setDisableActions:YES so the model write does NOT start an implicit
1173+
// animation. The model layer needs to hold the target value at animation
1174+
// completion time so that when the explicit animation removes itself
1175+
// (fillMode=removed), the presentation reverts to the correct resting
1176+
// state instead of identity.
11671177
const auto &viewProps =
11681178
*std::static_pointer_cast<const EaseViewProps>(_props);
11691179
int mask = viewProps.animatedProperties;
11701180

11711181
if (!(mask & (kMaskOpacity | kMaskBorderRadius | kMaskBackgroundColor |
11721182
kMaskBorderWidth | kMaskBorderColor | kMaskShadowOpacity |
1173-
kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset))) {
1183+
kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset |
1184+
kMaskAnyTransform))) {
11741185
return;
11751186
}
11761187

1188+
BOOL hasTransform = (mask & kMaskAnyTransform) != 0;
1189+
11771190
[CATransaction begin];
11781191
[CATransaction setDisableActions:YES];
1192+
if (hasTransform) {
1193+
self.layer.transform = [self targetTransformFromProps:viewProps];
1194+
}
11791195
if (mask & kMaskOpacity) {
11801196
[self.layer removeAnimationForKey:@"opacity"];
11811197
self.layer.opacity = viewProps.animateOpacity;

0 commit comments

Comments
 (0)