UIView Layer Mask Animate

Gizmodo picture Gizmodo · Apr 6, 2016 · Viewed 10.5k times · Source

I am trying to animate the mask layer on a UIView.

Basically this code displays the image below:

let bounds: CGRect = self.manualWBMaskView!.bounds
let maskLayer: CAShapeLayer = CAShapeLayer()
maskLayer.frame = bounds
maskLayer.fillColor = UIColor.blackColor().CGColor
let screenWith  : CGFloat = UIScreen.mainScreen().bounds.width
let roundedRectFrame : CGRect = CGRectMake(self.manualWBMaskView!.bounds.midX - (screenWith/4), self.manualWBMaskView!.bounds.midY - (screenWith/4), screenWith/2, screenWith/2)
let path: UIBezierPath = UIBezierPath(roundedRect:roundedRectFrame, cornerRadius:10  )
path.appendPath(UIBezierPath(rect: bounds))
maskLayer.path = path.CGPath
maskLayer.fillRule = kCAFillRuleEvenOdd
self.manualWBMaskView!.layer.mask = maskLayer

enter image description here

I want it to animate to that position of the cutout from being full screen to above position:

enter image description here

I tried UIView animation, and no luck. Since I already have a CAShapeLayer, I should be able to animate that?

EDIT**

Here's the animation code I tried, which didn't work:

 //Before Animation Make the Mask fill the whole view:
    self.manualWBMaskView!.layer.mask = self.manualWBMaskView!.bounds

    var duration: NSTimeInterval = 0.8

    CATransaction.begin()
    CATransaction[kCATransactionAnimationDuration] = Int(duration)
    self.manualWBMaskView!.layer.mask = CGRectMake(self.manualWBMaskView!.bounds.midX - (screenWith/4), self.manualWBMaskView!.bounds.midY - (screenWith/4), screenWith/2, screenWith/2)
    CATransaction.commit()

As per 'originaluser2's reply below, I have this:

func presentMaskScreenWithAnimation () {
                let bounds: CGRect = self.manualWBMaskView!.bounds
                let maskLayer: CAShapeLayer = CAShapeLayer()
                maskLayer.frame = bounds
                maskLayer.fillColor = UIColor.blackColor().CGColor
                let screenWith  : CGFloat = UIScreen.mainScreen().bounds.width
                let roundedRectFrame : CGRect = self.manualWBMaskView.bounds
                let path: UIBezierPath = UIBezierPath(roundedRect:roundedRectFrame, cornerRadius:0  )
                path.appendPath(UIBezierPath(rect: bounds))
                maskLayer.path = path.CGPath
                maskLayer.fillRule = kCAFillRuleEvenOdd
                self.manualWBMaskView!.layer.mask = maskLayer

       // define your new path to animate the mask layer to
        let screenWith2  : CGFloat = UIScreen.mainScreen().bounds.width
        let roundedRectFrame2 : CGRect = CGRectMake(self.manualWBMaskView!.bounds.midX - (screenWith2/4), self.manualWBMaskView!.bounds.midY - (screenWith2/4), screenWith2/2, screenWith2/2)

        let path2 = UIBezierPath(roundedRect:roundedRectFrame2, cornerRadius:10  )

        // create new animation
        let anim = CABasicAnimation(keyPath: "path")    

        // from value is the current mask path
        anim.fromValue = maskLayer.path

        // to value is the new path
        anim.toValue = path2.CGPath

        // duration of your animation
        anim.duration = 5.0

        // custom timing function to make it look smooth
        anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)

        // add animation
        maskLayer.addAnimation(anim, forKey: nil)

        // update the path property on the mask layer, using a CATransaction to prevent an implicit animation
        CATransaction.begin()
        CATransaction.setDisableActions(true)
           maskLayer.path = path2.CGPath
           maskLayer.fillRule = kCAFillRuleEvenOdd
        CATransaction.commit()

}

It does the animation; However, I got two issues now: 1. Shape is all off during the animation, yet if come together at the end of the animation. (Screenshot below)

  1. Mask is inverted. It 's visible inside the shape, but transparent around it. I need the opposite. Inside the shape being transparent.

enter image description here

Answer

Hamish picture Hamish · Apr 6, 2016

You'll want to use a CABasicAnimation on the path property of your mask layer

This will allow you to animate your mask from a given path, to another given path. Core Animation will automatically interpolate the intermediate steps for you.

Something like this should do the trick:

// define your new path to animate the mask layer to
let path = UIBezierPath(roundedRect: CGRectInset(view.bounds, 100, 100), cornerRadius: 20.0)
path.appendPath(UIBezierPath(rect: view.bounds))

// create new animation
let anim = CABasicAnimation(keyPath: "path")

// from value is the current mask path
anim.fromValue = maskLayer.path

// to value is the new path
anim.toValue = path.CGPath

// duration of your animation
anim.duration = 5.0

// custom timing function to make it look smooth
anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)

// add animation
maskLayer.addAnimation(anim, forKey: nil)

// update the path property on the mask layer, using a CATransaction to prevent an implicit animation
CATransaction.begin()
CATransaction.setDisableActions(true)
maskLayer.path = path.CGPath
CATransaction.commit()

Result:

enter image description here

One thing to note with this is that there appears to be a bug in Core Animation, where it's unable to correctly animate from a path that includes rectangle, to a path that includes a rounded rect with a corner radius. I have filed a bug report with Apple for this.

There is a simple fix for this, which is to use a rounded rect with a corner radius of negligible value on the original path (0.0001 works okay for me). A bit hacky – but you can't notice the difference when using the app.


Bug report response from Apple

Unfortunately, Apple considers this to be "expected behaviour". They said the following:

For the animation to render correctly, the animated paths should have the same number of elements/segments (ideally of the same type). Otherwise, there is no reasonable way to interpolate between the two.

For instance, if a first path has four elements and the second path has six elements, we can easily create the first four points by averaging the first four start and end points with a weight for current time, but it's impossible to come up with a general purpose way to calculate the intermediate positions for the fifth and sixth points. This is why the animation works just fine for corner radius 0.001 (the same number of elements), but fails for plain rectangular path (different number of elements).

I personally disagree, if you specify a rounded rect of corner radius 0 and then animate to a rounded rect with a corner radius > 0 – an incorrect animation is still obtained, even though both paths should have the "same number of elements". I suspect the reason this doesn't work is the internal implementation checks for a radius of 0, and tries to optimise by using a rectangular path with 4 elements instead, which is why it doesn't work.

I will submit another bug report explaining this when I get the time.


A couple of other things...

You're force unwrapping your manualWBMaskView quite a bit. Don't. You need to learn how to properly deal with optionals. Every time you force unwrap something - your app could crash.

You also have a few lines like this:

let maskLayer: CAShapeLayer = CAShapeLayer()

The : CAShapeLayer here is not only unnecessary - but goes against good practise. You should always let Swift infer the type of a value where it can.