This answer is posted as a complement to the solution @oivvio linked in the answer from @arsenius, about how to use a UITabController
filled with UIHostingController
s.
The answer in the link has one issue: if the children views have external SwiftUI dependencies, those children will not be updated. This is fine for most cases where children views only have internal states. However, if you are like me, a React developer who enjoys a global Redux system, you will be in trouble.
In order to resolve the issue, the key is to update rootView
for each UIHostingController
every time when updateUIViewController
is called. My code also avoids creating unnecessary UIView
or UIViewControllers
: they are not that expensive to create if you do not add them to the view hierarchy, but still, the less waste the better.
Warning: the code does not support a dynamic tab view list. To support that correctly, we would like to identify each child tab view and do an array diff to add, order, or remove them correctly. That can be done in principle but goes beyond my need.
We first need a TabItem
. It is made this way for the controller to grab all the information, without creating any UITabBarItem
:
struct XNTabItem: View {
let title: String
let image: UIImage?
let body: AnyView
public init<Content: View>(title: String, image: UIImage?, @ViewBuilder content: () -> Content) {
self.title = title
self.image = image
self.body = AnyView(content())
}
}
We then have the controller:
struct XNTabView: UIViewControllerRepresentable {
let tabItems: [XNTabItem]
func makeUIViewController(context: UIViewControllerRepresentableContext<XNTabView>) -> UITabBarController {
let rootController = UITabBarController()
rootController.viewControllers = tabItems.map {
let host = UIHostingController(rootView: $0.body)
host.tabBarItem = UITabBarItem(title: $0.title, image: $0.image, selectedImage: $0.image)
return host
}
return rootController
}
func updateUIViewController(_ rootController: UITabBarController, context: UIViewControllerRepresentableContext<XNTabView>) {
let children = rootController.viewControllers as! [UIHostingController<AnyView>]
for (newTab, host) in zip(self.tabItems, children) {
host.rootView = newTab.body
if host.tabBarItem.title != host.tabBarItem.title {
host.tabBarItem.title = host.tabBarItem.title
}
if host.tabBarItem.image != host.tabBarItem.image {
host.tabBarItem.image = host.tabBarItem.image
}
}
}
}
Children controllers are initialized in makeUIViewController
. Whenever updateUIViewController
is called, we update each children controller's root view. I did not do a comparison for rootView
because I feel the same check would be done at the framework level, according to Apple's description about how views are updated. I might be wrong.
To use it is really simple. The below is partial code I grabbed from a mock project I am currently doing:
class Model: ObservableObject {
@Published var allHouseInfo = HouseInfo.samples
public func flipFavorite(for id: Int) {
if let index = (allHouseInfo.firstIndex { $0.id == id }) {
allHouseInfo[index].isFavorite.toggle()
}
}
}
struct FavoritesView: View {
let favorites: [HouseInfo]
var body: some View {
if favorites.count > 0 {
return AnyView(ScrollView {
ForEach(favorites) {
CardContent(info: $0)
}
})
} else {
return AnyView(Text("No Favorites"))
}
}
}
struct ContentView: View {
static let housingTabImage = UIImage(systemName: "house.fill")
static let favoritesTabImage = UIImage(systemName: "heart.fill")
@ObservedObject var model = Model()
var favorites: [HouseInfo] {
get { model.allHouseInfo.filter { $0.isFavorite } }
}
var body: some View {
XNTabView(tabItems: [
XNTabItem(title: "Housing", image: Self.housingTabImage) {
NavigationView {
ScrollView {
ForEach(model.allHouseInfo) {
CardView(info: $0)
.padding(.vertical, 8)
.padding(.horizontal, 16)
}
}.navigationBarTitle("Housing")
}
},
XNTabItem(title: "Favorites", image: Self.favoritesTabImage) {
NavigationView {
FavoritesView(favorites: favorites).navigationBarTitle("Favorites")
}
}
]).environmentObject(model)
}
}
The state is lifted to a root level as Model
and it carries mutation helpers. In the CardContent
you could access the state and the helpers via an EnvironmentObject
. The update would be done in the Model
object, propagated to the ContentView
, with our XNTabView
notified and each of its UIHostController
updated.
EDITs:
- It turns out that
.environmentObject
can be put at the top level.
ScrollView
in How to make a SwiftUI List scroll automatically? – Daric