DEV Community

Anton Sergeev
Anton Sergeev

Posted on

Advanced Containers in SwiftUI

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.

Notification

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

Show notification

Let's split this task in two.

  1. Show UIViewControllers with notification content.
  2. Provide this UIViewControllers 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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)