Introduction

The goal of this article is to create an iOS/SwiftUI application listing the available views in our project, and on tap that specific view is shown. This way a gallery of available views or components can be shown to a client.

What makes it hard?

The application needs to gather a list of Views to be showcased. This can be a hardcoded list. In order to make it a dynamic it’s desired for Swift to automatically find the Structs in the built conforming to the View protocol.

In C#, another strongly typed language, it can be done like this (source):

var type = typeof(IMyInterface);
var types = AppDomain.CurrentDomain.GetAssemblies()
    .SelectMany(s => s.GetTypes())
    .Where(p => type.IsAssignableFrom(p));

Unfortunately in Swift things are not that easy. There is the Objective-C function ‘obj_getClassList()’, but this doesn’t work for Structs. Meta programming is very limited.

Thanks to Zev Eisenberg for pointing out to a solution: the Sourcery library created by https://github.com/krzysztofzablocki/Sourcery.

Sourcery

Sourcery adds meta-programming for Swift and using Stencil templates it can generate code.

Installing Sourcery

Reading the sourcery documentation shows there are several ways to install sourcery. One way is to put the binaries in one of the project folders. In this article the binaries are installed globally with homebrew. The command for installing sourcery then is brew install sourcery.

If you have an XCode beta edition and encounter an error like Source/SourceKittenFramework/library_wrapper.swift, line 66 ilegal hardware instruction this github issue explains how to tell xcode-select the location of another xcode binary.

Running sourcery

Sourcery normally runs as follows, read the documentation for details:

$ ./sourcery --sources <sources path> --templates <templates path> --output <output path>

There are different ways to run Sourcery. This article assumes running it before the XCode built. If the --watch parameter is added it watches your source folders and auto-generates the Swift file. Keep in mind there are other ways, like having a configuration file for Sourcery, or a pre-built step in XCode.

Building the template

So a Swift function is needed for returning the views in the project. With Sourcery, Swift code can be generated with Stencil templates. The core of the solution is in the template below:

 
{% for type in types.implementing.View %}
    {{type.name}}
{% endfor %}

Sourcery is able to scan your source files, in the example above it will list all types conforming to the protocol ‘View’.

Known types and unknown types

Sourcery scans all Swift files in the folder passed to the --sources parameter, meaning all it can identify are types in source files within the files of this folder.

Howwever, Sourcery cannot identify types in imported compiled code like Foundation or SwiftUI, meaning it cannot find the View protocol from SwiftUI framework and Sourcery will give ‘Unknown type’ errors. There is a github issue regarding this, but it can be solved with a workaround.

All what’s need to be done is creating an empty protocol which in the example needs the name ‘View’

protocol View {
}

The file holding this dummy interface cannot be in your Xcode project since it would interfere with the SwiftUI interface. So in the example its placed in the folderSourceryDummyTypes. This folder is outside the Xcode project. In order to let Sourcery know about this template, pass an extra --source argument to the Sourcery command with that folder.

In the example app of this article the full command is:

sourcery --sources ./Gallery/Shared --sources ./SourceryDummyTypes --templates ./Gallery/Shared/Sourcery --output ./Gallery/Shared/Generated --watch

The generated code

The complete template which will generate the code to find the Views is listed below:


import SwiftUI
import Foundation

class ViewFinder {
    
    static func buildViewFromViewInfo(viewInfo: ViewInfo) -> AnyView {
          switch viewInfo.view.self {
            {% for type in types.implementing.View %}
               case is {{type.name}}.Type: return AnyView( {{type.name}}() )
            {% endfor %}
            default: return AnyView(EmptyView())
        }
    }
    
    static func getViewInfos() -> [ViewInfo] {
        var myViewInfos: [ViewInfo] = [ViewInfo]()
        
        {% for type in types.implementing.View %}
        if ("{{type.name}}" != "ContentView") {
            myViewInfos.append(ViewInfo(id: "{{type.name}}", name: "{{type.name}}", view: {{type.name}}.self))
        }
        {% endfor %}
        
        return myViewInfos
    }
}

