Power of Swift Macros

As iOS devs, sometimes We have to write repeatable code. This tedious task is very boring, error-prone, and unsatisfying. Instead of falling into despair we can elevate our experience with Swift Macros! We will create a macro which will automate creating SwiftUI representation to a UIKit view! For the purpose of our exercise, we will handle only simple views, without bindings or init parameters.

First steps

To create Swift macro, we have to create new Swift Package. I'll use the provided option to bootstrap the repository:

swift package init --type macro --name RepresentableMacros

This will create skeleton of our repo. Open package in Xcode and we will start coding!

Macro code

In the project, find folder named RepresentableMacrosMacros, I know... not perfect. In this folder create new file - ViewRepresentable.swift

This will be our base file for logic.

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public enum ViewRepresentableError: CustomStringConvertible, Error {
    case onlyApplicableToView
    
    public var description: String {
        switch self {
        case .onlyApplicableToView:
            "@ViewRepresentable is only applicable to UIKit View."
        }
    }
}

public struct ViewRepresentable: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        // here will be our magic code
        return []
    }
}

It looks intimidating, but fear not, it is not that complex. We will be only interested in declaration parameter, which will give us details about entity macro was added to.

Extracting metadata

We have to extract metadata from declaration. We will need view's name and access level:

guard let viewClass = declaration.as(ClassDeclSyntax.self),
              viewClass.inheritanceClause?
                .inheritedTypes
                .contains(where: { "UIView" == $0.type.trimmedDescription }) == true else {
            throw ViewRepresentableError.onlyApplicableToView
        }
        
        let classtName = viewClass.name.text
        let structName = "\(classtName)SwiftUIView"
        let accessLevel = viewClass.modifiers.first(
            where: { modifier in
                [Keyword.public, .private, .internal, .fileprivate, .package].contains(
                    where: { keyword in
                        modifier.name.text == "\(keyword)"
                    }
                )
            }
        )?.name.text ?? "\(Keyword.internal)"

First, we check if our declaration is class, as UIKit Views are classes. Second, we check if class inherits from UIView.

After that, we can extract the class name, create a name for our SwiftUI view, and find the access level of the view.

Building code

We have everything to build our code!

let buildCode = """
\(accessLevel) struct \(structName): UIViewRepresentable {
\(accessLevel) func makeUIView(context: Context) -> \(classtName) {
return \(classtName)()
}
\(accessLevel) func updateUIView(
_ uiView: \(classtName),
context: Context
) {
}
}
"""
return [DeclSyntax(stringLiteral: buildCode)]

It's as simple as that!

Registering macro

In RepresentableMacros.swift add two lines of code:

@attached(peer, names: suffixed(SwiftUIView))
public macro ViewRepresentable() = #externalMacro(module: "RepresentableMacrosMacros", type: "ViewRepresentable")

Modify RepresentableMacrosMacro.swift to include new macro:

@main
struct RepresentableMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        ...,
        ViewRepresentable.self
    ]
}

We're almost there!

Testing

let's test our code! In RepresentableMacrosTests add test methods to do just that!

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest

// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests.
#if canImport(RepresentableMacrosMacros)
import RepresentableMacrosMacros

let testMacros: [String: Macro.Type] = [
    "viewRepresentable": ViewRepresentable.self,
]
#endif

final class RepresentableMacrosTests: XCTestCase {
    func testMacro() throws {
        #if canImport(RepresentableMacrosMacros)
        assertMacroExpansion(
            """
            @viewRepresentable
            final class TestView: UIView {
            }
            """,
            expandedSource: """
            final class TestView: UIView {
            }

            internal struct TestViewSwiftUIView: UIViewRepresentable {
                internal func makeUIView(context: Context) -> TestView {
                    return TestView()
                }
                internal func updateUIView(_ uiView: TestView, context: Context) {
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
    
    func testPublicMacro() throws {
        #if canImport(RepresentableMacrosMacros)
        assertMacroExpansion(
            """
            @viewRepresentable
            final public class TestView: UIView {
            }
            """,
            expandedSource: """
            final public class TestView: UIView {
            }

            public struct TestViewSwiftUIView: UIViewRepresentable {
                public func makeUIView(context: Context) -> TestView {
                    return TestView()
                }
                public func updateUIView(_ uiView: TestView, context: Context) {
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }

    func testNotAViewMacro() throws {
        #if canImport(RepresentableMacrosMacros)
        assertMacroExpansion(
            """
            @viewRepresentable
            final public class TestView {
            }
            """,
            expandedSource: """
            final public class TestView {
            }
            """,
            diagnostics: [
                DiagnosticSpec(message: "@ViewRepresentable is only applicable to UIKit View.", line: 1, column: 1)
            ],
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }}

We cover all cases: no modifier, final modifier, checking for visibility modifiers, and using non-view to test the diagnostic message!

Usage

We can use the macro for our simple view, I'll post Xcode screenshot to demonstrate the expanded state:

Expanded state for the macro

Bonus for the more curious

We can use more structured, and type-safe code to produce code. This requires us to create DeclSyntax entity by hand, using builders and initializers. Let's replace our string with more readable code:

let structDecl = StructDeclSyntax(
            modifiers: DeclModifierListSyntax(itemsBuilder: {
                DeclModifierSyntax(name: TokenSyntax(stringLiteral: accessLevel))
            }),
            name: .identifier(structName),
            inheritanceClause: InheritanceClauseSyntax(inheritedTypesBuilder: {
                InheritedTypeSyntax(type: IdentifierTypeSyntax(name: TokenSyntax(stringLiteral: "UIViewRepresentable")))
            }),
            memberBlock: try MemberBlockSyntax(membersBuilder: {
                try FunctionDeclSyntax(SyntaxNodeString(stringLiteral: "\(accessLevel) func makeUIView(context: Context) -> \(classtName)")) {
                    StmtSyntax("return \(raw: classtName)()")
                }
                try FunctionDeclSyntax(SyntaxNodeString(stringLiteral: "\(accessLevel) func updateUIView(_ uiView: \(classtName), context: Context)")) {
                    //empty
                }
            }))
        return [DeclSyntax(structDecl)]

This way, we see creating struct syntax, added modifiers, name, inheritance clause, and members. Each member has its own body builder.

Conclusions

Swift macros are very powerful tools to make our life easier, hope you like it, and learned something new!