Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/pages/versions/unversioned/sdk/brownfield.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '"<ios.bundleIdentifier base>.<targetName>" or "com.example.<targetName>"',
},
{
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',
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions docs/pages/versions/v55.0.0/sdk/brownfield.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '"<ios.bundleIdentifier base>.<targetName>" or "com.example.<targetName>"',
},
{
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',
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions packages/expo-modules-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -89,6 +89,8 @@ extension ExpoSwiftUI {
self?.setStyleSize(width, height: height)
}

props.shadowNodeProxy = shadowNodeProxy

shadowNodeProxy.objectWillChange.send()

#if os(iOS) || os(tvOS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. <View style={{ width: 100, height: 100 }} />
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()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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?

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ extension ExpoSwiftUI {
This class is the Swift component of SwiftUIVirtualView, as referenced in ExpoFabricView.swift.
*/
final class SwiftUIVirtualView<Props: ViewProps, ContentView: View<Props>>: 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.
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -363,7 +367,11 @@ - (void)setStyleSize:(nullable NSNumber *)width height:(nullable NSNumber *)heig
if (_state) {
float widthValue = width ? [width floatValue] : std::numeric_limits<float>::quiet_NaN();
float heightValue = height ? [height floatValue] : std::numeric_limits<float>::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
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/expo-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 1 addition & 5 deletions packages/expo-ui/ios/ExpoUIModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions packages/expo-ui/ios/HostView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
101 changes: 91 additions & 10 deletions packages/expo-ui/ios/RNHostView.swift
Original file line number Diff line number Diff line change
@@ -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<Content: SwiftUI.View>: 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) }
}
}
}
}
}
Loading