Animated Custom Controls in Swift

Animated Custom Controls in Swift
Cool, custom animations!

Cool, custom animations!

One of the things that makes iOS so much fun to develop for is the ease of making sophisticated custom controls.

This will ring true for almost every developer. But especially for those of you who’ve ever had the misfortune of trying to do even the slightest customizations of controls under Windows 3.1, or had to hack old-school Mac resource files with a hex editor just to change a button’s color.

It’s likely that you still harbor P.T.S.D, have battle-scars or awake in panic-driven fevered night-sweats as a result — trust me, I know all about it. However, you’re about to do something remarkable that will help you finally get over the trauma.

In this tutorial, you’ll build a animated custom control that expands into a number of different selectable buttons with fun little animations when touched. Core Animation does the heavy lifting, which you’ll use instead of UIView’s animation methods because it lets you enjoy a much higher degree of control with very little additional overhead.

Getting Started

Download the starter project here. It uses the Single View Application template and displays a sole button in the middle of the screen, as shown in fig. 1a. The button doesn’t do anything just yet, but by the end of this tutorial, it will animate into the menu shown in fig 1b.

Squishy.fig.1A.1B

The starter project consists of four classes. The control itself comprises of SquishyControl and SquishyControlItem. The former encapsulates all of the button instances and handles all of the animation, while the latter is for each of the individual button-item-thingies. And yes, thingies is the technical term. The other two classes merely act as the boilerplate code for a very simple one screen app, which you’ll use to test the control.

In order to know when a button is tapped, the presenting view controller will need to conform to the SquishyControlDelegate protocol, as well as the SquishyControlItemDelegate protocol, to capture and forward touch events from each of the buttons. More on this later.

There are three different animations to configure, making this a fairly busy object:

  1. The central home, or “launch,” button, the red one, spins left and right when clicked and released.
  2. The array of black menu buttons expand outward as if they’re being squished out from under the red one.
  3. A selected button will expand and fade out as the others shrink and fade.

The Launch Button

First, SquishyControl.swift must handle touch events for the entire screen. Ultimately, this closes the menu after a touch on any part of the screen outside any of the button’s hit areas.

This can be done using a gesture recognizer to handle taps. Add the following to the bottom of init(_:startItem:optionsMenus:):

let tapRecognizer = UITapGestureRecognizer(target: self, action: "handleTapGesture:")
addGestureRecognizer(tapRecognizer)

Here you’re creating a tap gesture recognizer and adding it to the view. Now you need to add the action method.

Add the following to SquishyControl, just below the initializers:

func handleTapGesture(sender: UITapGestureRecognizer) {
  handleTap()
}

If you want to see that you are consuming taps, comment out the call to handleTap(), add a breakpoint on the closing brace, then build and run. If all works you should trigger the breakpoint by tapping anywhere on the screen except for the buttons.

handleTap() is called from two different directions. The first is handled above to allow consuming events on the background of the screen. The second will be needed to handle events from the menu items themselves.

SquishyControl now needs to act as the delegate of SquishyControlItem; you’ll do this using Swift extensions.

Note: A Swift extension can be used to isolate a delegate implementation into a separate block of code to make a class a little more insular and readable at the same time. You can read more about this on our Swift Style Guide.

Add these two delegate methods to the end of SquishyControl.swift, after the closing curly bracket of the class definition:

extension SquishyControl: SquishyControlItemDelegate{
 
  public func SquishyControlItemTouchesBegan(item: SquishyControlItem) {
    if (item == startButton) {
      handleTap()
    }
  }
 
  public func SquishyControlItemTouchesEnd(item:SquishyControlItem) {
    if (item == startButton) {
      return
    }
  }
}

Notice how startButton is intercepted for special cases; this button behaves differently to all the rest.

Add the following inside SquishyControl. This creates the first animation that makes the central button spin around when tapped:

