Skip to content

Instantly share code, notes, and snippets.

@timroesner
Last active April 28, 2023 09:53
Show Gist options
  • Save timroesner/6824bd01e7fb9803ee0540abfa89ebf5 to your computer and use it in GitHub Desktop.
Save timroesner/6824bd01e7fb9803ee0540abfa89ebf5 to your computer and use it in GitHub Desktop.
Compile Safe Image Assets in Xcode

Compile Safe Image Assets

This gist was published as part of the Twitch GitHub Organization. It is distributed under the MIT license

The script below is a tool to generate code references from the Xcode asset catalog. The benefit of this is a type safe, typo safe, and compile time checked API for your Image assets. This is in contrast to the current String based UIImage API of UIKit, which does not produce compile time errors when an asset is renamed or removed from the catalog.

Setup

In order to generate the image assets during a Build Phase, you will first need to add a new file with the name ImageAssets.swift to your project. This is important as the project file should not be modified during compile time.

Pre Compiling

It is highly recommended to precompile this swift script. Doing so drastically improves the time the build phase will take to generate code references from your assets. In order to precompile this script you can run the following within your Terminal from the directory where you stored the script:

swiftc ./GenerateImageAssets.swift

Build Phase

To ensure your assets and their code references are kept in sync, you need to add a Build Phase to your project within Xcode. From the Build Phase you are able to execute the pre compiled script while passing both the path to the asset catalog and the path to the output directory (Note: The correct file name is automatically appended in the script).

Since Xcode 11 you will also need to provide input and output files, this helps Xcode optimize the build and to determine when to run this phase. Below you can see a screenshot showing which files and directories need to be added:

Build Phase

Here is a version of the build script for you to copy. Ensure you update these to match your paths:

"${SRCROOT}/tools/GenerateImages" "${SRCROOT}/path/to/Assets.xcassets" "${SRCROOT}/directory/for/generated/file"

Make sure you put this Build Phase before the Compile Resources phase, otherwise your builds will fail.

Additionally we check the if [ "${CONFIGURATION}" == "Debug" ]. This is to ensure that we don't run the Build Phase on our CI, or when building a release version, however it is not required.

Script

#!/usr/bin/env xcrun --sdk macosx swift

//  GenerateImageAssets.swift
//  Created by Twitch Interactive Inc.

// Usage `$ GenerateImageAssets path/to/asset/catalog path/to/output/directory`

import Foundation

/// Generates a `UIImage` extension with static references to all assets included in the
/// asset catalog found at the path of the first command line argument.
private func generateExtension() {
    guard CommandLine.arguments.count == 3  else {
        print("Invalid number of arguments.\nUsage `$ GenerateImageAssets path/to/asset/catalog path/to/output/directory`")
        return
    }
    
    let assetCatalogPath = URL(fileURLWithPath: CommandLine.arguments[1])
    assert(assetCatalogPath.pathExtension == "xcassets", "Path does not lead to an asset catalog")
    
    let generatedFileURL = URL(fileURLWithPath: CommandLine.arguments[2]).appendingPathComponent("/ImageAssets.swift")
    
    var fileContents = """
    // This is an auto generated file. All manual changes will be overridden.
    // Last created: \(Date())

    import UIKit

    extension UIImage {\n
    """
    
    fileContents.append(staticImageList(from: assetCatalogPath))
    fileContents.append(
        """
        }
        
        private final class __BundleClass: NSObject {}
        private let currentBundle = Bundle(for: __BundleClass.self)
        private func asset(withName name: String) -> UIImage {
            return UIImage(named: \"\\(name)\", in: currentBundle, compatibleWith: nil)!
        }

        """
    )
    
    let previousFileContents = try! String(contentsOf: generatedFileURL, encoding: .utf8)
    
    let oldContentsWithoutComment = previousFileContents.split(separator: "\n", maxSplits: 5).dropFirst(2)
    let newContentsWithoutComment = fileContents.split(separator: "\n", maxSplits: 5).dropFirst(2)
    
    if newContentsWithoutComment != oldContentsWithoutComment {
        try! fileContents.write(to: generatedFileURL, atomically: true, encoding: .utf8)
    } else {
        print("Generated file is identical to file on disk. Did not override.")
    }
}

private func staticImageList(from url: URL) -> String {
    let directoryContents = try! FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
    var result = ""
    
    for item in directoryContents {
        if item.pathExtension == "imageset" {
            // Make sure imageset directory is not empty
            let contents = try! FileManager.default.contentsOfDirectory(at: item, includingPropertiesForKeys: nil)
            guard !contents.isEmpty == false else { continue }
            result.append(staticImage(fromImageSetName: item.lastPathComponent))
        } else if item.hasDirectoryPath {
            // Skip .appicon, .colorset, etc
            guard item.pathExtension == "" else { continue }
            
            // Add empty line before directory mark, if last line isn't already empty
            if !result.hasSuffix("\n\n") && result != "" {
                result.append("\n")
            }
            
            result.append("    // \(item.lastPathComponent)\n")
            result.append(staticImageList(from: item))
            
            // Add empty line after list of directory assets, if last line isn't already empty
            if !result.hasSuffix("\n\n") {
                result.append("\n")
            }
        }
    }
    
    return result
}

private func staticImage(fromImageSetName imageSetName: String) -> String {
    let resourceName = imageSetName.replacingOccurrences(of: ".imageset", with: "")
    
    let imageName: String
    if resourceName.contains("-") {
        imageName = resourceName.camelCased(separator: "-")
    } else if resourceName.contains("_") {
        imageName = resourceName.camelCased(separator: "_")
    } else if resourceName.contains(".") {
        imageName = resourceName.camelCased(separator: ".")
    } else {
        imageName = String(resourceName.prefix(1)).lowercased() + String(resourceName.dropFirst())
    }
    
    return "    public static var \(imageName): UIImage { asset(withName: \"\(resourceName)\") }\n"
}

private extension String {
    func camelCased(separator: Character) -> String {
        return self.lowercased().split(separator: separator).enumerated().map { (index, element) in
            if index == 0 {
                return element.lowercased()
            } else {
                return element.capitalized
            }
        }.joined()
    }
}

generateExtension()

FAQ

What happens if I have two assets with the same name in different targets?
This will lead to a compile time error. We suggest to use distinct asset names, or modify the script so that each target provides a unique prefix.

License

MIT License

Copyright (C) 2020 Amazon.com, Inc. or its affiliates.  All Rights Reserved.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment