Containers in iOS is used to organize other views. The great example of container is NavigationStack
and its counterpart from UIKit UINavigationContainer
.
Containers don't show any useful content for a user by itself. Their main goal is to show a user's content in some specific way. For instance, NavigationStack
shows a topmost view, decorate it with a navigation bar and provide logic to navigate back and forth.
A creating reusable container views video by Martin Barreto inspired me to deep dive in different approaches of creating containers in SwiftUI. In the video Martin show's how to create a container with plain SwiftUI. In this post I'm showing how interop with UIKit can help us to save our time and simplify our code.
To make things as simple as possible we create NotificationView
. This container can show our interface and decorate it with Music app styled notifications.
You may see full code in a github repo.
Container's API
Instantiation
Let's initialize our container the same way as we initialize NavigationStack
. To control current state we pass Binding
as a parameter to an initializer. With the binding we can show and hide notifications.
@StateObject private var notificationManager = NotificationManager()
var body: some View {
NotificationView($notificationManager.current) {
NavigationStack {
FruitListView()
}
.environmentObject(notificationManager)
}
}
NavigationStack
can be instantiate without external state for its stack. It's still useful because it hasNavigationLink
that can behave as button and hide all state logic. While this is a nice design for such a common component asNavigationStack
, our container can't use this API because it's not common to show notifications on a tap.
Register notifications
Different part of an app can have their specific types of notifications. Furthermore, different screens can be developed in different modules, times and teams. So once again let's use the same API as NavigationStack
use. With navigationDestination(for:destination:)
method we can register different screen that can be pushed on stack.
struct FruitListView: View {
@EnvironmentObject var notificationManager: NotificationManager
var body: some View {
List {
ForEach(Fruit.allFruits, id: \.emoji) { fruit in
Button(fruit.name) {
notificationManager.value = fruit
}
}
}
.notification(for: Fruit.self) { fruit in
// notification content
}
}
}
We register a notification for any type and after that it can be shown by setting a binding value. We can register many types of notifications.
Implementation
Wrap the content
Let's start with a simple task of showing main interface inside notification container.
struct NotificationViewControllerWrapper<Content: View>: UIViewControllerRepresentable {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> NotificationViewController {
let contentController = UIHostingController(rootView: content)
let controller = NotificationViewController(content: contentController)
return controller
}
}
NotificationViewController
is an actual place where all the presentation logic lies. At the moment it's just showing content without any decoration.
class NotificationViewController: UIViewController {
private let content: UIViewController
init(content: UIViewController) {
self.content = content
super.init(nibName: nil, bundle: nil)
addChild(content)
content.didMove(toParent: self)
}
override func loadView() {
view = UIView()
view.addSubview(content.view)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
content.view.frame = view.bounds
}
}
If we try to use our brand new view, we'll find out that NavigationStack
ignores safe area insets while our container does not. It's can be tricky to fix this. For instance, the straightforward approach is not useful.
// this approach doesn't work
func makeUIViewController(context: Context) -> NotificationViewController {
let content = content
.ignoreSafeArea() // <- ignoring safe area
let contentController = UIHostingController(rootView: content)
let controller = NotificationViewController(content: contentController)
return controller
}
To make our view to ignore safe area we should ask it to do this from the outside, so let's wrap it in another view. Check this great article for better understanding how SwiftUI layout engine works.
struct NotificationView<Root: View>: View {
private let root: () -> Root
init(@ViewBuilder root: @escaping () -> Root) {
self.root = root
}
var body: some View {
NotificationViewControllerWrapper(content: root)
.ignoresSafeArea()
}
}
Register notifications
Any view inside NotificationView
can register its own notifications. To do this let's send an environment object down to hierarchy.
struct RegistryModifier<T, Note: View>: ViewModifier {
@Environment(\.notificationRegistry) var registry
let note: (T) -> Note
func body(content: Content) -> some View {
content
.onAppear {
registry?.register(for: T.self, content: note)
}
.onDisappear {
registry?.unregister(T.self)
}
}
}
extension View {
func notification<T, Content: View>(for type: T.Type, @ViewBuilder content: @escaping (T) -> Content) -> some View {
modifier(RegistryModifier(note: content))
}
}
func makeUIViewController(context: Context) -> NotificationViewController {
let content = self.content
.environment(\.notificationRegistry, context.coordinator.registry) // <- adding to hierarchy
let contentController = UIHostingController(rootView: content)
let controller = NotificationViewController(content: contentController)
return controller
}
But what should we send? We want to have ability to add and remove notifications for any type. Also we don't want that registering another notification start view update cycle. So notificationRegistry
should have pair of non mutating methods. The most simple way to do this is using reference type.
class NotificationRegistry {
// AnyView is for simplicity, the better way is to erase type directly to UIViewController
private var storage = [String: (Any) -> AnyView]()
func register<T, Content: View>(for type: T.Type, content: @escaping (T) -> Content) {
let key = String(reflecting: type)
let value: (Any) -> _ = {
AnyView(content($0 as! T))
}
storage[key] = value
}
func unregister<T>(_ type: T.Type) {
let key = String(reflecting: type)
storage[key] = nil
}
}
Show notification
Let's split this task in two.
- Show
UIViewController
s with notification content. - Provide this
UIViewController
s and calculate size of their content.
First task is quiet straightforward. We should add child view controller, layout its view and animate transition. So let's add two methods to NotificationViewController
.
func removeNotification() {
// remove a visible notification
}
func showNotification(_ viewController: UIViewController, size: CGSize) {
// show the new notification or add transition from an old notification to the new one
}
In fact the second task is even more simple. If we add Coordinator
to NotificationViewControllerWrapper
SwiftUI will create it for us and we can use it to update our view. Let's add registry inside coordinator.
class Coordinator {
let registry = NotificationRegistry()
}
From now we can use the registry to create notification views when they need to be shown.
Let's add notification value to NotificationViewControllerWrapper
.
private var value: Any?
And registry method to get a view from it.
class NotificationRegistry {
private var storage = [String: (Any) -> AnyView]()
func view(for value: Any) -> AnyView? {
let key = String(reflecting: type(of: value))
guard let factory = storage[key] else {
return nil
}
return factory(value)
}
}
We are ready to implement updateUIViewController
method.
func updateUIViewController(_ uiViewController: NotificationViewController, context: Context) {
if let value = value, let view = context.coordinator.registry.view(for: value) {
let notificationViewController = UIHostingController(rootView: view)
let size = notificationViewController.sizeThatFits(in: uiViewController.view.bounds.insetBy(dx: 20, dy: 100).size)
uiViewController.showNotification(notificationViewController, size: size)
} else {
uiViewController.removeNotification()
}
}
We use sizeThatFits(in:)
method of UIHostingController
to obtain an actual size of notification view.
In summary
I hope you found this article useful. On this simple example you may learn how to create root components of an app.
SwiftUI is a great UI framework. And one of the its main feature is interop with UIKit. Together with data flow features like environment and preferences it can help us to create complicated UIKit components and use it inside modern SwiftUI apps. Even if this components as big as custom tab bars, page views or custom presentations and transitions.
Top comments (0)