Embedding SwiftUI view in UIKit views

Michał Ziobro
9 min readJul 14, 2023

--

In the world of iOS development, SwiftUI has emerged as a powerful and intuitive framework for building user interfaces. Its declarative syntax and seamless integration with Apple’s platforms have made it a go-to choice for many developers. However, if you’ve been working with UIKit for some time and have a project that heavily relies on it, you might wonder how you can leverage the capabilities of SwiftUI without abandoning your existing UIKit codebase.

Reusing UIViews or UIViewControllers from UIKit in SwiftUI apps is generally better documented. You can just use UIViewRepresentable or UIViewControllerRepresentable. These protocols act as bridges, enabling you to effortlessly integrate your UIKit components into SwiftUI views. On the other hand demonstrating how to reuse SwiftUI views and controls in UIKit codebase is a little bit neglected both by apples and many Swift advocates. In this tutorial I will demonstrate to ways how you can wrap SwiftUI view in UIView. Our control enable both passing date from UIKit to SwiftUI and the opposite way from SwiftUI to UIKit. It also leverage UIKit autolayout to facilitate correct layout of this view between other UIKit views, taking into account changes of SwiftUI view size during its lifetime.

  1. Sample control in SwiftUI

The view we will wrap is simple SwiftUI control that enables to select country from the list. We skip its detail implementation and only demonstrate its interface seen from client side perspective.

struct SelectCountryButton: View {

@Binding private var selectedCountry: CountryProtocol?
@Binding private var fieldState: FieldState

private let title: String?
private let listTitle: String?
private let placeholder: String?
private let borderColor: Color?
private let backgroundColor: Color?

init(
title: String?,
selectedCountry: Binding<CountryProtocol?>,
placeholder: String? = nil,
listTitle: String? = nil,
fieldState: Binding<FieldState> = .constant(.normal),
borderColor: Color? = nil,
backgroundColor: Color? = nil
) {
self.title = title
self.listTitle = listTitle
self.placeholder = placeholder
self.borderColor = borderColor
self.backgroundColor = backgroundColor

_selectedCountry = selectedCountry
_fieldState = fieldState
}

var body: some View {
SelectCountryButtonView(
viewModel: SelectCountryButtonViewModel(allowedCountries: allowedCountries),
title: title,
selectedCountry: $selectedCountry,
placeholder: placeholder,
listTitle: listTitle,
fieldState: $fieldState,
borderColor: borderColor,
backgroundColor: backgroundColor
)

}
}

2. Passing date from SwiftUI to UIKit
Passing data from SwiftUI to UIKit is rather simple we can use several well known techniques:
- closure e.g. onSubmit
- Binding(get: { }, set { })
e.g. selectCountry, fieldState

SelectCountryButton(
title: title,
selectedCountry: .init(
get: { [weak self] in self?.selectedCountry },
set: { [weak self] in
self?.selectedCountry = $0
self?.onSelection?($0)
}
),
placeholder: placeholder,
listTitle: listTitle,
fieldState: .init(
get: { [weak self] in self?.fieldState ?? .normal },
set: { [weak self] in self?.fieldState = $0 }
),
allowedCountries: allowedCountries,
borderColor: buttonBorderColor?.color,
backgroundColor: buttonBackgroundColor?.color
)
.disabled(!isEnabled)
.onSubmit { onSubmit() }
.toAnyView

3. Passing data from UIKit to SwiftUI

In UIKit, when integrating SwiftUI views, we encounter a slightly more complex situation. However, there are two primary approaches that can be employed to address this challenge effectively:

3.1 ObservableObject

One approach involves utilizing the power of ObservableObject. By using ObservableObject for passing data to SwiftUI view, we can leverage the automatic refreshing capabilities of SwiftUI. As you may already know, changing the values of a published property in an ObservableObject triggers refreshes in SwiftUI views. By setting @Published properties of such an object, we can pass data and make changes from UIKit to our SwiftUI view seamlessly.

To demonstrate this approach, we have provided a sample code snippet of a custom UISelectCountryButton class. It incorporates a SelectCountryButtonState class that conforms to ObservableObject and holds the relevant published properties. By modifying these properties in the UIKit portion of the code, such as isEnabled and selectedCountry, we trigger the necessary updates in the SwiftUI portion.