public func handleTap() {
  var state = motionState!
  var degreesPerRadians: CGFloat = 57.3
  var selector: Selector?
  // 1
  var angle: CGFloat = 225.0
  // 2
  switch state {
    case .Close:
      motionState = State.Expand
      angle = angle/degreesPerRadians
 
    case .Expand:
      motionState = State.Close
      angle = 0.0
  }
  // 3
  if let rotateAddButton = rotateAddButton {
    UIView.animateWithDuration(Double(startMenuAnimationDuration!), animations: { () -> Void in
      self.startButton.transform = CGAffineTransformMakeRotation(angle)
    })
  }
}

So, what’s going on here?

  1. When tapped, the button will spin 225 degrees, which is slightly more than a half rotation. This will result in the + looking more like x, with a substantial motion thrown in.
  2. motionState keeps track of the current state of the overall menu, e.g. whether it’s open or not, and is handled by the switch block.
  3. Here you ensure that only the red add button receives this rotation while it ignores all others.
  4. animateWithDuration(_:animations:) is a quick and painless method that does an awful lot underneath the hood:
    • duration, the first argument, is in seconds.
    • The second argument is a closure that specifies the actual button rotation. Each view has a transform property that contains rotation, transformation and scaling info. Untouched, it defaults to 0 radians of rotation and a scaling of 1, or in other words, it remains unchanged.
    • By adding CGAffineTransformMakeRotation(angle), with just the final value of 225 degrees, you tell the system to rotate the view 225 degrees counter clockwise in 0.2 seconds. The animation lasts for 0.2 seconds, but only because that is the default value contained in startMenuAnimationDuration. Feel free to play around with this value and find a duration that looks right to your eye. The animation executes asynchronously, meaning that your app can continue other operations while iOS handles the rotation behind the scenes. Try doing that in one line of Win32. :]

Finally, add the following line to the very bottom of init(_:startItem:optionsMenus:):

startButton.delegate = self

Build and run. Tap the screen and you’ll see the button spin counter-clockwise, then clockwise if you tap a second time. The result should be that the button starts out looking like the image of the left, but ends up like the one on the right when done.

redButton

Squishy Menu Buttons

It’s time to handle the menu buttons. When the red button is tapped, the others will expand out to a certain distance, and then bounce back just a little to add a jolly little bit of motion.

First, add the following optional to the top of handleTap():

var selector: Selector?

This will be used in the switch block to dynamically pick which method to call at run time. Admittedly, this is a non-standard design, but it’s perfectly safe when used properly and has the added value of improving readability.

Next, add the following to the bottom of the .Close case:

selector = "expand"

Then, add this statement to the bottom of the .Expand case:

selector = "close"

Finally, add the following at the very bottom of handleTap():

if (!timer) {
  timer = NSTimer.scheduledTimerWithTimeInterval(0.0, target: self, selector: selector!, 
      userInfo: nil, repeats: true)
  NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSRunLoopCommonModes)
}

The timer is created to invoke the main animation routine, be it expand() or close(). Note that the timer object is added to the current run loop by and use the mode NSRunLoopCommonModes. This mode specifies that the timer event is common across all threads, ensuring it will be serviced more frequently and the animation is less likely to drop frames.

Now you need to create a method that will calculate the three points needed to define each button’s animation path, their final resting place, as well as an overshoot and undershoot value to cause that jolly little bounce effect.

Add the following to SquishyControl:

