15
\$\begingroup\$

A more up-to-date version of this control can be found on GitHub.


One UI control that has always been mysteriously missing from Xcode's interface builder is some sort of checkbox. UISwitch is available, but this isn't always quite the feel you might want. It's particularly mysterious that any sort of check box is missing given that there are check boxes used somewhat predominately throughout Apple's own preloaded apps (Camera Roll is first thing that comes to mind).

As such, I decided to implement a UICheckbox... and one that can be designed in interface builder!

//
//  UICheckbox.swift
//  UIStuff
//
//  Created by Nick Griffith on 11/6/14.
//  Copyright (c) 2014 nhg. All rights reserved.
//

import UIKit

enum UICheckboxStyle {
    case OpenCircle, GrayedOut
}

@IBDesignable class UICheckbox: UIControl {
    @IBInspectable var checked: Bool = false {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var checkmarkSize: CGFloat = 5.0 {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var checkmarkColor: UIColor = UIColor.whiteColor() {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var filled: Bool = true {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var checkedFillColor: UIColor = UIColor(red: 0.078, green: 0.435, blue: 0.875, alpha: 1.0) {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var uncheckedFillColor: UIColor = UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.3) {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var borderColor: UIColor = UIColor.whiteColor() {
        didSet {
            self.setNeedsDisplay()
        }
    }

    @IBInspectable var borderWidth: CGFloat = 1.0 {
        didSet {
            self.setNeedsDisplay()
        }
    }

    // custom enums, unfortunately, cannot be IBInspectable
    var style: UICheckboxStyle {
        return self.filled ? .GrayedOut : .OpenCircle
    }

    override func touchesEnded(touches: NSSet, withEvent event: UIEvent) {
        if (self.touchInside) {
            self.checked = !self.checked
        }

        super.touchesEnded(touches, withEvent: event)
    }

    override func drawRect(rect: CGRect) {
        super.drawRect(rect)

        let context = UIGraphicsGetCurrentContext()
        let fillColor = currentFillColor()
        let shadowColor = UIColor.blackColor()

        let shadowOffset = CGSizeMake(0.1, -0.1)
        let shadowBlurRadius: CGFloat = 2.5

        let frame = self.bounds

        let group = CGRectMake(CGRectGetMinX(frame) + 3, CGRectGetMinY(frame) + 3, CGRectGetWidth(frame) - 6, CGRectGetHeight(frame) - 6)

        let ovalX = CGRectGetMinX(group) + floor(CGRectGetWidth(group) * 0.00000 + 0.5)
        let ovalY = CGRectGetMinY(group) + floor(CGRectGetHeight(group) * 0.00000 + 0.5)
        let ovalW = floor(CGRectGetWidth(group) * 1.00000 + 0.5) - floor(CGRectGetWidth(group) * 0.00000 + 0.5)
        let ovalH = floor(CGRectGetHeight(group) * 1.00000 + 0.5) - floor(CGRectGetHeight(group) * 0.00000 + 0.5)

        let ovalRect = CGRectMake(ovalX, ovalY, ovalW, ovalH)

        let ovalPath = UIBezierPath(ovalInRect: ovalRect)
        CGContextSaveGState(context)
        CGContextSetShadowWithColor(context, shadowOffset, shadowBlurRadius, shadowColor.CGColor)
        fillColor.setFill()
        ovalPath.fill()
        CGContextRestoreGState(context)

        self.borderColor.setStroke()
        ovalPath.lineWidth = self.borderWidth
        ovalPath.stroke()

        if self.checked || self.filled {
            let bezierPath = UIBezierPath()

            let startX = CGRectGetMinX(group) + 0.27083 * CGRectGetWidth(group)
            let startY = CGRectGetMinY(group) + 0.54167 * CGRectGetHeight(group)
            let startPoint = CGPointMake(startX, startY)

            let midX = CGRectGetMinX(group) + 0.41667 * CGRectGetWidth(group)
            let midY = CGRectGetMinY(group) + 0.68750 * CGRectGetHeight(group)
            let midPoint = CGPointMake(midX, midY)

            let endX = CGRectGetMinX(group) + 0.75000 * CGRectGetWidth(group)
            let endY = CGRectGetMinY(group) + 0.35417 * CGRectGetHeight(group)
            let endPoint = CGPointMake(endX, endY)

            bezierPath.moveToPoint(startPoint)
            bezierPath.addLineToPoint(midPoint)
            bezierPath.addLineToPoint(endPoint)
            bezierPath.lineCapStyle = kCGLineCapSquare

            self.checkmarkColor.setStroke()
            bezierPath.lineWidth = self.checkmarkSize;
            bezierPath.stroke()
        }        
    }

    private func currentFillColor() -> UIColor {
        if self.checked {
            return self.checkedFillColor
        } else if self.filled {
            return self.uncheckedFillColor
        } else {
            return UIColor.clearColor()
        }
    }
}

Here's some screenshots of the @IBInspectable properties at work:

enter image description here enter image description here enter image description here enter image description here enter image description here

There's definitely still some room for improvement. I'm far from a master with UIGraphics stuff... this project involved some copypasta...

More problematic, is that right now this is purely a view. That is, tapping it wouldn't actually changed the checked status. For now, you'd have to layer a button over it and hook that up manually. Eventually, for completeness, I want this to be more built-in (though I've not researched quite how to make this work yet). The focus on this project was mostly a checkbox view that was designable in interface builder.

Turning this into a workable button that changes its state and can be hooked up via an IBAction was surprisingly simple. All I had to do was change from a UIView subclass to UIControl, and add the touchesEnded method (which I just edited in to this post).

\$\endgroup\$
7
  • \$\begingroup\$ This might need some better tags--not sure what though. \$\endgroup\$
    – nhgrif
    Nov 7, 2014 at 1:21
  • \$\begingroup\$ Nice work! I noticed one strange effect: If I place two checkboxes in a view and resize one of then, then position/size of the other is set to 0/0/0/0. \$\endgroup\$
    – Martin R
    Nov 7, 2014 at 21:53
  • \$\begingroup\$ I think there are some Xcode 6 IB bugs. Earlier today I placed a UIButton on a view, and three UITextView's experience the same behavior, frame set to X:0,Y:0,H:0,W:0 \$\endgroup\$
    – nhgrif
    Nov 7, 2014 at 21:54
  • \$\begingroup\$ For such a control it would probably make sense to allow only an aspect ratio of 1:1, but I don't know if that is possible. \$\endgroup\$
    – Martin R
    Nov 7, 2014 at 22:17
  • \$\begingroup\$ I agree. I played around with this some. The best I could do was to just calculate the largest square rect that fit within the rect passed to drawRect and use that. I'm not sure if I can actually lock it in to 1:1 on IB though. \$\endgroup\$
    – nhgrif
    Nov 7, 2014 at 22:19

1 Answer 1

8
\$\begingroup\$

In playing around with this, I noticed a few things I didn't particularly like:


enter image description here

This picture represents the first problem. Setting the background color fills in the entire rectangle, but should probably only fill in the circle. We can fix this as such:

Add a private variable to hold the value of our circle's background:

private var _backgroundColor: UIColor = UIColor.clearColor() {
    didSet {
        self.setNeedsDisplay()
    }
}

Now override the default property, backgroundColor:

override var backgroundColor: UIColor? {
    set(newColor) {
        super.backgroundColor = UIColor.clearColor()
        if let backColor = newColor {
            self._backgroundColor = backColor
        } else {
            self._backgroundColor = UIColor.clearColor()
        }
    }
    get {
        return self._backgroundColor
    }
}

The next problem is represented here:

enter image description here

This doesn't really look great. I don't know if there's a way to enforce a 1:1 ratio for the rect in interface builder itself (like some Apple UI elements are able to enforce a height), however, we can add this code to drawRect to help:

var frame = rect
if frame.size.width != frame.size.height {
    let shortestSide = min(frame.size.width, frame.size.height)
    let longestSide = max(frame.size.width, frame.size.height)

    let originY = (longestSide - frame.size.width) / 2
    let originX = (longestSide - frame.size.height) / 2

    frame = CGRectMake(originX, originY, shortestSide, shortestSide)
}

let group = CGRectMake(CGRectGetMinX(frame) + 3, CGRectGetMinY(frame) + 3, CGRectGetWidth(frame) - 6, CGRectGetHeight(frame) - 6)

Now we're calculating a square within the rectangle and centering it, giving the following result:

enter image description here


The third problem is the inability to have a transparent checkmark, which might be desirable:

enter image description here

As you can see here, Checked is set to On, but no checkmark appears because the checkmark color is set to Clear. However, we can actually fix this. If we first stroke the checkmark path with the clear blend, then restroke with the normal blend, transparent/translucent checkmark colors will let the color behind our object show through. Here's the code:

bezierPath.lineWidth = self.checkmarkSize            
self.checkmarkColor.setStroke()

CGContextSetBlendMode(context, kCGBlendModeClear)
bezierPath.stroke()
CGContextSetBlendMode(context, kCGBlendModeNormal)
bezierPath.stroke()

And here's the result:

enter image description hereenter image description here


With a particularly large border, we run into trouble due to some magic numbers:

enter image description here

Now, why anyone would want a checkbox anywhere near this size is completely beyond me, but all the same, if we're going to off a variable for a custom border size, it should look decent no matter the selected size (within reason... not much we can do if the object is 200px by 200px and a border size of 200px is selected).

If we change the code that calculates the group rect to something more like this:

let groupX = CGRectGetMinX(frame) + (self.borderWidth / 2)
let groupY = CGRectGetMinY(frame) + (self.borderWidth / 2)
let groupW = CGRectGetWidth(frame) - self.borderWidth
let groupH = CGRectGetHeight(frame) - self.borderWidth

let group = CGRectMake(groupX, groupY, groupW, groupH)

Then we get a much nicer looking result:

enter image description here

The sizing rect makes the object still appear to have slightly flat edges, but when run in the application, it's actually a quite smooth circle.

enter image description here

(And would look better still on an actual device which has a much higher pixel density than my computer)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.