[SC]()

iOS. Apple. Indies. Plus Things.

Vending Non-Mutating Data in SwiftUI

// Written by Jordan Morgan // Aug 7th, 2023 // Read it in about 2 minutes // RE: SwiftUI

This post is brought to you by Emerge Tools, the best way to build on mobile.

It’s well noted that imperative interface frameworks tend to have their bugs manifested from mutating data. It’s all too common to display stale or incorrect interface values which don’t accurately reflect their backing view model. That’s just one scenario where you can get into trouble, but we could go on with the rough edges of imperative user interface frameworks.

Today, SwiftUI has swept in and practically eliminated that entire class of error. I’ll avoid writing another treacly blog post proclaiming declarative interface advantages though. To wit - I’m here to do somewhat of the opposite. I recently hit a situation I hadn’t yet encountered in my years of SwiftUI.

Data, but the kind that doesn’t mutate at all.

How do you do this? Is there an idiomatic SwiftUI API for this? The answer wasn’t immediately obvious to me.

Consider any composing experience, the likes of which you’d see in virtually any app where you create a post, edit some details, append notes or anything else in-between:

Three composing experiences on iOS.

I had a similar case recently - our composing experience for Buffer. Our nascent SwiftUI version is contained in a Swift package, used in extensions and our main app target. Today, it takes in “population” data in our current Objective-C and UIKit based code. It has metadata like user information or domain-specific identifiers, whether or not you’re editing a post or creating a new one. That kind of stuff.

What’s interesting here is that none of that changes.

The composer opens, uses that metadata for a lot of things - but it never changes it.

So, What’s a SwiftUI Version of That?

Usually, in a SwiftUI world - a simple top level state object is at play here:

struct MyView: View {
	@StateObject private var metadata: Metadata = .init()

	var body: some View {
		ViewsAndMoreViews()
			.environmentObject(metadata)
	}
}

There are so many SwiftUI tools available to manage mutating data from bindings, state and the more modern Observation framework and its macro.

That’s all well and good, but we’re coming in hot from an Objective-C call site - so this doesn’t really mesh. After quite a bit of back and forth with some other friends - we chatted about dependency injection methods, using other SwiftUI semantics but bridging them and some more thoughtful patterns.

I do want to point out that, true to this post’s title - you could use any of those to handle non-mutating data in SwiftUI. But remember where we are: Objective-C and UIKit land, needing to present a SwiftUI view wrapped in a hosting controller.

I like where we landed considering this, it’s technically a mix of dependency injection, just with SwiftUI flavorings: Create a custom environment key, populate it when creating the SwiftUI view, and then pass it back wrapped in a hosting controller.

Here’s how it works. The Objective-C code calls into a Swift shim that sets up a metadata object from anything the Objective-C caller feeds it. Then, it takes care of creating the view and the hosting controller. We present it and we’re done, the immutable data is available all over.

What follows is a simplified example.

The Approach

First, we’ll need the environment key. This little mechanism lets us reach into this data anywhere in our SwiftUI views. That’s perfect for us, none of this data mutates. It allows us to play nice with previews by setting up lightweight defaults, we don’t need to pass it in each view’s initializer - the list goes on:

private struct ComposeMetadataKey: EnvironmentKey {
    static var defaultValue: ComposingMetadata { .empty }
}

public extension EnvironmentValues {
    var composingMetadata: ComposingMetadata {
        get { self[ComposeMetadataKey.self] }
        set { self[ComposeMetadataKey.self] = newValue }
    }
}

Now, we can peek into it anywhere we need that metadata:

public struct ChannelListView: View {
    @Environment(\.composingMetadata) private var metadata: ComposingMetadata
    
    public init() {}
    
    public var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing:4) {
            	// Access the metadata
                ForEach(metadata.channels) { channel in
                    ChannelGroupListItemView(channel: channel)
                }
            }
            .padding()
        }
    }
}

Now, back in Objective-C - we use a shim to easily call into SwiftUI and get the metadata assigned:

func createComposerViewController(with info: Info) -> UIViewController {    
    let metadata = SwiftUIComposer.ComposingMetadata(with: info)
    
    let composeView = SwiftUIComposer.ComposerView()
        .environment(\.composingMetadata, metadata)
    let host = UIHostingController(rootView: composeView)
    
    return host
}

And then, over back in dino land:

SwiftUIComposerShim *shim = [SwiftUIComposerShim new];
UIViewController *composingVC = [shim createComposerViewControllerWith: composingInfo];

[viewController presentViewController:composingVC 
		        animated:YES 
		        completion:nil];

Voila.

That’s how I ended up solving it, and it worked quite well. If you have other solutions, I’d love to hear them.

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.