Introduction
A common way for children’s apps to teach letters is to have someone draw
the letter, the app tracking the user’s drawing. For this article, we’ll
implement this functionality by subclassing UIView
, adding features
incrementally, adding tests, managing Cocoapod dependencies, and using
fastlane. You can follow along using the source code on
GitHub. Along the way, I’ve added links to
git tags, pointers to the code at specific points in time.
Touches
There are several UIView
callbacks which deal with users touching the view.
When we detect that someone has moved their touch, we can grab the list of
touches, which includes the current and previous position of the touch.
We’ll store those in a struct Line
.
Then, we’ll draw these lines in the the UIView
’s draw
call. The drawing
functionality is similar to other drawing APIs like HTML canvas.
Out of Bounds
We want to implement a feature where a user has to follow a particular path. If the user goes beyond some threshold of this path, then we want to detect that in order to give the user some sort of indication.
There are at least two ways we can implement this:
- We can allow the user to draw as long as any point drawn is within a threshold from any point in the expected path.
- We can track the progress of the user as they draw, and note which points on the expected path have been drawn, and require that the user continue on the path. In this way, we only need to check parts of the expected path that we have not drawn yet.
We’ll implement #1 first since it is simpler.
To implement this, we’ll first need to accept some expected path. Although we may want smooth paths at some point using bezier paths, we’ll keep things simple and only support straight lines between segments. We’ll display the path under our drawing to guide our touch. Since this expected path won’t change, we’ll draw this on an image. We’ll display the image on an image view behind our drawing.
Next, when we draw, we will compare the points we draw, and calculate the distance from each of the points in the expected path. This is fairly inefficient but there are ways of making it quicker. One way is to partition the space into a set of areas and note which points in the expected path fall into these areas. (Related to BSP.) For now, we will just use the trivial method of doing this. If the line that we draw is beyond some threshold distance from the closest point on the expected path, then we will give some indication (by drawing the line red).
We create a property that will draw the expected path on the image associated
with a UIImageView
when it is set.
We change the touchesMoved
call to note the color that should be used for the
line. Black if the line is valid and red if invalid. We’ll add a property in
the Line
struct to hold the color as well.
colorForPoints
just checks if both of the line’s points are within some
threshold of any of the points in the expected path, returning black if so, and
otherwise red.
Refining Features
Although we’ve made good progress, this is somewhat unimpressive. There are two problems.
- Firstly, things would look a lot better with some nice image outlining the path we have to trace, like a big letter T (we pick T because it is easy to path).
- Secondly, when we specify a path and threshold, we want the view to figure out waypoints between the given path so that the distance between any two points is less than the threshold. This will ensure that the red lines we generate are what we’d expect.
We add a T made of flowers, centered in the window to make things look a bit better. Note that although the image is aspect fit, that’s a problem since we’ll have to scale the path accordingly. Until we do that, it is better to fix the dimensions of the image.
To add waypoints, we use a recursive method. We take the points of a line segment and calculate the distance between them. If the distance between them is larger than the threshold, we find the midpoint. This point will be added to the expected path. We then find any waypoints that need to be added between the start and midpoint, and midpoint and end. In this way, we recursively add points until we have enough points to cover the distance.
Indicating Success
We need to know when we have completed tracing the figure. We will define this as having a point close to each of the points in the expected path (with waypoints). We’ll use the same threshold for error as described above. When we complete tracing the figure, we will change the color of the drawn line to green.
Although we don’t need to implement smooth curves to make this usable, it would be nice to support multiple paths, in order to support disconnected paths. We will do that by taking a list of expected paths instead of just a single path.
This is all working pretty well. One last visual thing we’ll implement to make things look better is we’ll draw the expected path with a wide stroke twice the error threshold. This will add a nice visual indicator of where we are allowed to make mistakes.
We’ll also add stars at key points in the path that show the points we need to
pass through. We accept some image to be the image we display at each path
point. When given the image, we create UIImageView
subviews placed at each
point, sized using the maxDistance
variable and centered at the point. If the
image is nil
‘ed, we remove each UIImageView
from the view and empty the array
of references.
Unit Testing
We have created something quite usable and the code is clean enough that it
can be augmented; however, we did a bunch of refactoring in the last step
and things are more messy than they were. Since code tends towards entropy,
we want to make sure what we’ve done is protected against future mistakes by
creating some tests. Although most of what we’ve done is visual, and uses
built-in parts of Cocoa Touch, we can cover it with tests by isolating the
changes we make and testing them. Let’s start first with the code that we used
to place the stars. The code is mostly straightforward, but we can certainly
test that given a point and maxDistance
, we return the expected frame
(CGRect
).
We separate the code above that creates UIImageView
s and sets their frame
into two separate parts.
Now, we can easily extract a method that calculates a frame from a point and
max distance. We make it public static
so that we can call it from the test without
instantiating the TracerView
.
Then we can add our first test:
calculateWayPoints
is even easier to test because it doesn’t make use of
any member variables. We can just declare it and all of the methods it calls
as static, then create a test for it.
Since I know that the method works, an easy way to implement the tests is to
just run the method with some logical input, then write a failing
XCTAssertEqual
. The console will note the actual value, and I can just add
that into the test if it makes sense. This way of testing is not bulletproof.
There are edge cases that we will miss, but at least we can guard against
regressions in the future.
Dependency Management
One of the best ways to test edge cases is using property-based testing, which relies on the testing system to generate test cases. We can do just that with by using a QuickCheck-like library. In order to import it, we will setup our project with Cocoapods for dependency management. See the notes for more about Cocoapods and some of the tools that follow3.
We make sure bundler, the Ruby package manager, is
installed using the gem
command2.
We create a Gemfile
in order to install the cocoapods
gem.
Then we install the cocoapods
gem using Bundler, and create a new Podfile
,
where we can define our iOS dependencies.
We add SwiftCheck
to the tests target block of our new Podfile
.
Then run bundle exec pod install
to install the SwiftCheck
dependency. Upon
executing that command, Cocoapods will create an xcproject
to build the
dependencies into a framework that is included in your original project. It
packages this project along with our original project in a new workspace file
called Tracer.xcworkspace
. From now on, we’ll use the workspace to build our
project since we’re relying on the Cocoapods-managed dependencies.
Property-based Testing
SwiftCheck
is a QuickCheck-like
library for Swift. We will write a property-based test that will test the
following:
- For all max distances at least 1, and
- Starting points with positive x and y, and
- Ending points with positive x and y
- Generate waypoints that are less than the max distance apart
SwiftCheck
is smart enough to test edge cases, like what happens when the
max distance is 1 or the maximum CGFloat
, or when both the starting and
ending point are (0, 0). Here’s how we will define the test:
In order for this test to run, we have to tell SwiftCheck
how to generate
the required data types CGFloat
and CGPoint
. One way to generate an arbitrary
CGFloat
is to map over the existing SwiftCheck
-provided generator for Double
.
But note that we want max distance to always be at least 1. Although we could
embed this into the Arbitrary
4 implementation we have added, we could not use
it as an arbitrary for CGFloat
s in general. Instead, we apply a predicate to
the Gen
erator5 we just defined and let-bind the result.
We’ll also need only positive CGFloat
instances to create the CGPoint
s we
need for the test.
We’ll let-bind a CGPoint
generator by zipping two positiveFloats
and
applying the result to the CGPoint
constructor.
And finally, although there are type signatures that allow passing Gen
instances to forAll
, I couldn’t get the type signatures to work out. Intead,
in order to enforce using the exact Gen
instances I need, I introduced a
parameter
object.
and defined its Arbitrary
implementation by zipping the generators above,
and mapping the result using the LineAndDistance
default constructor.
The final test code becomes:
Adding Fastlane
It’s great that we can have a little more confidence in our code with these new tests. We want to be able to run our test via command line so that we can implement a CI pipeline, and the command for that is:
There are two small problems with this.
- It gives us messy build output like 1
- We have to repeat those arguments whenever we want to test
In order to address these issues, we’ll use fastlane scan
to do the testing. First we’ll add the fastlane gem to our Gemfile
, then
bundle install
, then bundle exec fastlane scan
. The command will figure out
how we want to run the tests, and if it doesn’t use the proper defaults,
we can save the settings in a file. This also results in more nicely
formatted output.
Continuous Integration (CI)
Now that we have a relatively simple way of running tests, we can setup CI for our project. CI is useful in making sure that our code is always working, by running tests each time we check in our code.
If you have your own hardware, such as a spare Mac lying around, you can setup something opensource such as Jenkins or Gitlab to kick off the build, setting up the spare Mac as a job runner. If you’d rather not go through the trouble of setting up your own CI pipeline, there are a number of service providers that offer CI services and macOS build agents. We’ll try to add our repository to two different CI services.
First, CircleCI is one of the most popular hosted CI providers right now. It works via a declarative yaml file that has various shortcuts that make it simple to read and build projects, but also allows you to run arbitrary commands. They offer a free trial to get started. We can add support for CircleCI by adding the following yaml file to our project.
Once we have added and committed this file to our repository, and linked our repository with CircleCI via their web interface, it will start to build. The output looks like this:
CircleCI will build the project whenever a commit is pushed to master.
If you have trouble getting on CircleCI’s free plan like I did, Azure DevOps is another option that supports free macOS pipelines and is easy to get started with.
We can get started by defining a yaml file in our project containing a definition of the image we will use and the build steps. Azure DevOps allows us to choose which yaml file defines our build, so the name doesn’t matter.
Although Azure DevOps does support a task that will test an XCode project,
and even allow you to test on real devices,
I didn’t want to make the testing step much different than what we already have.
So, I run a shell script in the pipeline that runs fastlane scan
.
The pipeline run looks like this:
Framework Distribution
We just added Cocoapods
in order to add and maintain dependencies
in our project. Wouldn’t it be nice if we could distribute this project as a
Cocoapod so that it was easy for people to use? If we want to do this, we’ll
have to make several changes:
- Restructure the project to produce a
Cocoa Touch Framework
product. This is a library that can be included into other projects. - Optionally, move the code that shows the view into some sort of “Demo” project.
- Create the necessary files to define our
Cocoapod
and publish it to theCocoapod
repository.
First, we will move most of our files into a subdirectory called Demo
.
Opening the project from the Demo
folder, we’ll use XCode to rename the
project and each of the targets. We’ll postpone further changes until we have
the framework project working. We will create a new project in the root
directory called Tracer. (To do this we wil create the project in a temporary
directory, then copy the files into the root directory.) We’ll choose “Cocoa
Touch Framework” as the template. We’ll move TraceView.swift
and
TracerTests
from the Demo
folder to source folders in the root directory.
We will add a Podfile
that installs SwiftCheck
, run bundle exec pod
install
in the root directory, and open the Tracer.xcworkspace
. We’ll make
sure that it builds correctly, then add a Tracer.podspec
.
Now that we have made a Tracer
pod, we’ll finish modifications to the Demo
project. We’ll edit the Podfile
to remove unnecessary targets and update the
Podfile
to include the Tracer
dependency. We use the path
argument to
refer to the source files in the root directory.
We’ll rerun bundle exec pod install
to generate Demo.xcworkspace
. We may
have to delete some Framworks and Products added by the previous Podfile
targets, and modify the location of the Info.plist
file in the Build
Settings. We’ll also have to add an import Tracer
to the
ViewController.swift
. Compiling, we’ll notice some issues we have to resolve
dealing with visibility of the types. We have to add public
to classes,
structs, and some methods to make them visible outside of the framework. We’ll
build and run Demo.xcworkspace
to make sure that everything works.
refs/tags/cocoapod-distribution
Next Steps
There are many places we could continue at this point. Since I don’t have a specific use case for the project right now, there’s no clear direction for me to take. One of the very next steps that would probably be needed to make this more usable is to create some delegate protocol that allows further customization of this view’s functionality. For example, we could have delegates that can be used for simple customizations such as:
or a delegate that can be used to customize everything:
Conclusion
In this article, we started with a concept and implemented that idea piece by
piece into something that works. There’s a lot more we can do with this idea,
so feel free to fork the project. We implemented unit tests, and a
property-based test using SwiftCheck
. We saw how to manage dependencies
using Cocoapods
. We made test runs more straightforward using fastlane
and setup CI with two hosted solutions. Finally, we saw how to package our
own Framework as a Cocoapod.
The source code for this project is available on
GitHub, and you can add this Cocoapod
to your own project by including this in your Podfile
.
Notes
1 1:
2 1: Assuming we are using System ruby, that is, the ruby that comes with an install of macOS.
3 1:
Bundler
We use bundler
to manage ruby dependencies called gem
s. Cocoapods
is a ruby
gem that is itself an iOS dependency manager. So, we use bundler
to make sure
all the contributors to the Tracer
project are using the same version of
Cocoapods
. In practice, I’ve never run into an issue where someone using a
different version of Cocoapods
than I was caused an issue.
Cocoapods
Cocoapods
is one of a few possibilities for managing dependencies. There’s
also manual management, which will work decently until you want to upgrade
libraries or track the version you are using. There’s
Carthage which is less invasive than
Cocoapods
. And although it doesn’t seem pratical to use yet (at least for iOS
projects), there is Swift Package
Manager.
One thing I like about Cocoapods
is that there is a catalogue of pods that
is searchable. In practice, this often yields out of date and unused pods. I
often just wind up performing a web search for a library I want, and checking
if there is a Cocoapod spec.
Fastlane
Although we just use Fastlane here for testing, it is the swiss army knife of iOS build automation. Check it out if you do anything iOS build related.