Getting Artistic w/ RubyMotion
A coworker has a habit of leaning over to me and mentioning something that would be neat to have on an iPhone. I’m a sucker for it every time.
What he was looking for was an app that can draw over the top of another picture, namely a layout of his backyard, and be able to clear off any changes with a shake of the device. Having never done a drawing app before, I thought this would be a lot of fun and set off to write it in RubyMotion.
Some quick google searching turned up a few forum and blog posts that provide instruction if you are using Objective-C. Here’s how to do it in RubyMotion.
For the purposes of this blog post we are going to be building an app that allows you to drag your finger around the screen to create an image.
We are going to start out by creating a custom view which we can draw to as well as track touches.
class PaintView < UIView def initWithFrame(frame) if super @hue = 0.5 end self end def touchesMoved(touches, withEvent:event) @touch = touches.anyObject self.setNeedsDisplay() end def drawRect(rect) context = UIGraphicsGetCurrentContext() color = UIColor.colorWithHue(@hue, saturation:0.7, brightness:1.0, alpha:1.0) CGContextSetStrokeColorWithColor(context, color.CGColor) CGContextSetLineCap(context, KCGLineCapRound) CGContextSetLineWidth(context, 15) last_point = @touch.previousLocationInView(self) new_point = @touch.locationInView(self) CGContextMoveToPoint(context, last_point.x, last_point.y) CGContextAddLineToPoint(context, new_point.x, new_point.y) CGContextStrokePath(context) end end
We instantiate this view inside a basic UIViewController and add it to the display.
class MainViewController < UIViewController def viewDidLoad paint_view = PaintView.alloc.initWithFrame(self.bounds) view.addSubview(paint_view) end end
Upon running this app, you will discover that it flickers REALLY badly. This is because the view is double-buffered by default. We can solve this by caching our draw calls in a new context.
def init_cache_context bitmap_bytes_per_row = size.width * 4 bitmap_byte_count = bitmap_bytes_per_row * size.height cache_bitmap = Pointer.new(:char, bitmap_byte_count) @cached_context = CGBitmapContextCreate(cache_bitmap, size.width, size.height, 8, bitmap_bytes_per_row, CGColorSpaceCreateDeviceRGB(), KCGImageAlphaNoneSkipFirst) true end
There are a couple of interesting things to note here about the differences between Objective-C development and RubyMotion. The first is the pointer. Here we are generating a new character pointer with a size equal to that of the buffer we need. The second thing of note is the
KCGImageAlphaNoneSkipFirst constant. The Objective-C equivalent is
kCGImageAlphaNoneSkipFirst. Ruby requires its constants to start with a capital letter. This is a gotcha that can send you on a wild google hunt.
Add a call to
init_cache_context to the PaintView
def initWithFrame(frame) if super @hue = 0.5 self.init_cache_context(frame.size) end self end
Now that we have our cache context setup, we need to use it when someone touches the screen. This is a good time to extract that code into a new method.
def touchesMoved(touches, withEvent:event) @touch = touches.anyObject self.draw_to_cache(@touch) end def draw_to_cache(touch) color = UIColor.colorWithHue(@hue, saturation:0.7, brightness:1.0, alpha:1.0) CGContextSetStrokeColorWithColor(@cached_context, color.CGColor) CGContextSetLineCap(@cached_context, KCGLineCapRound) CGContextSetLineWidth(@cached_context, 15) last_point = @touch.previousLocationInView(self) new_point = @touch.locationInView(self) CGContextMoveToPoint(@cached_context, last_point.x, last_point.y) CGContextAddLineToPoint(@cached_context, new_point.x, new_point.y) CGContextStrokePath(@cached_context) self.setNeedsDisplay() end
Nothing new here, just using our cached context instead of the current one. If you are following closely along, you will by now have realized that the call to
setNeedsDisplay causes the view to redraw itself.
Because we are using a cached context, we need to have our
drawRect method behave a little differently. We need to have it copy our cached context in the form of an image onto the screen.
def drawRect(rect) context = UIGraphicsGetCurrentContext() cache_image = CGBitmapContextCreateImage(@cached_context) CGContextDrawImage(context, self.bounds, cache_image) end
With any luck, you should now have an app that is flicker free while dragging your finger around the screen. I’ll leave tweaks and additions going forward up to your imagination. You can get the code from github here.