How to re-create the iOS camera button?

For a project I worked on, I had to create a video capture interface. My goal was to make it look and feel as close to the regular iPhone camera app. One of the control that needed to be recreated for this is the camera button. This button has some very specific behavior that can take a bit of time to recreate. In this blog post, i'll explain how to do it.

At the end of this post, you'll find a link to GitHub to download the whole project.

What does it actually do?

First off, let's look at the button in question

So a couple things to notice:

  • The inner shape of the button becomes translucent when you touch it
  • When the touch is released, the inner shape transition back to red and changes shape

Implementation

First thing to do is create a subclass of UIButton so we can inherit all the button related goodness.

Second in we need to set up the initial state of the control, i'll do that in a setup method so it can be called from all initializers.

 
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.setup()
    }
    
    //common set up code
    func setup()
    {
        //add a shape layer for the inner shape to be able to animate it
        self.pathLayer = CAShapeLayer()
        
        //show the right shape for the current state of the control
        self.pathLayer.path = self.currentInnerPath().cgPath
        
        //don't use a stroke color, which would give a ring around the inner circle
        self.pathLayer.strokeColor = nil
        
        //set the color for the inner shape
        self.pathLayer.fillColor = UIColor.red.cgColor
        
        //add the path layer to the control layer so it gets drawn
        self.layer.addSublayer(self.pathLayer)
    }
    
    override func awakeFromNib()
    {
        super.awakeFromNib()
        
        //lock the size to match the size of the camera button
        self.addConstraint(NSLayoutConstraint(item: self,
                                              attribute:.width,
                                              relatedBy:.equal,
                                              toItem:nil,
                                              attribute:.width,
                                              multiplier:1,
                                              constant:66.0))
        self.addConstraint(NSLayoutConstraint(item: self,
                                              attribute:.height,
                                              relatedBy:.equal,
                                              toItem:nil,
                                              attribute:.width,
                                              multiplier:1,
                                              constant:66.0))
        
        //clear the title
        self.setTitle("", for:UIControlState.normal)
    }
    

What does this do?

Well, to be able to draw the various shapes we need to draw, we're going to use bezier path. Moreover we need to animate those, so we need to draw them in a layer. So this code essentially adds a layer to the button which will contains those shapes which need to be animated.

You'll notice later on that the outer white circle is not added to the layer but rather drawn in the draw() function.

The awakeFromNib function sets up two constraints to make sure the control keeps a 66*66 px size, it should not resize.

One function call you probably noticed in here is currentInnerPath(). This method simply returns the correct path depending on the state of the button (square or circle)

 
    func currentInnerPath () -> UIBezierPath
    {
        //choose the correct inner path based on the control state
        var returnPath:UIBezierPath;
        if (self.isSelected)
        {
            returnPath = self.innerSquarePath()
        }
        else
        {
            returnPath = self.innerCirclePath()
        }
        
        return returnPath
    }
    
    func innerCirclePath () -> UIBezierPath
    {
    	//make the corner radius the same as the radius of the circle to 		
        //make this rectangle look like a circle
        return UIBezierPath(roundedRect: CGRect(x:8, y:8, width:50, height:50), cornerRadius: 25)
    }
    
    func innerSquarePath () -> UIBezierPath
    {
        return UIBezierPath(roundedRect: CGRect(x:18, y:18, width:30, height:30), cornerRadius: 4)
    }
    

The inner shape is actually a rounded rectangle, even when displaying a circle. A circle can be represented as a rounded rectangle whose corner radius is the same as the radius of the circle... I learned that trick for this control.

Now let's take care of drawing the outer circle, as I mentioned earlier this is not animated, so it will just get drawn in the draw() function.

 
     override func draw(_ rect: CGRect) {
        //always draw the outer ring, the inner control is drawn during the animations
        let outerRing = UIBezierPath(ovalIn: CGRect(x:3, y:3, width:60, height:60))
        outerRing.lineWidth = 6
        UIColor.white.setStroke()
        outerRing.stroke()
    }
    

At this point if we were to run an app with this control, we'd see the control as we expect it, but it would not react to any touch. So on to even handling.

We need two things. We need to change the color of the inner shape when the control is touched, and then we need to change the shape of inner shape when the button is release.

To accomplish this, we'll handle the color change during the touchUpInside and touchDown events, but for the shape change we'll handle that on state change.

 
    func touchDown(sender:UIButton)
    {
        //when the user touches the button, the inner shape should change transparency
        //create the animation for the fill color
        let morph = CABasicAnimation(keyPath: "fillColor")
        morph.duration = animationDuration;
        
        //set the value we want to animate to
        morph.toValue = UIColor(colorLiteralRed: 1, green: 0, blue: 0, alpha: 0.5).cgColor
        
        //ensure the animation does not get reverted once completed
        morph.fillMode = kCAFillModeForwards
        morph.isRemovedOnCompletion = false
        
        morph.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        self.pathLayer.add(morph, forKey:"")
    }
    
 func touchUpInside(sender:UIButton)
    {
        //Create first animation to restore the color of the button
        let colorChange = CABasicAnimation(keyPath: "fillColor")
        colorChange.duration = animationDuration;
        colorChange.toValue = UIColor.red.cgColor
        
        //make sure that the color animation is not reverted once the animation is completed
        colorChange.fillMode = kCAFillModeForwards
        colorChange.isRemovedOnCompletion = false
        
        //indicate which animation timing function to use, in this case ease in and ease out
        colorChange.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        
        //add the animation
        self.pathLayer.add(colorChange, forKey:"darkColor")
        
        //change the state of the control to update the shape
        self.isSelected = !self.isSelected
    }
    
    //override the setter for the isSelected state to update the inner shape
    override var isSelected:Bool{
        didSet{
            //change the inner shape to match the state
            let morph = CABasicAnimation(keyPath: "path")
            morph.duration = animationDuration;
            morph.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
            
            //change the shape according to the current state of the control
            morph.toValue = self.currentInnerPath().cgPath
            
            //ensure the animation is not reverted once completed
            morph.fillMode = kCAFillModeForwards
            morph.isRemovedOnCompletion = false
            
            //add the animation
            self.pathLayer.add(morph, forKey:"")
        }
    }
    

All that is left to do is hook up those event handlers to the right events, we'll do that in our awakeFromNib() function

 
    override func awakeFromNib()
    {
        super.awakeFromNib()
        
        //lock the size to match the size of the camera button
        self.addConstraint(NSLayoutConstraint(item: self,
                                              attribute:.width,
                                              relatedBy:.equal,
                                              toItem:nil,
                                              attribute:.width,
                                              multiplier:1,
                                              constant:66.0))
        self.addConstraint(NSLayoutConstraint(item: self,
                                              attribute:.height,
                                              relatedBy:.equal,
                                              toItem:nil,
                                              attribute:.width,
                                              multiplier:1,
                                              constant:66.0))
        
        //clear the title
        self.setTitle("", for:UIControlState.normal)
        
        //add out target for event handling
        self.addTarget(self, action: #selector(touchUpInside), for: UIControlEvents.touchUpInside)
        self.addTarget(self, action: #selector(touchDown), for: UIControlEvents.touchDown)
    }
    

That's it you have it, now you can add this button using InterfaceBuilder, just make sure to set the button type to custom to avoid issue with the animations.

 

You can download the whole source with a sample app from GitHub.