davey

Bridging UIKit and SwiftUI in a Nitro Module

In react-native-nitro-symbols, a library I built for using Apple’s SF Symbols in React Native/Expo apps, I added support for the new .drawOn animations available for iOS 26+ devices. I bridged UIKit (Apple's legacy imperative UI framework) and SwiftUI (the newer declarative framework) in order to build a rich, native symbol experience for React Native apps. The project involved using Nitro Modules, a native module system built by Marc Rousavy and the team at Margelo which leverages the JSI (Javascript Interface), allowing native (Swift, Kotlin & C++) code to be called by JavaScript/TypeScript.

The initial idea I had for building this project was to return an Image view in SwiftUI straight to React Native, and through a set of props, I'd control its behavior. This immediately proved to be impossible because React Native's rendering system - the Fabric Renderer - supports Apple's legacy UIKit framework and not the newer SwiftUI. Fortunately enough, bridging the two frameworks is possible through the UIHostingController, which is a UIKit view controller that manages a SwiftUI view hierarchy.

That allowed me to define a Swift struct such as:

import SwiftUI

struct NativeSwiftUIView: View {
    var body: some View {
        // rest of the code
    }
}

And then setting it as the root view in the UIHostingController. See HybridSymbolView.swift for the code.

One might wonder, how do we update the Image view whenever a prop is set? Well, using the didSet property observers in our HybridSymbolView class, we can notice changes to props, and using a function, we can update our native SwiftUI view and set the updated view as the new root view in the UI hosting controller.

To avoid unnecessarily updating props that haven't changed, I do a little check in the property observer to be sure the current prop is a new value before it changes. Example:

var symbolName: String = "" {
    didSet { 
        if oldValue != symbolName { 
            updateSwiftUIView() 
        } 
    }
}

Another thing worth considering was that when I was defining props for the native SwiftUI view, I had a little problem defining the type of effect - which is a prop for adding an animation to the SF Symbol - because SwiftUI doesn't provide a generic type for effect. Rather, it's based on the specific effect such as .pulse and .drawOn which are themselves enums.

The fix was to create a custom SymbolEffectType enum:

enum SymbolEffectType {
    case none
    case bounce
    case pulse
    case drawOn
    // TO-DO: Add others as needed
}

And then using a @ViewBuilder and a switch statement, I dynamically apply the effects to the view based on the user's choice:

@ViewBuilder
func applyEffect(to view: some View) -> some View {
    switch effect {
    case .bounce:
        if #available(iOS 26.0, *) {
            view.symbolEffect(.bounce, isActive: isAnimating)
        } else {
            // Fallback on earlier versions
        }
    case .pulse:
        view.symbolEffect(.pulse, isActive: isAnimating)
    case .drawOn:
        if #available(iOS 26.0, *) {
            view.symbolEffect(.drawOn, isActive: isAnimating)
        } else {
            // Fallback on earlier versions
        }
    case .none:
        view
    }
}

Another question might arise: "How do we map from JS strings to Swift enums?"

Well, Nitro Modules give us an optional code generation tool called nitrogen which allows one to define types in typescript and then those types automatically get converted to their equivalents(in a way) in Swift.

For example, today, I decided to make the api for the module more type safe so I made a SymbolScale type (and types for other props, see the types folder in github). The SymbolScale type is a union of strings and nitrogen converted that union to an enum in Swift. So whenever a user puts in a string in the scale prop, Nitro would automatically convert this to a Swift enum which I then map to the enums for the SwiftUI Image.Scale type. See the MappingFuncs.swift file for mapping functions. Example:

func mapScale(_ value: SymbolScale?) -> Image.Scale {
    guard let value = value else { return .medium }
    switch value {
    case .small: return .small
    case .large: return .large
    case .medium: return .medium
    }

I also encountered another issue related to type safety. I was trying to make the symbolName prop type-safe for all the sf-symbols names using sf-symbol-typescript by Fernando Rojo, but some of the symbols contained number prefixes which led to nasty errors during build time, because nitrogen converted these strings to enums and in Swift, enums cannot begin with numbers. So the only way I could support type safety for the symbol names while keeping Nitro happy was to make sure the symbolName prop the "imported component" had was typed with SFSymbol, and the props parsed by nitrogen was typed with string. Example:

import { getHostComponent } from 'react-native-nitro-modules'
import type { SymbolProps } from '../specs/SymbolView.nitro'
import type { SymbolPropsMethods } from '../specs/SymbolView.nitro'
import type { ComponentType } from 'react'
import type { SFSymbol } from 'sf-symbols-typescript'
import SymbolViewConfig from '../../nitrogen/generated/shared/json/SymbolViewConfig.json'

const SymbolViewNative = getHostComponent<SymbolProps, SymbolPropsMethods>(
    'SymbolView',
    () => SymbolViewConfig
)

export type SymbolViewProps = Omit<SymbolProps, 'symbolName'> & {
    symbolName: SFSymbol;
}

// this uses the right SFSymbol type!
export const SymbolView = SymbolViewNative as ComponentType<SymbolViewProps>

Now when users use the SymbolView in their code, the symbolName prop is properly typed and even autocompletes as they type!

Our symbols need to be colored right? To do that, the library currently supports only hex strings, and to map that to Swift, I extended the UIColor struct to support converting hex strings to numbers that point to the right color in SwiftUI.

EDIT: The library now supports more than hex strings using react native's ColorValue prop! See the Github repo for changes.

Another error I encountered - but this time after building the module - was "Invariant Violation: View config getter callback... received undefined". This occurred when the native component registry wanted to register my component. This occurs when the native view is registered against a different React Native instance. Ensure the module does not list react-native as a dependency; it should resolve to the host app’s instance.

That's all for today, this was great fun to build and I learned a lot. You can see the demo of it on X here.

This library (including its example app) is open source and is also available to download on npm. See my GitHub for it here, and my other open source libraries such as expo-speech-transcriber for realtime and file-based on-device transcription using SFSpeechRecognizer and the new SpeechAnalyzer API.

Till next time, Happy Coding. 🛠️