The beauty of imperfection

0:00
/

Perfection is not something one can truly achieve, so it’s very smart that one of the best meditation services called Headspace is using an imperfect dot as their brand logo.

If you ever used their iOS app, you can notice how their dot slightly deforms, captivating your focus.

When it comes to graphics, if you are able to cheat (if user can’t tell the difference) and make tech stuff easier, just do it.

A note on original implementation

The original implementation of Headspace app was using Quadrilateral, there is now a library for iOS for doing that, but I no longer think it’s neccesary for the intentend effect, it makes things more complex that they need to be.

We can achieve same effect with little code.

Implementation

The first thing I wanted to do is start with slightly deformed circle, so I’ve opened PaintCode:

  • draw a circle
  • edit bezier and moved couple of vertices
  • end up with:

and the generated code:

func createBlobShape() -> UIBezierPath {
  let ovalPath = UIBezierPath()
  ovalPath.moveToPoint(CGPointMake(13.71, -29.07))
  ovalPath.addCurveToPoint(CGPointMake(27.92, -14), controlPoint1: CGPointMake(20.64, -25.95), controlPoint2: CGPointMake(24.57, -20.72))
  ovalPath.addCurveToPoint(CGPointMake(33, 0.5), controlPoint1: CGPointMake(30.08, -9.68), controlPoint2: CGPointMake(33, -4.64))
  ovalPath.addCurveToPoint(CGPointMake(20.82, 26), controlPoint1: CGPointMake(33, 10.93), controlPoint2: CGPointMake(27.47, 17.84))
  ovalPath.addCurveToPoint(CGPointMake(0, 33), controlPoint1: CGPointMake(16.02, 31.88), controlPoint2: CGPointMake(7.63, 33))
  ovalPath.addCurveToPoint(CGPointMake(-16.72, 28.33), controlPoint1: CGPointMake(-6.21, 33), controlPoint2: CGPointMake(-11.89, 31.29))
  ovalPath.addCurveToPoint(CGPointMake(-23.86, 22), controlPoint1: CGPointMake(-19.59, 26.57), controlPoint2: CGPointMake(-22.22, 24.28))
  ovalPath.addCurveToPoint(CGPointMake(-28, 17), controlPoint1: CGPointMake(-25.19, 20.16), controlPoint2: CGPointMake(-26.74, 19.46))
  ovalPath.addCurveToPoint(CGPointMake(-33, 0.5), controlPoint1: CGPointMake(-30.24, 12.61), controlPoint2: CGPointMake(-33, 5.74))
  ovalPath.addCurveToPoint(CGPointMake(-23.86, -23), controlPoint1: CGPointMake(-33, -9.63), controlPoint2: CGPointMake(-31.23, -17.04))
  ovalPath.addCurveToPoint(CGPointMake(-4.57, -33), controlPoint1: CGPointMake(-18.17, -27.6), controlPoint2: CGPointMake(-12.51, -33))
  ovalPath.addCurveToPoint(CGPointMake(13.71, -29.07), controlPoint1: CGPointMake(0.32, -33), controlPoint2: CGPointMake(9.53, -30.95))
  ovalPath.closePath()
  return ovalPath
}


Now keep in mind this is **way more deformed** that what you'd do for replicating Headspace dot, but I wanted to make it more obvious for the article.

Deform it less to get a more subtle effect.

Next thing to do was drawing that in iOS, for that as always I’ve used my favourite layer called CAShapeLayer

I set it’s shape to the path generated from PaintCode and made sure it’s centered correctly:

let blob = CAShapeLayer()
let blobShape = createBlobShape()
blob.path = blobShape.CGPath
blob.frame = blobShape.bounds
blob.anchorPoint = CGPointMake(0, 0)
blob.fillColor = UIColor.orangeColor().CGColor

var view = UIView(frame: CGRectMake(0, 0, 640, 480))
view.backgroundColor = UIColor.grayColor().colorWithAlphaComponent(0.8)
XCPlaygroundPage.currentPage.liveView = view

and already I’ve a simple deformed circle in my playground:

Now we just need to make it animate with time, so we get the desired slight deformation effect.

Animation

We can use CADisplayLink to implement our animation manually. To use CADisplayLink in our playground, we'll add a simple wrapper for block based animation:

class Animator {
  class TargetProxy {
      init (target: Animator) {
          self.target = target
      }
      
      weak var target: Animator!
      var totalTime: NSTimeInterval = 0
      
      @objc func onFire (dl: CADisplayLink) {
          totalTime += dl.duration
          target.block(totalTime)
      }
  }
  
  
  private lazy var displayLink: CADisplayLink = {
      return CADisplayLink(target: TargetProxy(target: self), selector: "onFire:")
  }()
  private let block: NSTimeInterval -> Void

  init (block: NSTimeInterval -> Void) {
      self.block = block
      displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)
  }
  
  deinit {
      displayLink.invalidate()
  }
}

Animator can be created with a block accepting total time that has passed from it's creation, quite simple.

We compose our animation of 2 simple matrix transforms:

  • Rotation
  • Scale
  • Skew

Rotation itself is too easy to spot, but when we throw skewing the effect is more interesting. Give it a try and play with the values.

let dl = Animator {
  let skewBaseTime = $0 * 0.3

  let rotation = CATransform3DMakeRotation(CGFloat(acos(cos(skewBaseTime))), 0, 0, 1)
  let upscale = 5.0
  let scaleAdjustment = 0.1
  let scale = CATransform3DMakeScale(CGFloat(upscale + abs(sin(skewBaseTime) * scaleAdjustment)), CGFloat(upscale + abs(cos(skewBaseTime) * scaleAdjustment)), 1)
  
  let skewTransform = CGAffineTransformMake(1.0, 0.0, CGFloat(cos(skewBaseTime + M_PI_2) * 0.1), 1.0, 0.0, 0.0)
  CATransaction.begin()
  CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
  view.layer.sublayerTransform = CATransform3DConcat(CATransform3DMakeAffineTransform(skewTransform), scale)
  blob.transform = rotation
  CATransaction.commit()
}
  • We upscale the dot to make it easier to see
  • Apply some small offset scaling in both dimmension
  • Apply skew transformation to make it even more deformed (could probably get rid of this part)

Now why are we using sublayerTransform and not just apply everything on the blob?

Because it will allow you to add other views and make them deform with the blob, while rotating only the blob itself.
E.g. in Headspace that could be play / pause button.

So adding a Play button from Noun Project (created by Mike Ashley):

let playImageView = UIImageView(image: UIImage(named: "play"))
playImageView.transform = CGAffineTransformMakeScale(0.05, 0.05)
playImageView.center = blob.position
view.addSubview(playImageView)

We achieve a play icon deforming slightly while the dot is significant (for real app it would be much less exaggerated)

Conclusion

The total implementation fit 100 lines of code, the original implementation using Quadrilateral's was probably around this size.

Keeping stuff simpler will make your life easier, and when it comes to graphics you can often cheat and your user won't care as they won't be able to tell the difference.

You've successfully subscribed to Krzysztof Zabłocki
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.