From 0dd21b5878b8dffb06d701f6c9a1421cac8172f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 27 Sep 2026 08:11:18 +0200 Subject: [PATCH] fix(ios): size the new runtime's Metal drawable from the view's own bounds On devices where UIScreen.nativeScale != scale (Display Zoom, iPhone mini and Plus), MTKView derives its drawable size through ancestor transforms the first time it attaches to a window. Under a rotated parent the drawable gets the wrong aspect ratio and the artboard renders squashed; under a parent at scale(0) the size becomes infinite and the view stays blank. RiveUIView creates its MTKView asynchronously with automatic drawable sizing. Turn that off as soon as the MTKView is added (observed through the RiveUIView layer's sublayers, before the MTKView reaches the window) and size the drawable from the MTKView's bounds times the screen's nativeScale on layout and window changes. Legacy backend counterpart: #394. Fixes #393 for the new runtime. --- .../src/reproducers/Issue393DisplayZoom.tsx | 142 ++++++++++++++++++ ios/new/RiveReactNativeView.swift | 47 ++++++ 2 files changed, 189 insertions(+) create mode 100644 example/src/reproducers/Issue393DisplayZoom.tsx 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