Float Rating View in Swift
A simple ratings iOS control written in Swift using CALayer masks
Way back in January at Tab Payments we needed a simple iOS rating view so that users could rate their server and leave a review. After some searching I found a great tutorial by Ray Wenderlich, whose site has some of the best iOS tutorials I've come across. The only problem was it didn't do float ratings (to show averages) and I didn't like how it needed a 3rd image for half ratings.
The solution was to write a control based on that tutorial, which I recently rewrote in Swift. FloatRatingView was the result and the full source code with demo project is on github.
FloatRatingView
FloatRatingView is rather simple to use, returns the live float rating to a delegate and only needs 2 images to setup (an empty and full rating image). This was accomplished using the CALayer class and its optional mask layer. For full details on how this control works, be sure to check out the tutorial linked above.
CALayer and Masks
Behind every UIView there is a CALayer class which manages the image based content such as animations, colour and alpha channels. The mask property of CALayer is completely optional and lets you control how much of the background layer is shown by adjusting the mask’s frame.
So while the original control used one set of UIImageViews to show the current rating, FloatRatingView actually has 2 sets of UIImageViews laid directly on top of each other. One to show the empty rating images and one on top to show the full rating images.
The full rating image is then masked to only partially show its image for floating point ratings. All of this is done in a refresh() function which responds to user touches and layout changes. The refresh() function iterates through all the full image views and does 3 things:
- If the rating is greater than the current full image view index, we show the full image view.
- If the rating is less than the current full image view index, we hide the full image view.
- If the rating is a float value somewhere between the current and next full image view index, we create a mask to partially hide it.
func refresh() {
for i in 0..<self.fullImageViews.count {
let imageView = self.fullImageViews[i
// Show the full rating image if rating is greater than index
if self.rating>=Float(i+1) {
imageView.layer.mask = nil
imageView.hidden = false
}
// Use a mask CALayer to partially show the full rating image
else if self.rating>Float(i) && self.rating<Float(i+1) {
// Create a mask layer for full image
let maskLayer = CALayer()
// Calculate the mask width as a fraction of the full image
let maskWidth = CGFloat(self.rating—Float(i)) *
imageView.frame.size.width
let maskHeight = imageView.frame.size.height
// Set the mask frame
maskLayer.frame = CGRectMake(0, 0, maskWidth , maskHeight )
// The mask layer needs a colour to show the full image
maskLayer.backgroundColor = UIColor.blackColor().CGColor
// Set the full image view's mask and unhide it
imageView.layer.mask = maskLayer
imageView.hidden = false
}
// Hide the full rating image if rating is less than index
else {
imageView.layer.mask = nil
imageView.hidden = true
}
}
}
Originally I had used masks on all the full rating images but found that performance suffered when showing many FloatRatingViews at the same time. Setting the hidden property and only using masks when needed ended up being the way to go.
Swift Property Observers
One convenient Swift feature used in FloatRatingView is Property Observers. Whenever a property’s value will be changed or has been changed, we can respond to these events by overriding their respective observers, willSet and didSet. In this case, whenever an empty or full image is set for FloatRatingView we can respond by setting the empty and full UIImageView images.
/** Sets the empty image (e.g. a star outline) */
var emptyImage: UIImage? {
didSet {
// Update empty image views
for imageView in self.emptyImageViews {
imageView.image = emptyImage
}
self.refresh()
}
}/** Sets the full image that is overlayed on top of the empty image. Should be same size and shape as the empty image. */
var fullImage: UIImage? {
didSet {
// Update full image views
for imageView in self.fullImageViews {
imageView.image = fullImage
}
self.refresh()
}
}
This is all that’s required to initialize everything. The full rating image views are laid on top of the empty rating image views and are shown or hidden depending on the rating. The function refresh() is then called to update the image view masks.
Some Notes on Swift
A couple of small things I came across writing FloatRatingView. In Swift, you specify an optional value by placing a question mark (?) right after it, or a forced unwrapped value with an exclamation mark (!). This means you need to watch your spacing when writing equality statements or you’ll get an error.
For example
// This works fine
if rating>=i {
}// But this won't compile
if rating!=i {
}
While the above example looks innocent enough, the compiler will think the exclamation mark in the 2nd if-statement is a forced unwrapping. This means it should be written like so:
// Mind the spacing
if rating != i {
// Do something
}
Another example
// 0 is not an optional value
let rating = i==0? 0:5
This statement looks like something any Objective-C programmer would write and not think twice about. This also gives an error however, as the compiler looks at 0? and thinks it’s for an optional value. Again, just need to be mindful of the spacing whenever you’re using question or exclamation marks.
// What we actually need
let rating = i==0 ? 0:5
That’s all for now!
There are some layout functions that aren't covered here but are fully explained in the Ray Wenderlich tutorial. Feel free to use or fork FloatRatingView at anytime or even send a pull request!
Photo cred for the header once again goes to NASA’s Hubble Space Telescope. ☺