public func setMenu() {
  let count: Int = menusArray.count
  var denominator: Int?
 
  for (index, value) in enumerate(menusArray){
    var item = menusArray[index]
    item.tag = 1000 + index
    item.startPoint = startPoint
 
    // avoid overlap
    if (menuWholeAngle >= CGFloat(M_PI) * 2) {
      menuWholeAngle = menuWholeAngle! - menuWholeAngle! / CGFloat(count)
    }
 
    if count == 1 {
      denominator = 1
    } else {
      denominator = count - 1
    }
 
    let temp1 = sinf(Float(index) * Float(menuWholeAngle!) / Float(denominator!))
    let temp2 = cosf(Float(index) * Float(menuWholeAngle!) / Float(denominator!))
 
    // the final resting place
    let i1 = Float(endRadius) * temp1
    let i2 = Float(endRadius) * temp2
    item.endPoint = CGPoint(x: startPoint.x + CGFloat(i1), y: startPoint.y - CGFloat(i2))
 
    // the nearest part of the bounce to the center
    let j1 = Float(nearRadius) * temp1
    let j2 = Float(nearRadius) * temp2
    item.nearPoint = CGPoint(x: startPoint.x + CGFloat(j1), y: startPoint.y - CGFloat(j2))
 
    // the furthest part of the bounce from the center
    let k1 = Float(farRadius) * temp1
    let k2 = Float(farRadius) * temp2
    item.farPoint = CGPoint(x: startPoint.x + CGFloat(k1), y: startPoint.y - CGFloat(k2))
 
    item.center = item.startPoint!
    item.delegate = self
 
    insertSubview(item, belowSubview: startButton)
  }
}

The value menuWholeAngle specifies the breadth of the angle of the displayed buttons. At 360°, the buttons will be evenly spaced around a complete circle.

At 180°, the first and last buttons will be spaced a half-circle apart, with any others evenly spaced between them. This comes in handy if you want to place the red button along the side of your screen so that none of the buttons will be clipped — note that the angles are expressed in radians.

You can build and run at this point, if you dare, but you’ll get a crash until you add both expand() and close(), which, as a matter of fact, you’ll do next. Ta-dah!

Expanding

First up is expand(); it takes the three points calculated above and generates instances of CAKeyFrameAnimation for each part of the animation.

Key frames tell the system where the object is supposed to be at a specific time, while they let iOS handle all intermediate locations.

Once finished, you’ll insert each button into the view stack underneath the red button so they remain hidden until expand() is called.

Ready? Thought so! Add the following method just below setMenu():

public func expand() {
  var count = 0;
 
  if flag == menusArray.count {
    timer?.invalidate()
    timer = nil
    return
  }
 
  count++
 
  let tag: Int = 1000 + flag!
  var item = viewWithTag(tag) as! SquishyControlItem
 
  // 1
  let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
  // 2
  rotateAnimation.values = [0.0, expandRotation!,0.0]
  // 3
  rotateAnimation.duration = CFTimeInterval(expandRotateAnimationDuration!)
  // 4
  rotateAnimation.keyTimes = [0.0, 0.8, 1.0]
  // 5
  let positionAnimation = CAKeyframeAnimation(keyPath: "position")
  positionAnimation.duration = CFTimeInterval(animationDuration!)
  // 6
  let path = CGPathCreateMutable()
  // 7
  CGPathMoveToPoint(path, nil, item.startPoint!.x, item.startPoint!.y)
  CGPathAddLineToPoint(path, nil, item.farPoint!.x, item.farPoint!.y)
  CGPathAddLineToPoint(path, nil, item.nearPoint!.x, item.nearPoint!.y)
  CGPathAddLineToPoint(path, nil, item.endPoint!.x, item.endPoint!.y)
  // 8
  positionAnimation.path = path
  // 9
  let animationgroup = CAAnimationGroup()
  animationgroup.animations = [positionAnimation, rotateAnimation]
  animationgroup.duration = CFTimeInterval(animationDuration!)
  animationgroup.fillMode = kCAFillModeForwards
  // 10
  animationgroup.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
  animationgroup.delegate = self
 
  if flag == (menusArray.count - 1) {
    animationgroup.setValue("firstAnimation", forKey: "id")
  }
  // 11
  item.layer.addAnimation(animationgroup, forKey: "Expand")
  item.center = item.endPoint!
 
  flag!++
}

There’s a lot of stuff going on here, but what Apple has managed to accomplish with their frameworks is pretty elegant.

The first part invalidates, or shuts off, the timer once all items have finished processing. The rest of the code sets up the compound animation group using Core Animation.

Note: Simple animations can be created with UIView’s animatable properties, but for more complicated animations it’s best to use Core Animation.

Why? Well, it lets you easily layer multiple animations, as well as handling the intermediate key frame animations.

In this case, the buttons need simultaneous rotation and translation. If you build and run right now, you’ll see what I mean. A single black button will expand out from under the red one, but since there is no close method yet, it will crash when you tap on it.

The first block allows the button to spin around its center, the rotation, while the other will move, or translate, the button out along a predetermined path simultaneously.

Rotation is handled as follows:

  1. You create a CAKeyFrameAnimation object using a key path of transform.rotation.z. The available key paths are hard to come by in the Apple documentation, but can be found here.
  2. values is an array of rotational key points, and the three values in this case make the button rotate from 0 to 360 degrees by using expandRotation, and then back to 0.
  3. The animation duration in seconds.
  4. keyTimes performs a one-to-one mapping with the values array and is proportional to the duration that you set during initialization. Here you start at 0 seconds for the first part of the rotation, then at 0.8 or 80 percent into the total duration, you should be 360 degrees away from the original heading, or a full turn. The final value is 1.0, so at the end of the animation it will snap back to the start.
  5. Generate a CAKeyFrameAnimation as above, but use position as the key path.
  6. Create a bezier path based on the three CGPoint values using CGPathCreateMutable().
  7. The points are then added by a combination of CGPathMoveToPoint() and CGPathAddLineToPoint calls. The former sets the starting point, the center of the red button, while the latter is called once for each additional point. Of course, you can layer on more and more points to give a more complicated and potentially more amusing path.
  8. Add the new path to positionAnimation.path
  9. Create an animation group and set animations to an array that has just your two animations. fillMode is set to kCAFillModeForwards, which tells to system that any properties changed in the animation should remain at their final state, rather than jumping back.
  10. The timingFunction specifies how the objects start and stop their motion. For example, kCAMediaTimingFunctionEaseIn, means that the animation curve starts slowly and picks up speed instead of starting at full speed. Conversely, there is kCAMediaTimingFunctionEaseOut that does just the opposite.
  11. The final item adds the animation group to the button’s layer and sets its center property to the end point. The layer manages the actual image data of the view.

Build and run. This time you should get you something like this after you tap the center red button:

oneSquishyButton

But since close() still doesn’t exist, tapping the button again will cause the app to crash. You’ll fix that now!

Closing

As you might expect, the animation to close the menu is quite similar to that used in expand(). Add the following to the bottom of SquishyControl:

public func close() {    
  if (flag! == -1) {
    timer?.invalidate()
    timer = nil
    return
  }
 
  let tag = Int(1000 + flag!)
  var item = viewWithTag(tag) as! SquishyControlItem
 
  let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
 
  rotateAnimation.values = [0.0, closeRotation!,0.0]
  rotateAnimation.duration = CFTimeInterval(closeRotateAnimationDuration!)
  rotateAnimation.keyTimes = [0.0, 0.4, 0.5]
 
  let positionAnimation = CAKeyframeAnimation(keyPath: "position")
 
  positionAnimation.duration = CFTimeInterval(animationDuration!)
  let path = CGPathCreateMutable()
 
  CGPathMoveToPoint(path, nil, item.endPoint!.x, item.endPoint!.y)
  CGPathAddLineToPoint(path, nil, item.farPoint!.x, item.farPoint!.y)
  CGPathAddLineToPoint(path, nil, item.startPoint!.x, item.startPoint!.y)
  positionAnimation.path = path
 
  let animationgroup = CAAnimationGroup()
 
  animationgroup.animations = [positionAnimation, rotateAnimation]
  animationgroup.duration = CFTimeInterval(animationDuration!)
  animationgroup.fillMode = kCAFillModeForwards
  animationgroup.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
  animationgroup.delegate = self
 
  if flag == 0 {
    animationgroup.setValue("lastAnimation", forKey: "id")
  }
 
  item.layer.addAnimation(animationgroup, forKey: "Close")
  item.center = item.startPoint!
 
  flag!--
}

I won’t reiterate what each part does; if you’re curious then feel free to re-read the explanation above for expand(), as the code is almost identical.

Build and run. You should be able to complete the entire expand and close cycle without those pesky crashes. :]

Responding to Selection