The method getViewInfos() finds all types implementing View (except ContentView) and wraps the information in a ViewInfo object. This object contains the name of the string and the concrete view type.

public struct ViewInfo: Identifiable {
    public var id: String
    public var name: String
    public var view: Any
}

The method buildViewFromViewInfo is an helper function which creates a usable SwiftUI View from the view type.

Note that the example XCode project contains three Views: CircleView, RectangleView and SquareView. Next to the getViewInfos function a buildViewFromViewInfo is generated to return a usable SwiftUI View from the view type.

The fully generated code is the ViewFinder class below:

import SwiftUI
import Foundation

class ViewFinder {
    static func buildViewFromViewInfo(viewInfo: ViewInfo) -> AnyView {
          switch viewInfo.view.self {
               case is CircleView.Type: return AnyView( CircleView() )
               case is ContentView.Type: return AnyView( ContentView() )
               case is RectangleView.Type: return AnyView( RectangleView() )
               case is SquareView.Type: return AnyView( SquareView() )
            default: return AnyView(EmptyView())
        }
    }
    static func getViewInfos() -> [ViewInfo] {
        var myViewInfos: [ViewInfo] = [ViewInfo]()
        if ("CircleView" != "ContentView") {
            myViewInfos.append(ViewInfo(id: "CircleView", name: "CircleView", view: CircleView.self))
        }
        if ("ContentView" != "ContentView") {
            myViewInfos.append(ViewInfo(id: "ContentView", name: "ContentView", view: ContentView.self))
        }
        if ("RectangleView" != "ContentView") {
            myViewInfos.append(ViewInfo(id: "RectangleView", name: "RectangleView", view: RectangleView.self))
        }
        if ("SquareView" != "ContentView") {
            myViewInfos.append(ViewInfo(id: "SquareView", name: "SquareView", view: SquareView.self))
        }
        return myViewInfos
    }
}

Now with the ViewFinder class and its functions generated, the app can be build.

The code of the ContentView of the app is listed below:

import SwiftUI

struct ContentView: View {
    let columns = [
        GridItem(.adaptive(minimum: 160))
    ]

    @State var viewInfos: [ViewInfo] = ViewFinder.getViewInfos()

    var body: some View {
        NavigationView {
            ScrollView {
                Spacer()
                    .frame(height: 50)
                LazyVGrid(columns: columns, alignment: HorizontalAlignment.leading, spacing: 20) {
                    ForEach(viewInfos) { viewInfo in //alternative is List
                        NavigationLink(destination: ViewFinder.buildViewFromViewInfo(viewInfo: viewInfo)
                             .frame(width: 50, height: 50)
                             .padding(.leading, 10)) {
                                Text(viewInfo.name)
                                    .font(.subheadline)
                                    .padding()
                                    .border(Color.black)
                                    .frame(alignment: .leading)
                        }
                        .navigationTitle("My View Gallery")
                    }
                }
                .padding(.horizontal)
            }
        }
    }
}

In the ContentView the viewInfos property gets filled with an array of ViewInfo objects which are retrieved from the generated getViewInfos() function.

Each of the elements of this array is looped over within a ForEach loop, and the buildViewFromViewInfo is called to create a presentable SwiftUI View.

The NavigationView provides a way to quickly check a view and go back to the gallery overview. With the code in place, an iOS application has been built, which shows the names of all views, and on tap, it navigates to that specific view.

A quick impression is shown below:

Conclusion and further

Sourcery provides us a great way for meta-programming and makes it possible to find all types implementing a type. In this article it’s shown how to generate the code to find all types implementing SwiftUI’s View protocol, meaning all concrete views.

With this code in place, an iOS gallery app is created, making it possible to quickly showcase your views on your phone. After every new build with the generated code by Sourcery, new views will appear automatically in the app!

Further steps might include enhancing the app with thumbnails or small views in the main screen, or including SwiftUI previews etc.

Links: