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