final class SelectCountryButtonState: ObservableObject {
@Published var selectedCountry: CountryProtocol?
@Published var fieldState = Daystrom.FieldState(state: .normal)
@Published var isEnabled = true
}
struct SelectCountryButtonWrapper: View {

@StateObject var state: SelectCountryButtonState

let title: String?
let listTitle: String?
let placeholder: String?
let allowedCountries: Daystrom.AllowedCountries
let borderColor: Color?
let backgroundColor: Color?

var body: some View {
SelectCountryButton(
title: title,
selectedCountry: $state.selectedCountry,
placeholder: placeholder,
listTitle: listTitle,
fieldState: $state.fieldState,
allowedCountries: allowedCountries,
borderColor: borderColor,
backgroundColor: backgroundColor
)
.disabled(!state.isEnabled)
}
}

As you see we have to write both ObservableObject class and custom SwiftUI wrapper around our SwiftUI View. This is because our view didn’t use ObservableObject but regular bindings.

Having this two types implementation of our UIKit wrapper around SwitUI view can look like this.

@IBDesignable
final class UISelectCountryButton: BaseView, IBLocalization {

@IBInspectable
var l10nPlaceholder: String {
get {
messageForSetOnlyProperty()
}
set {
placeholder = newValue.localized()
}
}

@IBInspectable
var l10nTitle: String {
get {
messageForSetOnlyProperty()
}
set {
title = newValue.localized()
}
}

@IBInspectable
var l10nListTitle: String {
get {
messageForSetOnlyProperty()
}
set {
listTitle = newValue.localized()
}
}

var isEnabled = true {
didSet {
state.isEnabled = isEnabled
}
}

var selectedCountry: CountryProtocol? {
didSet {
state.selectedCountry = selectedCountry
}
}

var onSelection: ((CountryProtocol?) -> Void)?

private var allowedCountries: Daystrom.AllowedCountries = .all

private var title: String?
private var listTitle: String?
private var placeholder: String?
private var buttonBorderColor: UIColor?
private var buttonBackgroundColor: UIColor?

private var hostingController: UIHostingController<AnyView>?

private let state = SelectCountryButtonState()
private var cancellables = Set<AnyCancellable>()

var fieldState: Daystrom.FieldState {
state.fieldState
}

init(
title: String? = nil,
listTitle: String? = nil,
selectedCountry: CountryProtocol? = nil,
placeholder: String? = nil,
allowedCountries: Daystrom.AllowedCountries = .all,
borderColor: UIColor? = nil,
backgroundColor: UIColor? = nil
) {
super.init(frame: .constraintSolving)
self.title = title
self.listTitle = listTitle
self.selectedCountry = selectedCountry
self.placeholder = placeholder
self.allowedCountries = allowedCountries
self.buttonBorderColor = borderColor
self.buttonBackgroundColor = backgroundColor

configure()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func awakeFromNib() {
super.awakeFromNib()
configure()
}

func setError(_ error: String?) {
if let error {
state.fieldState = fieldState.updated(with: .error(withText: error))
} else {
state.fieldState = fieldState.updated(with: .normal)
}
}

private func configure() {
let contentView = SelectCountryButtonWrapper(
state: self.state,
title: title,
listTitle: listTitle,
placeholder: placeholder,
allowedCountries: allowedCountries,
borderColor: buttonBorderColor?.color,
backgroundColor: buttonBackgroundColor?.color
)
.toAnyView

state.$selectedCountry
//.dropFirst()
.sink { [weak self] country in
self?.onSelection?(country)
}
.store(in: &cancellables)

let hostingController = UIHostingController(rootView: contentView)
if #available(iOS 16.0, *) {
hostingController.sizingOptions = .intrinsicContentSize
} else {
state.objectWillChange
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.hostingController?.view.invalidateIntrinsicContentSize()
}
.store(in: &cancellables)
}

if let hostingView = hostingController.view {
addSubview(hostingView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

self.hostingController = hostingController
}
}

By adopting this approach, we enable bidirectional communication between UIKit and SwiftUI, ensuring a seamless flow of data and UI updates.

The configure() method plays a crucial role in setting up the UISelectCountryButton and integrating the SwiftUI view within it. Let's take a closer look at this meInside the configure() method, the first step is to create the contentView. It is an instance of the SelectCountryButtonWrapper, which is a SwiftUI view wrapped with the necessary properties and state. This view encapsulates the logic and UI elements required for the UISelectCountryButton.thod and the specific lines of code you mentioned.

Inside the configure() method, the first step is to create the contentView. It is an instance of the SelectCountryButtonWrapper, which is a SwiftUI view wrapped with the necessary properties and state. This view encapsulates the logic and UI elements required for the UISelectCountryButton.

let contentView = SelectCountryButtonWrapper(
state: self.state,
title: title,
listTitle: listTitle,
placeholder: placeholder,
allowedCountries: allowedCountries,
borderColor: buttonBorderColor?.color,
backgroundColor: buttonBackgroundColor?.color
)
.toAnyView

By passing the appropriate parameters and the SelectCountryButtonState instance, we ensure that the SwiftUI view has access to the necessary data and can respond to changes.

Next, we create a UIHostingController instance named hostingController and assign the contentView as its rootView. This step allows us to embed the SwiftUI view within a UIKit environment.

let hostingController = UIHostingController(rootView: contentView)

To ensure proper layout and sizing behavior, we utilize the sizingOptions property of the UIHostingController (available from iOS 16.0 onwards). By setting it to .intrinsicContentSize, the hosting controller automatically adjusts its size based on the intrinsic content size of the SwiftUI view.

if #available(iOS 16.0, *) {
hostingController.sizingOptions = .intrinsicContentSize
} else {
// iOS versions prior to 16.0
state.objectWillChange
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.hostingController?.view.invalidateIntrinsicContentSize()
}
.store(in: &cancellables)
}

