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:
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!