From 3163bef2871474fc5a853e8f99be9d52068f00ce Mon Sep 17 00:00:00 2001 From: Patryk Mleczek <67064618+pmleczek@users.noreply.github.com> Date: Wed, 4 Mar 2026 03:32:43 +0100 Subject: [PATCH 1/2] [docs] update expo-brownfield docs to include new features (#43590) # Why We introduced shipping Swift packages and plugin option `buildReactNativeFromSource` (yet to be renamed in https://github.com/expo/expo/pull/43574) and docs should include those new features # How - Added entry for `-p`/`--package` flag to `build:ios` specifications - Added `ios.buildReactNativeFromSource` plugin prop to the appropriate table (I also plan to cherrypick this to SDK 55 branch along with the appropriate changes later on) # Test Plan Ran: - `yarn prettier` - `yarn test` - `yarn lint` - `yarn run lint-prose` # Checklist - [X] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/pages/versions/unversioned/sdk/brownfield.mdx | 8 ++++++++ docs/pages/versions/v55.0.0/sdk/brownfield.mdx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/pages/versions/unversioned/sdk/brownfield.mdx b/docs/pages/versions/unversioned/sdk/brownfield.mdx index 6e126520568ae4..1b9916e2527079 100644 --- a/docs/pages/versions/unversioned/sdk/brownfield.mdx +++ b/docs/pages/versions/unversioned/sdk/brownfield.mdx @@ -183,6 +183,13 @@ The `expo-brownfield` package provides a [config plugin](/config-plugins/introdu 'Bundle identifier for the brownfield target. This should be unique and different from your main app bundle identifier.', default: '"." or "com.example."', }, + { + name: 'ios.buildReactNativeFromSource', + platform: 'ios', + description: + 'Build React Native from source instead of using prebuilt frameworks. Turning this on significantly increases the build times.', + default: 'true', + }, { name: 'android.group', platform: 'android', @@ -256,6 +263,7 @@ Builds the brownfield XCFramework and copies the Hermes XCFramework to the artif | `-a, --artifacts` | Path to the artifacts directory (default: `./artifacts`) | | `-s, --scheme` | Xcode scheme to build | | `-x, --xcworkspace` | Xcode workspace path | +| `-p, --package` | Ship artifacts as Swift Package (with optional name) | | `--verbose` | Include all logs from subprocesses | #### `tasks:android` diff --git a/docs/pages/versions/v55.0.0/sdk/brownfield.mdx b/docs/pages/versions/v55.0.0/sdk/brownfield.mdx index 6e126520568ae4..d531f2a4783ccc 100644 --- a/docs/pages/versions/v55.0.0/sdk/brownfield.mdx +++ b/docs/pages/versions/v55.0.0/sdk/brownfield.mdx @@ -183,6 +183,13 @@ The `expo-brownfield` package provides a [config plugin](/config-plugins/introdu 'Bundle identifier for the brownfield target. This should be unique and different from your main app bundle identifier.', default: '"." or "com.example."', }, + { + name: 'ios.buildReactNativeFromSource', + platform: 'ios', + description: + 'Build React Native from source instead of using prebuilt frameworks. Turning this on significantly increases the build times.', + default: 'true', + }, { name: 'android.group', platform: 'android', @@ -256,6 +263,7 @@ Builds the brownfield XCFramework and copies the Hermes XCFramework to the artif | `-a, --artifacts` | Path to the artifacts directory (default: `./artifacts`) | | `-s, --scheme` | Xcode scheme to build | | `-x, --xcworkspace` | Xcode workspace path | +| `-p, --package` | Ship artifacts as Swift Package (accepts optional name) | | `--verbose` | Include all logs from subprocesses | #### `tasks:android` From 5c0bc451256de2a67c09733804ce00632b462249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Wed, 4 Mar 2026 08:29:28 +0530 Subject: [PATCH 2/2] [ui][iOS] - Make `RNHostView` SwiftUI view (#43570) # Why - Simplifies current RNHostView implementation and matches the approach with the Android [version](https://github.com/expo/expo/pull/43495) - As a side effect, it also fixes https://github.com/expo/expo/issues/43537 # How Make `RNHostView` SwiftUI view instead of regular Expo View. Use bounds kvo instead of using `layoutSubviews` to get the updated bounds from yoga. # Test Plan Tested all the `RNHostView` examples from NCL. # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-modules-core/CHANGELOG.md | 1 + .../ios/Core/Views/SwiftUI/AnyChild.swift | 7 ++ .../Views/SwiftUI/SwiftUIHostingView.swift | 4 +- .../Core/Views/SwiftUI/SwiftUIViewHost.swift | 49 +-------- .../Core/Views/SwiftUI/SwiftUIViewProps.swift | 5 + .../Views/SwiftUI/SwiftUIVirtualView.swift | 10 ++ .../Views/SwiftUI/SwiftUIVirtualViewObjC.mm | 8 ++ packages/expo-ui/CHANGELOG.md | 1 + packages/expo-ui/ios/ExpoUIModule.swift | 6 +- packages/expo-ui/ios/HostView.swift | 3 +- packages/expo-ui/ios/RNHostView.swift | 101 ++++++++++++++++-- 11 files changed, 133 insertions(+), 62 deletions(-) diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index aacfdd30d1bb4d..3a35e7ec436d4f 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -16,6 +16,7 @@ ### 💡 Others - [iOS] Improved conversions of returned arrays and dictionaries with mixed element types. ([#42641](https://github.com/expo/expo/pull/42641) by [@barthap](https://github.com/barthap)) +- [iOS] Make RNHostView SwiftUI view ([#43570](https://github.com/expo/expo/pull/43570) by [@nishan](https://github.com/intergalacticspacehighway)) ## 55.0.12 — 2026-02-25 diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/AnyChild.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/AnyChild.swift index 7236b013ceaa29..b4de8fdebd30dc 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/AnyChild.swift +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/AnyChild.swift @@ -12,6 +12,9 @@ extension ExpoSwiftUI { var childView: ChildViewType { get } var id: ObjectIdentifier { get } + + // The underlying UIKit view, if this child wraps one. Returns `nil` for pure SwiftUI child. + var uiView: UIView? { get } } } @@ -23,4 +26,8 @@ public extension ExpoSwiftUI.AnyChild where Self == ChildViewType { var id: ObjectIdentifier { fatalError("Expected override by derived SwiftUIVirtualView or UIViewHost") } + + var uiView: UIView? { + nil + } } diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift index b58d7c0a2cf440..b1c9f8bda4db30 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift @@ -70,7 +70,7 @@ extension ExpoSwiftUI { */ init(viewType: ContentView.Type, props: Props, appContext: AppContext) { self.contentView = ContentView(props: props) - let rootView = AnyView(contentView.environmentObject(shadowNodeProxy)) + let rootView = AnyView(contentView) self.props = props let controller = UIHostingController(rootView: rootView) @@ -89,6 +89,8 @@ extension ExpoSwiftUI { self?.setStyleSize(width, height: height) } + props.shadowNodeProxy = shadowNodeProxy + shadowNodeProxy.objectWillChange.send() #if os(iOS) || os(tvOS) diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewHost.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewHost.swift index bb9a28bfbd4652..237f520b638a56 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewHost.swift +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewHost.swift @@ -45,62 +45,23 @@ extension ExpoSwiftUI { class Coordinator { var originalAutoresizingMask: UIView.AutoresizingMask = [] + init() {} } // MARK: - AnyChild implementations var childView: some SwiftUI.View { - ViewSizeWrapper(viewHost: self) + self } var id: ObjectIdentifier { ObjectIdentifier(view) } - } - - public protocol RNHostViewProtocol { - var matchContents: Bool { get set } - } -} - -// ViewSizeWrapper attaches an observer to the view's bounds and updates the frame modifier of the view host. -// This allows us to respect RN layout styling in SwiftUI realm -// .e.g. -private struct ViewSizeWrapper: View { - let viewHost: ExpoSwiftUI.UIViewHost - @StateObject private var viewSizeModel: ViewSizeModel - - init(viewHost: ExpoSwiftUI.UIViewHost) { - self.viewHost = viewHost - _viewSizeModel = StateObject(wrappedValue: ViewSizeModel(viewHost: viewHost)) - } - var body: some View { - if let rnHostView = viewHost.view as? ExpoSwiftUI.RNHostViewProtocol, rnHostView.matchContents { - viewHost - .frame(width: viewSizeModel.viewFrame.width, height: viewSizeModel.viewFrame.height) - } else { - viewHost - } - } -} - -@MainActor -private class ViewSizeModel: ObservableObject { - @Published var viewFrame: CGSize - private var observer: NSKeyValueObservation? - - init(viewHost: ExpoSwiftUI.UIViewHost) { - let view = viewHost.view - self.viewFrame = view.bounds.size - observer = view.observe(\.bounds) { [weak self] view, _ in - MainActor.assumeIsolated { - self?.viewFrame = view.bounds.size - } + var uiView: UIView? { + view } } - deinit { - observer?.invalidate() - } } + diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewProps.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewProps.swift index cdb37ef44bf9c6..b133ea4ef12172 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewProps.swift +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewProps.swift @@ -34,6 +34,11 @@ extension ExpoSwiftUI { */ public var children: [any AnyChild]? + /** + Proxy for controlling the shadow node (Yoga layout) of the view. + */ + public internal(set) var shadowNodeProxy: ShadowNodeProxy = ShadowNodeProxy() + public internal(set) weak var appContext: AppContext? /** diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift index c5c2f4c591a770..925175fe599149 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift @@ -8,6 +8,8 @@ extension ExpoSwiftUI { This class is the Swift component of SwiftUIVirtualView, as referenced in ExpoFabricView.swift. */ final class SwiftUIVirtualView>: SwiftUIVirtualViewObjC, ExpoSwiftUIView { + var uiView: UIView? + /** A weak reference to the app context associated with this view. The app context is injected into the class after the context is initialized. @@ -39,6 +41,14 @@ extension ExpoSwiftUI { self.viewDefinition = viewDefinition self.appContext = appContext super.init() + + props.shadowNodeProxy.setViewSize = { [weak self] size in + self?.setViewSize(size) + } + props.shadowNodeProxy.setStyleSize = { [weak self] width, height in + self?.setStyleSize(width, height: height) + } + installEventDispatchers() } diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualViewObjC.mm b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualViewObjC.mm index a9c0ea5e89ab96..512bef9e91fcb9 100644 --- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualViewObjC.mm +++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualViewObjC.mm @@ -354,7 +354,11 @@ - (void)viewDidUpdateProps - (void)setShadowNodeSize:(float)width height:(float)height { if (_state) { +#if REACT_NATIVE_TARGET_VERSION >= 82 + _state->updateState(expo::ExpoViewState(width, height), EventQueue::UpdateMode::unstable_Immediate); +#else _state->updateState(expo::ExpoViewState(width, height)); +#endif } } @@ -363,7 +367,11 @@ - (void)setStyleSize:(nullable NSNumber *)width height:(nullable NSNumber *)heig if (_state) { float widthValue = width ? [width floatValue] : std::numeric_limits::quiet_NaN(); float heightValue = height ? [height floatValue] : std::numeric_limits::quiet_NaN(); +#if REACT_NATIVE_TARGET_VERSION >= 82 + _state->updateState(expo::ExpoViewState::withStyleDimensions(widthValue, heightValue), EventQueue::UpdateMode::unstable_Immediate); +#else _state->updateState(expo::ExpoViewState::withStyleDimensions(widthValue, heightValue)); +#endif } } diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md index e4f38d3c404bea..1ec8612eaad8d2 100644 --- a/packages/expo-ui/CHANGELOG.md +++ b/packages/expo-ui/CHANGELOG.md @@ -17,6 +17,7 @@ ### 💡 Others - [iOS] Introduce `SlotView` to replace structural child view types with a single generic slot. ([#43607](https://github.com/expo/expo/pull/43607) by [@nishan](https://github.com/intergalacticspacehighway)) +- [iOS] Make RNHostView SwiftUI view ([#43570](https://github.com/expo/expo/pull/43570) by [@nishan](https://github.com/intergalacticspacehighway)) ## 55.0.1 — 2026-02-25 diff --git a/packages/expo-ui/ios/ExpoUIModule.swift b/packages/expo-ui/ios/ExpoUIModule.swift index 6aa80ca6794eaf..a24822f1ef8f1d 100644 --- a/packages/expo-ui/ios/ExpoUIModule.swift +++ b/packages/expo-ui/ios/ExpoUIModule.swift @@ -6,11 +6,7 @@ public final class ExpoUIModule: Module { public func definition() -> ModuleDefinition { Name("ExpoUI") - View(RNHostView.self) { - Prop("matchContents") { (view, matchContents: Bool) in - view.matchContents = matchContents - } - } + View(RNHostView.self) OnDestroy { Task { @MainActor in diff --git a/packages/expo-ui/ios/HostView.swift b/packages/expo-ui/ios/HostView.swift index 38f7fe1d9a702d..47c9ecb7ce422d 100644 --- a/packages/expo-ui/ios/HostView.swift +++ b/packages/expo-ui/ios/HostView.swift @@ -161,13 +161,12 @@ private struct ViewportSizeMeasurementLayout: Layout { */ private struct GeometryChangeModifier: ViewModifier { let props: HostViewProps - @EnvironmentObject var shadowNodeProxy: ExpoSwiftUI.ShadowNodeProxy private func dispatchOnLayoutContent(_ size: CGSize) { if props.matchContentsHorizontal || props.matchContentsVertical { let styleWidth = props.matchContentsHorizontal ? NSNumber(value: Float(size.width)) : nil let styleHeight = props.matchContentsVertical ? NSNumber(value: Float(size.height)) : nil - shadowNodeProxy.setStyleSize?(styleWidth, styleHeight) + props.shadowNodeProxy.setStyleSize?(styleWidth, styleHeight) } props.onLayoutContent([ diff --git a/packages/expo-ui/ios/RNHostView.swift b/packages/expo-ui/ios/RNHostView.swift index 35efd5638564f4..f8cba36a3bd002 100644 --- a/packages/expo-ui/ios/RNHostView.swift +++ b/packages/expo-ui/ios/RNHostView.swift @@ -1,21 +1,102 @@ // Copyright 2015-present 650 Industries. All rights reserved. +import SwiftUI import ExpoModulesCore -public final class RNHostView: ExpoView, ExpoSwiftUI.RNHostViewProtocol { - public required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - ExpoUITouchHandlerHelper.createAndAttachTouchHandler(for: self) +internal final class RNHostViewProps: ExpoSwiftUI.ViewProps { + @Field var matchContents: Bool = false +} + +struct RNHostView: ExpoSwiftUI.View { + + @ObservedObject var props: RNHostViewProps + + var body: some View { + if props.matchContents, let childUIView = firstChildUIView { + ApplySizeFromYogaNode(childUIView: childUIView) { + Children() + } + .onAppear { + ExpoUITouchHandlerHelper.createAndAttachTouchHandler(for: childUIView) + } + } else { + Children() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .modifier(ReportSizeToYogaNodeModifier(shadowNodeProxy: props.shadowNodeProxy)) + .onAppear { + if let view = firstChildUIView { + ExpoUITouchHandlerHelper.createAndAttachTouchHandler(for: view) + } + } + } + } + + private var firstChildUIView: UIView? { + props.children?.first?.uiView + } +} + +// Sets SwiftUI view size from Yoga node size +// Listens to Yoga node size changes and updates the SwiftUI view size +private struct ApplySizeFromYogaNode: SwiftUI.View { + @StateObject private var observer: Observer + let content: Content + + init(childUIView: UIView, @ViewBuilder content: () -> Content) { + _observer = StateObject(wrappedValue: Observer(view: childUIView)) + self.content = content() } - public var matchContents: Bool = false + var body: some SwiftUI.View { + content + .frame(width: observer.size.width, height: observer.size.height) + } + + @MainActor + fileprivate class Observer: ObservableObject { + @Published var size: CGSize + private var kvoToken: NSKeyValueObservation? + + init(view: UIView) { + self.size = view.bounds.size + kvoToken = view.observe(\.bounds) { [weak self] view, _ in + MainActor.assumeIsolated { + self?.size = view.bounds.size + } + } + } + + deinit { + kvoToken?.invalidate() + } + } +} + +// Sets Yoga node size from SwiftUI view size +// Listens to SwiftUI view size changes and updates the Yoga node size +private struct ReportSizeToYogaNodeModifier: ViewModifier { + let shadowNodeProxy: ExpoSwiftUI.ShadowNodeProxy + + private func handleSizeChange(_ size: CGSize) { + shadowNodeProxy.setViewSize?(size) + } - public override func layoutSubviews() { - super.layoutSubviews() - if matchContents, let subview = self.subviews.first { - self.setViewSize(subview.bounds.size) + func body(content: Content) -> some View { + if #available(iOS 16.0, tvOS 16.0, macOS 13.0, *) { + content.onGeometryChange(for: CGSize.self, of: { proxy in proxy.size }) { size in + handleSizeChange(size) + } } else { - self.setViewSize(bounds.size) + content.overlay { + GeometryReader { geometry in + Color.clear + .hidden() + .onAppear { + handleSizeChange(geometry.size) + } + .onChange(of: geometry.size) { handleSizeChange($0) } + } + } } } }