Advanced Containers in SwiftUI
Tags: swiftui, ios, uikit, swift
Published: 2023-01-19 18:41:47

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](https://youtu.be/kJl3eMCoSDo) video by [Martin Barreto](https://twitter.com/mtnBarreto) 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.

![Notification](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/46s4bw0d296ldzdp12mz.png)

You may see full code in a github repo.

{% embed https://github.com/antonsergeev88/swiftui-container-example %}

## 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.

```swift
@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 has `NavigationLink` that can behave as button and hide all state logic. While this is a nice design for such a common component as `NavigationStack`, 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.

```swift
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.

```swift
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.

```swift
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.

```swift
// 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](https://swiftui-lab.com/frame-behaviors/) for better understanding how SwiftUI layout engine works.

```swift
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.

```swift
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.

```swift
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.
1. Show `UIViewController`s with notification content.
2. 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`.

```swift
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.

```swift
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`.

```swift
private var value: Any?
```

And registry method to get a view from it.

```swift
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.

```swift
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.