For iOS version previous to iOS 16 we need to add some hack to invalidateIntrinsicContentSize() each time ObservableObject will change.

Finally, we add the hosting view to the UISelectCountryButton’s subviews and set up the necessary auto-layout constraints.

if let hostingView = hostingController.view {
addSubview(hostingView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

By adding the hosting view as a subview and applying appropriate constraints, we ensure that the SwiftUI view is properly positioned and sized within the UISelectCountryButton.

In the next section, we will explore an alternative approach to passing data from SwiftUI to UIKit, broadening our understanding of the interoperability between these two frameworks.

3.2 Resetting UIHostingController rootView.

The second approach on the other hand instead of levaraging ObservableObject is resetting UIHostingController rootView property each time there is the change in UIKit UIView.

Firstly we have contentView property that instantiates SwiftUI view.

private var contentView: AnyView {
SelectCountryButton(
title: title,
selectedCountry: .init(
get: { [weak self] in self?.selectedCountry },
set: { [weak self] in
self?.selectedCountry = $0
self?.onSelection?($0)
}
),
placeholder: placeholder,
listTitle: listTitle,
fieldState: .init(
get: { [weak self] in self?.fieldState ?? .normal },
set: { [weak self] in self?.fieldState = $0 }
),
allowedCountries: allowedCountries,
borderColor: buttonBorderColor?.color,
backgroundColor: buttonBackgroundColor?.color
)
.disabled(!isEnabled)
.toAnyView
}

Then we use this contentView to properly configure it and embed inside UIKit UIView.

private func configure() {
if let hostingController {
hostingController.rootView = contentView
hostingController.view.invalidateIntrinsicContentSize()
} else {
let hostingController = UIHostingController(rootView: contentView)

if let hostingView = hostingController.view {
addSubview(hostingView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

self.hostingController = hostingController
}
}

Otherwise in the first approach with ObservableObject we not only need to call this configure method in init and awakeFromNib.

init(
title: String? = nil,
listTitle: String? = nil,
selectedCountry: CountryProtocol? = nil,
placeholder: String? = nil,
allowedCountries: Daystrom.AllowedCountries = .all,
borderColor: UIColor? = nil,
backgroundColor: UIColor? = nil
) {
super.init(frame: .constraintSolving)
//...

configure()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func awakeFromNib() {
super.awakeFromNib()
configure()
}

But also from places were UIKit view properties are changed. Like changes to selectedCountry or fieldState properties.

 var selectedCountry: CountryProtocol? {
didSet {
configure()
}
}

func setError(_ error: String?) {
if let error {
fieldState = fieldState.updated(with: .error(withText: error))
} else {
fieldState = fieldState.updated(with: .normal)
}

configure()
}

Here is the entire implementation:

@IBDesignable
final class UISelectCountryButton2: BaseView, IBLocalization {

@IBInspectable
var l10nPlaceholder: String {
get {
messageForSetOnlyProperty()
}
set {
placeholder = newValue.localized()
}
}

@IBInspectable
var l10nTitle: String {
get {
messageForSetOnlyProperty()
}
set {
title = newValue.localized()
}
}

@IBInspectable
var l10nListTitle: String {
get {
messageForSetOnlyProperty()
}
set {
listTitle = newValue.localized()
}
}

var isEnabled = true {
didSet {
configure()
}
}

var selectedCountry: CountryProtocol? {
didSet {
configure()
}
}

var onSelection: ((CountryProtocol?) -> Void)?

private(set) var fieldState = Daystrom.FieldState(state: .normal)
private var allowedCountries: Daystrom.AllowedCountries = .all

private var title: String?
private var listTitle: String?
private var placeholder: String?
private var buttonBorderColor: UIColor?
private var buttonBackgroundColor: UIColor?

private var hostingController: UIHostingController<AnyView>?

init(
title: String? = nil,
listTitle: String? = nil,
selectedCountry: CountryProtocol? = nil,
placeholder: String? = nil,
allowedCountries: Daystrom.AllowedCountries = .all,
borderColor: UIColor? = nil,
backgroundColor: UIColor? = nil
) {
super.init(frame: .constraintSolving)
self.title = title
self.listTitle = listTitle
self.selectedCountry = selectedCountry
self.placeholder = placeholder
self.allowedCountries = allowedCountries
self.buttonBorderColor = borderColor
self.buttonBackgroundColor = backgroundColor

configure()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func awakeFromNib() {
super.awakeFromNib()
configure()
}

func setError(_ error: String?) {
if let error {
fieldState = fieldState.updated(with: .error(withText: error))
} else {
fieldState = fieldState.updated(with: .normal)
}

configure()
}

private func configure() {
if let hostingController {
hostingController.rootView = contentView
hostingController.view.invalidateIntrinsicContentSize()
} else {
let hostingController = UIHostingController(rootView: contentView)

if let hostingView = hostingController.view {
addSubview(hostingView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

self.hostingController = hostingController
}
}

private var contentView: AnyView {
SelectCountryButton(
title: title,
selectedCountry: .init(
get: { [weak self] in self?.selectedCountry },
set: { [weak self] in
self?.selectedCountry = $0
self?.onSelection?($0)
}
),
placeholder: placeholder,
listTitle: listTitle,
fieldState: .init(
get: { [weak self] in self?.fieldState ?? .normal },
set: { [weak self] in self?.fieldState = $0 }
),
allowedCountries: allowedCountries,
borderColor: buttonBorderColor?.color,
backgroundColor: buttonBackgroundColor?.color
)
.disabled(!isEnabled)
.toAnyView
}
}

In the above implementation, we check if the hostingController already exists. If it does, it means the SwiftUI view is already embedded, and we update the rootView property of the hosting controller with the updated contentView. This allows the SwiftUI view to reflect the changes made to properties like selectedCountry or fieldState. Additionally, we call invalidateIntrinsicContentSize() on the hosting controller's view to ensure proper layout and sizing.

If the hostingController doesn't exist, we create a new instance and set the rootView to the initial contentView. We then add the hosting view as a subview to the UISelectCountryButton and set up appropriate auto-layout constraints. This step ensures that the SwiftUI view is properly positioned and sized within the UISelectCountryButton.

By updating the properties like selectedCountry, fieldState, or isEnabled, the configure() method ensures that the SwiftUI view receives the updated values, triggering appropriate UI changes. This bidirectional communication allows seamless interaction between the UIKit-based component and the embedded SwiftUI view.

4. To sum up

In conclusion, both approaches discussed in this article — leveraging the power of ObservableObject or resetting SwiftUI views on UIHostingController rootView — provide effective ways to pass data and enable bidirectional communication between the two frameworks. By embracing these techniques, developers can leverage the strengths of both UIKit and SwiftUI, creating dynamic and interactive user interfaces that seamlessly integrate with existing UIKit codebases. Whether you choose to adopt ObservableObject or reset UIHostingController rootView, you’ll have the tools to bridge the gap between UIKit and SwiftUI, unlocking a world of possibilities for your app development. Embrace the best of both worlds and elevate your iOS development journey by embracing the seamless integration of UIKit and SwiftUI.

--

--