However, the selection behavior still needs to be added to the menu items. The effect you want is that when you select one of these buttons, it should expand and fade out, while all the others shrink and fade.

Now you’ll make use of SquishyControlItemDelegate. Luckily, SquishyControl already has the necessary code, but it’s not called by anything yet. You’ll fix that now!

As before you’re going to use gesture recognizers, but with a slight twist; this time around you’ll use UILongPressGestureRecognizer so you can have fine-grained control over the duration that lapses before the tap is recognized.

Add the following to the bottom of init(backgroundImage:highlightedImage:contentImage:highlightedContentImage:) in SquishyControlItem.swift:

let tapDownRecognizer = UILongPressGestureRecognizer(target: self, action: "handleLongPress:")
tapDownRecognizer.minimumPressDuration = 0.01
addGestureRecognizer(tapDownRecognizer)

minimumPressDuration is set to an arbitrarily low number so handleLongPress(_:) is called immediately. Take a look at the implementation of handleLongPress(_:) to see how the different states of the gesture recognizer are handled.

When finished, build and run. See if the behavior is as expected. A long press should trigger the .Begin block. Wait a few seconds, raise your finger and the .End block will now be called.

Selecting a Button

Finally, you’ll add the selection animations to the menu item to perform a cool little effect once you tap on any of them.

Modify SquishyControlItemTouchesEnd() in SquishyControl.swift so it matches the following:

public func SquishyControlItemTouchesEnd(item:SquishyControlItem) {
  if (item == startButton) { return }
 
  let blowup = blowupAnimationAtPoint(item.center)
  item.layer.addAnimation(blowup, forKey: "blowup")
  item.center = item.startPoint!
 
  for (index, value) in enumerate(menusArray) {
    let otherItem = menusArray[index] as SquishyControlItem
    let shrink = shrinkAnimationAtPoint(otherItem.center)
    if (otherItem.tag == item.tag) {
      continue
    }
    otherItem.layer.addAnimation(shrink, forKey: "shrink")
    otherItem.center = otherItem.startPoint!
  }
 
  motionState = State.Close
 
  let angle = motionState == State.Expand ? CGFloat(M_PI_4) + CGFloat(M_PI) : 0.0
  UIView.animateWithDuration(Double(startMenuAnimationDuration!), animations: {() -> Void in
    self.startButton.transform = CGAffineTransformMakeRotation(angle)
  })
 
  delegate?.squishyControlSelected?(self, didSelectIndex: item.tag - 1000)
}

This enumerates through the button items and assigns which animation goes with each. The selected item gets the “blowup” animation and all others take the “shrink” animation, which effectively generates two animation groups.

The method blowupAnimationAtPoint(_:) returns an instance of CAAnimationGroup. It’s a good example of how to modularize and cache animation groups. It works the same for shrinkAnimationAtPoint(_:). You’ll add both momentarily.

Add the following to the bottom of SquishyControl:

private func blowupAnimationAtPoint(p: CGPoint) -> CAAnimationGroup {    
  let positionAnimation = CAKeyframeAnimation(keyPath: "position")
  positionAnimation.values = [NSValue(CGPoint: p)]
 
  let scaleAnimation = CABasicAnimation(keyPath: "transform")
  scaleAnimation.toValue = NSValue(CATransform3D: CATransform3DMakeScale(3, 3, 1))
 
  let opacityAnimation = CABasicAnimation(keyPath: "opacity")
  opacityAnimation.toValue = 0.0
 
  let animationgroup = CAAnimationGroup()
  animationgroup.animations = [positionAnimation, scaleAnimation, opacityAnimation]
  animationgroup.duration = CFTimeInterval(animationDuration!)
  animationgroup.fillMode = kCAFillModeForwards
 
  return animationgroup
}
 
