diff --git a/example/src/reproducers/Issue393DisplayZoom.tsx b/example/src/reproducers/Issue393DisplayZoom.tsx
new file mode 100644
index 00000000..db6cbdd3
--- /dev/null
+++ b/example/src/reproducers/Issue393DisplayZoom.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ Pressable,
+ type ViewStyle,
+} from 'react-native';
+import {
+ RiveView,
+ Fit,
+ useRiveFile,
+ type RiveFile,
+} from '@rive-app/react-native';
+import { type Metadata } from '../shared/metadata';
+
+/**
+ * Issue #393: on iOS devices where UIScreen.nativeScale != scale (Display
+ * Zoom, iPhone mini and Plus), MTKView sizes its drawable through ancestor
+ * transforms. A rotated parent squashes the artboard; a parent at scale(0)
+ * when the view mounts leaves it blank after the scale returns to 1.
+ *
+ * Run on an iPhone 13 mini simulator. All three artboards should keep the
+ * same aspect ratio, including after Remount and Resize.
+ */
+
+const W = 100;
+const H = 148;
+const ZERO_SCALE_HOLD_MS = 1500;
+
+function Cell({
+ label,
+ transform,
+ file,
+ resized,
+}: {
+ label: string;
+ transform: ViewStyle['transform'];
+ file: RiveFile;
+ resized: boolean;
+}) {
+ const size = resized ? styles.resized : styles.size;
+ return (
+
+ {label}
+
+
+
+
+ );
+}
+
+export default function Issue393DisplayZoom() {
+ const { riveFile, error } = useRiveFile(
+ require('../../assets/rive/rewards.riv')
+ );
+ const [generation, setGeneration] = useState(0);
+ const [zeroScaled, setZeroScaled] = useState(true);
+ const [resized, setResized] = useState(false);
+
+ useEffect(() => {
+ setZeroScaled(true);
+ const timer = setTimeout(() => setZeroScaled(false), ZERO_SCALE_HOLD_MS);
+ return () => clearTimeout(timer);
+ }, [generation, riveFile]);
+
+ return (
+
+
+ generation {generation} ·{' '}
+ {zeroScaled ? 'scale(0) holding' : 'scale(0) released'}
+
+ {error ? {String(error)} : null}
+ {riveFile ? (
+
+ |
+ |
+ |
+
+ ) : (
+ loading…
+ )}
+ setGeneration((g) => g + 1)}
+ >
+ Remount
+
+ setResized((r) => !r)}>
+
+ {resized ? 'Restore size' : 'Resize'}
+
+
+
+ );
+}
+
+Issue393DisplayZoom.metadata = {
+ name: 'Issue #393: Display Zoom drawable',
+ description:
+ 'iOS RiveView squashed or blank under a rotated or zero-scaled parent',
+} satisfies Metadata;
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#fff', padding: 12 },
+ subtitle: { fontSize: 12, color: '#666', textAlign: 'center', margin: 8 },
+ error: { color: 'red', textAlign: 'center' },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginTop: 24,
+ },
+ cell: { alignItems: 'center' },
+ cellLabel: { fontSize: 12, marginBottom: 16 },
+ frame: { backgroundColor: '#dde6ff' },
+ size: { width: W, height: H },
+ resized: { width: W * 1.3, height: H * 0.7 },
+ button: {
+ marginTop: 40,
+ alignSelf: 'center',
+ backgroundColor: '#007AFF',
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ borderRadius: 8,
+ },
+ buttonText: { color: '#fff', fontWeight: '600' },
+});
diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift
index 1f10b9c6..abd7fd6d 100644
--- a/ios/new/RiveReactNativeView.swift
+++ b/ios/new/RiveReactNativeView.swift
@@ -1,6 +1,7 @@
import RiveRuntime
import NitroModules
import UIKit
+import MetalKit
enum BindData {
case none
@@ -23,6 +24,7 @@ struct ViewConfiguration {
@MainActor
class RiveReactNativeView: UIView {
private var riveUIView: RiveUIView?
+ private var mtkViewObservation: NSKeyValueObservation?
private var riveInstance: RiveRuntime.Rive?
private var pendingBindInstance: ViewModelInstance?
private var viewReadyContinuations: [CheckedContinuation] = []
@@ -210,8 +212,43 @@ class RiveReactNativeView: UIView {
throw RuntimeError.error(withMessage: "Text runs are not supported by the new runtime — use data binding")
}
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ syncDrawableSize()
+ }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ syncDrawableSize()
+ }
+
// MARK: - Internal
+ private var riveMTKView: MTKView? {
+ riveUIView?.subviews.lazy.compactMap { $0 as? MTKView }.first
+ }
+
+ /// MTKView's automatic resize derives per-axis factors through ancestor
+ /// transforms when `nativeScale != scale` (Display Zoom, mini and Plus
+ /// iPhones), which squashes the artboard under a rotated parent and blanks
+ /// it under a zero-scale one (#393). Size the drawable from the view's bounds.
+ private func syncDrawableSize() {
+ guard let riveUIView, let mtkView = riveMTKView,
+ let nativeScale = window?.windowScene?.screen.nativeScale
+ else { return }
+ mtkView.autoResizeDrawable = false
+ riveUIView.layoutIfNeeded()
+ let pointSize = mtkView.bounds.size
+ guard pointSize.width > 0, pointSize.height > 0 else { return }
+ let size = CGSize(
+ width: (pointSize.width * nativeScale).rounded(),
+ height: (pointSize.height * nativeScale).rounded()
+ )
+ if mtkView.drawableSize != size {
+ mtkView.drawableSize = size
+ }
+ }
+
private func setupRiveUIView(with rive: RiveRuntime.Rive) {
if let existing = riveUIView {
// Reuse the existing view — avoids tearing down the MTKView on every
@@ -234,6 +271,15 @@ class RiveReactNativeView: UIView {
uiView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
self.riveUIView = uiView
+ // RiveUIView adds its MTKView asynchronously. This fires before the MTKView
+ // reaches the window, where it would compute the broken resize factors.
+ mtkViewObservation = uiView.layer.observe(\.sublayers) { [weak self] _, _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.riveMTKView?.autoResizeDrawable = false
+ self.setNeedsLayout()
+ }
+ }
}
}
@@ -241,6 +287,7 @@ class RiveReactNativeView: UIView {
dispatchPrecondition(condition: .onQueue(.main))
configTask?.cancel()
configTask = nil
+ mtkViewObservation = nil
riveUIView?.removeFromSuperview()
riveUIView = nil
riveInstance = nil