An Important Feature Xcode is Still Missing

Michael Austin Verges
The Startup
Published in
6 min readJul 21, 2019
Why do we need to provide all three?!

By nature, programmers recognize the value in planning an efficient workflow, and we should take every opportunity to automate trivial tasks. To some, spending a couple hours to automate a task that takes five minutes may seem counter-productive, but it can pay off in a matter of days. Recently, I began questioning the efficiency of generating scaled assets in Xcode. Do I really need to drag and drop three whole images of the exact same thing?

The Problem

Unless you are using scalable assets like PDF, Xcode will ask you for 1x, 2x, and 3x versions of your assets to display on different devices. Even worse, an App Icon asset asks for more than 15 different sizes!

For my image-generation workflow, I would create the icons in Keynote, copy to clipboard, paste into Preview, and save it as a PNG (that’s like at least 60 keyboard clicks to generate three scales of one icon). This rescale-copy-paste workflow can get tiring when creating a handful of icons. If you’ve encountered this problem before, you probably searched for a program like Prepo.

For a while, I settled for using such third-party solutions, but Prepo, for example, hasn't been updated in three years (the specs for App Icons has changed). In addition, I feel like the extra work of using the app is almost as bad as rescaling assets by hand.

In a perfect world, I could just provide one image per asset and Xcode could automatically generate the rest as-needed. This feature is not yet implemented natively, but what if we could add it ourselves?

Let’s Automate!

Photo by Rock'n Roll Monkey on Unsplash

We can implement a custom Run Script that automatically generates missing assets. Run Script phases are executed every time you build the project. We will write a script that generates missing assets every time we build the project.

To add a run script, navigate to your target; under Build Phases > “+” > New Run Script Phase. In the phase, write a line that executes the script you want to run. I chose to write a Ruby script, but you can implement the logic in any language you prefer.

Understanding Imagesets

Let’s reverse engineer how Xcode imagesets work. Right click your .xcassets folder, and select “Show in Finder”. Upon inspection of the revealed folder, you see each asset is contained in a .imageset folder. Each folder contains a Contents.json along with the scaled assets. Let’s open the json (I changed the whitespace for readability):

{
"images": [{
"idiom": "universal",
"scale": "1x",
"filename": "image@1x.png"
},{
"idiom": "universal",
"scale":"2x",
"filename": "image@2x.png"
},{
"idiom": "universal",
"filename": "image@3x.png",
"scale":"3x"
}],
"info": {
"version": 1,
"author":"xcode"
}
}

The imageset defines what image scales it’s looking for, and we normally provide the filename through Xcode’s UI. So theoretically, if an asset was missing, we could retrieve a copy of an existing filename, scale it according to the “scale” tag, and add the corresponding “filename” reference.

Find Missing Assets

The first step is to find missing assets. Likewise, we will want to iterate through all existing imageset’s Contents.json files, and read them. In Ruby, that looks like this:

require 'json'for filename in Dir['**/*.imageset/*.json'] do
json = JSON.parse File.read filename
# logic
end

Now we can iterate through all images and read the desired scale.

for filename in Dir['**/*.imageset/*.json'] do
json = JSON.parse File.read filename
for image in json['images'] do
scale = image['scale'].to_f
# generate the asset
end
# save changes
end

This is the basic layout of how we will generate the assets, and we will revisit the code later. Now, let’s take a look at how we will actually resize the image.

Resize images

So, how can we go about scripting the rescale of images? Fortunately, all Macs have a CLI we can use for modifying images — sips.

According to man sips:

This tool is used to query or modify raster image files and ColorSync ICC profiles.

The commands we need are -g pixelWidth for reading the width of the image, and --resampleWidth for setting a new width (the height will automatically be scaled). Here is a look at the full commands:

sips <source> -g pixelWidth
sips <source> --resampleWidth <width> -o #{destination}

Most languages provide a function for running shell commands. In Ruby, I made a wrapper for these two functions:

# evaluates shell function and returns output string
def run cmd; return `#{cmd}`.to_s end
# returns width of image (integer)
def width_of image
return run("sips #{image} -g pixelWidth").split(' ').last.to_i
end
# resizes image
def scale_to_width sourcename, new_width, outname
run "sips #{sourcename} --resampleWidth #{new_width} --out #{outname}"
end

Generate Assets

Okay, we can iterate through imagesets, and we know how to rescale images. There is just a little logic left to complete our custom script. We need to find the largest existing image in the imageset for our starting point to scale other images.

This Ruby function iterates through a json to find the largest existing image:

def find_largest filename, json
index = -1
maxsize = 0
json["images"].each_with_index do |image, i|
unless image["filename"].nil?
size = width_of "#{dir filename}/#{image['filename']}"
if size > maxsize
maxsize = size
index = i
end
end
end
return
json['images'][index]
end

Back in our main script, we can use the details of the largest asset to help us generate the missing assets. We find the path to the largest image for our sips commands, and we find what the width of the 1x asset should be so that we can scale later.

for filename in Dir['**/*.imageset/*.json'] do
json = JSON.parse File.read filename
# json object for largest image in set
large = find_largest filename, json
# path to largest image
largename = "#{File.dirname filename}/#{large['filename']}"
# base @1x asset width
width = width_of(largename).to_f / large['scale'].to_f
for image in json['images'] do
scale = image['scale'].to_f
# generate the asset
end
# save changes
end

All that’s left is to call the sips CLI to do some resizing for us, and save the new json. Here are some functions for parsing json that make everything a little cleaner in the main script:

# returns the name of the last directory in a filepath
# useful for finding the names of .imageset or .appiconset
def lastdir file, ext; return File.basename dir(file).split('/')[-1], ext end
# returns filepath
def dir file; return File.dirname file end
# returns extension
def ext file; return File.extname file end
# returns basename, removing extension
def base file, ext; return File.basename file, ext end
# returns object contents of json
def parse json; return JSON.parse File.read json end
# writes to file
def write file, contents; return File.write file, contents end

Okay, back to our main script:

for filename in Dir['**/*.imageset/*.json'] do    # json object for image set
json = parse filename
# json object for largest image in set
large = find_largest filename, json
# path to largest image
largename = "#{File.dirname filename}/#{large['filename']}"
# base @1x asset width
width = width_of(largename).to_f / large['scale'].to_f
# generate all images
for image in json['images'] do
# skip if the image already exists
next if !image['filename'].nil?
scale = image['scale'].to_f
out = "#{dir filename}/#{lastdir filename, '.*'}@#{scale.to_i.to_s}#{ext large['filename']}"
scale_to_width largename, (width * scale).to_i.to_s, out
# add json reference to new asset
image['filename'] = base out, ''
puts out
end
# update asset json
write filename, json.to_json
end

We’ve determined the scale of what the output should be, we create an output filename, we generate the file, and then we create a reference in the json.

The Final Product

To check out the final product, check out the GitHub page. It also has some additional features, like iterating through .appiconset files, making Xcode warnings when the it has to upscale images, and escaping spaces in project paths (the sips commands as-is aren’t friendly to spaces in paths).

--

--