private func shrinkAnimationAtPoint(p: CGPoint) -> CAAnimationGroup {
  let positionAnimation = CAKeyframeAnimation(keyPath: "position")
  positionAnimation.values = [NSValue(CGPoint: p)]
  positionAnimation.keyTimes = [3]
 
  let scaleAnimation = CABasicAnimation(keyPath: "transform")
  scaleAnimation.toValue = NSValue(CATransform3D: CATransform3DMakeScale(0.01, 0.01, 1))
 
  let opacityAnimation = CABasicAnimation(keyPath: "opacity")
  opacityAnimation.toValue = 0.0
 
  let animationgroup = CAAnimationGroup()
  animationgroup.animations = [positionAnimation, scaleAnimation, opacityAnimation]
  animationgroup.duration = CFTimeInterval(animationDuration!)
  animationgroup.fillMode = kCAFillModeForwards
 
  return animationgroup
 }

Build and run, and be amazed Earthling.

So, what’s happening here? By now, you should be familiar enough with Core Animation’s model to figure it out. In this case, three animations are layered on top of each other.

The first manipulates the position, and uses CAKeyframeAnimation. Technically, you’re not moving the buttons, but without this, the items will jump back to beneath the red button using their default position.

The other two use CABasicAnimation, which is in effect a key frame animation, but with a single value. Scaling is done in a way that resizes the object along each of its three dimensions: width, height and depth. Depth is not needed because this is a 2D object, so you default it to 1. If it’s set to 0, the button will vanish completely. You scale the button so it’s three times its original size at the end of the animation.

If you like, you can play around with these values to get something that better suits your individual tastes.

As before, the animations are grouped together for simultaneous execution.

Adding Polish

A single button is a bit of a waste for an elegant menu like this. You can add several more by updating viewDidLoad() in ViewController.swift.

Each menu item is created using SquishyControlItem(backgroundImage:highlightedImage:contentImage:highlightedContentImage:).

Simply supply the images for both highlighted and non-highlighted versions for each item, and then add each item to the menus array by changing the name of each new element.

let starMenuItem1 = SquishyControlItem(image: menuItemImage, highlightedImage: menuItemImagePressed,
    contentImage: starImage, highlightedContentImage:nil)
let starMenuItem2 = SquishyControlItem(image: menuItemImage, highlightedImage: menuItemImagePressed,
    contentImage: starImage, highlightedContentImage:nil)
let starMenuItem3 = SquishyControlItem(image: menuItemImage, highlightedImage: menuItemImagePressed,
    contentImage: starImage, highlightedContentImage:nil)
 
var menus = [starMenuItem1, starMenuItem2, starMenuItem3]

Here is what it looks like with 3 menu items, and 7 menu items:

finalResults

Finally, it’s time to notify your client when any of the buttons are selected. The delegate protocol is already defined in the original files, like so:

protocol SquishyControlDelegate {
  func squishyControlSelected(menu: SquishyControl, didSelectIndex idx: Int)
}

Open ViewController.swift and add the following extension at the very bottom of the file:

extension ViewController: SquishyControlDelegate {
  func squishyControlSelected(menu: SquishyControl, didSelectIndex idx: Int) {
    println("Select the index : \(idx)")
  }
}

And lastly, add this to viewDidLoad() in viewController(), not the extension:

menu.delegate = self

Build and run. Now when you select a menu item you should see the message printed to the console.

Where to Go From Here

Now’s the time to explore a little bit. Try adding different graphics to each of the buttons, moving the control down to the bottom of the screen and telling it to move all the buttons to the visible top half.

And if you’re as eager to get to the end result as I am, you can get the entire completed project from here.

I hope you enjoyed learning how to make some cool animations with relative ease using Core Animation.

If you have questions, comments or want to share your own discoveries, please comment in the forums below!

Attribution

This project is based on an open source control called PathMenu, and it comes from one of my favorite websites, Cocoa Controls.

Cocoa Controls is gravid with great controls and libraries, nearly all of which are open-source and many of them designed to mimic controls in commercial products such as this one does — although they have way too many activity controls or cellar menus for my taste. :]

Used with permission of the original author, Nagasawa Hiroki. Make sure to check out his many other projects.

The post Animated Custom Controls in Swift appeared first on Ray Wenderlich.

3
Like
Save

Comments

Write a comment

*