DEV Community

Jamie Ly
Jamie Ly

Posted on

Tracer, a Swift Drawing View, from Concept to CI

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.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
Enter fullscreen mode Exit fullscreen mode

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.

struct Line {
    let start: CGPoint
    let end: CGPoint
}
Enter fullscreen mode Exit fullscreen mode

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.

override func draw(_ rect: CGRect) {
    lines.forEach(drawLine)
}

private func drawLine(line: Line) {
    guard let context = UIGraphicsGetCurrentContext() else {
        print("ERROR: no context available")
        return
    }
    context.move(to: line.start)
    context.addLine(to: line.end)
    context.setStrokeColor(UIColor.black.cgColor)
    context.strokePath()
    UIGraphicsEndImageContext()
}
Enter fullscreen mode Exit fullscreen mode

refs/tags/line-drawing

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:

  1. We can allow the user to draw as long as any point drawn is within a threshold from any point in the expected path.
  2. 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.

Shows the expected path, traced path, and out of bounds concept

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.

var expectedPath: Array<CGPoint> {
    get { return _expectedPath }
    set {
        _expectedPath = newValue
        drawExpectedPath(points: newValue)
    }
}

private func drawExpectedPath(points: Array<CGPoint>) {
    UIGraphicsBeginImageContext(expectedPathView.bounds.size)

    guard var last = points.first,
        let context = UIGraphicsGetCurrentContext() else {
        print("There should be at least one point")
        return
    }

    context.setFillColor(UIColor.white.cgColor)
    context.fill(expectedPathView.bounds)

    context.setStrokeColor(UIColor.blue.cgColor)
    points[1..<points.count].forEach { pt in
        context.move(to: last)
        context.addLine(to: pt)
        context.strokePath()
        last = pt
    }

    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    expectedPathView.image = image
}

Enter fullscreen mode Exit fullscreen mode

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.

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    touches.forEach {
        let start = $0.previousLocation(in: self)
        let end = $0.location(in: self)
        let color = colorForPoints(start, end)
        self.lines.append(Line(start: start, end: end, color: color))
    }
    setNeedsDisplay()
}
Enter fullscreen mode Exit fullscreen mode

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.

private func colorForPoints(_ pts: CGPoint...) -> CGColor {
    if pts.allSatisfy(isPointWithinBounds) {
        return UIColor.black.cgColor
    }

    return UIColor.red.cgColor
}

private func isPointWithinBounds(_ pt: CGPoint) -> Bool {
    let threshold: CGFloat = 75
    return expectedPath.contains { ept in
        let dx = pt.x - ept.x
        let dy = pt.y - ept.y
        let distance = sqrt(dx * dx + dy * dy)
        return distance < threshold
    }
}
Enter fullscreen mode Exit fullscreen mode

A red line denotes out of bounds

refs/tags/red-lines

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.

If you are interested in reading more, this post is continued at https://jamie.ly/blog/programming/2019/04/01/tracer-a-swift-drawing-view.html, where we'll add new features, import cocoapods, add unit tests and property-based tests, add CI, and distribute our code via Cocoapods.

Top comments (2)

Collapse
 
jrtibbetts profile image
Jason R Tibbetts

This is a good writeup. I do have a couple notes about your style. I see several examples like

var expectedPath: Array<CGPoint> {
    get { return _expectedPath }
    set {
        _expectedPath = newValue
        drawExpectedPath(points: newValue)
    }
}

Since your getters don't have any side effects, these computed properties can just use didSet to make things simpler:

var expectedPath: Array<CGPoint> {
    didSet {
        drawExpectedPath(points: newValue)
    }
}

You don't have to deal with the _ variables or newValues.

And while we're using this example, it's much more idiomatic to declare Arrays and Dictionaries using [Type] and [KeyType: ValueType], respectively, so the previous example becomes

var expectedPath: [CGPoint] {
    didSet {
        drawExpectedPath(points: newValue)
    }
}

I've issued a PR for your project with these changes. Let me know what you think!

Collapse
 
jamiely profile image
Jamie Ly

Thanks Jason! I learned some new things!