Animations add a lot of motion to sports and fitness apps encouraging people to pull on their running shoes for fitness exercises. For instance, this UI concept pictures an activity tracking app that synchronizes with fitness trackers, bracelets, or smartwatches and lets you see a timeline and charts that display your daily activities. You can learn more about activity tracking technology in the following article:

This concept can help other fitness app developers make their app’s UI more intuitive, fun, and engaging. The component is called FitTrack, a compound from fitness tracking, and this animation is contributed to the open-source.

In this article, you will learn how you can use the FitTrack app component in your own project. And then, you’ll take a look under the hood to see what parts this animation consists of.

FitTrack animation for iOS

How to use the animation

Create a collection “activities” that will contain animation view:

let activities = ActivityDataProvider.generateActivities()

animationView.configureSubviews(activities.count, activities: activities)

Activity is a structure of the same name that looks like that:

struct Activity {

let title: String!

let goal: Float!

let currentProgress: Float!

let activityResource: ActivityResource!


HereActivityResource is also a structure that stores resources for Activity (pictures, fonts, colors):

struct ActivityResource {

let normalActivityButtonImage: UIImage!

let selectedActivityButtonImage: UIImage!

let gradientImage: UIImage!

let textColor: UIColor!


To create activity do the following:

// calories activity

let caloriesActivityResource = ActivityResource(normalActivityButtonImage: UIImage(named: “cal_normal”)!, selectedActivityButtonImage: UIImage(named: “cal_active”)!, gradientImage: UIImage(named: “grad_orange”)!, textColor: UIColor.caloriesActivityColor())

let caloriesActivity = Activity(title: “Calories”, goal: 2000, currentProgress: 1500, activityResource: caloriesActivityResource)


Also for the launch animation (just like in Twitter) you should use the method of the ZoomStartupAnimation class:

ZoomStartupAnimation.performAnimation(window!, navControllerIdentifier: “navigationController”, backgroundImage: UIImage(named: “bg”)!, animationImage: UIImage(named: “logo”)!)

Where animationImage is a picture that will be glued together.

What’s under the hood of the animation?

Three classes form the core of the component:

  • ZoomStartupAnimationis a class responsible for the launch animation (the heart).
  • AnimationView (Animations extension) is a class that counts the initial size and positions of the buttons. It is also an extension that contains a variety of animations.
  • ActivityContainerViewis a class-container that contains activities and animations while one activity is changing for another one.

Let’s take a closer look:

The ZoomStartupAnimation class has one static method performAnimation that creates a new mask with a picture transmitted to it, as well as a background with the same picture for a correctly animated resizing:

// logo mask

navController.view.layer.mask = CALayer()

navController.view.layer.mask!.contents = animationImage.CGImage

navController.view.layer.mask!.bounds = CGRect(x: 0, y: 0, width: startAnimationImageWidth, height: startAnimationImageWidth)

navController.view.layer.mask!.anchorPoint = CGPoint(x: 0.5, y: 0.5)

navController.view.layer.mask!.position = CGPoint(x: navController.view.frame.width / 2, y: navController.view.frame.height / 2)

// logo mask background view

let maskBackgroundImageView = UIImageView(frame: navController.view.layer.mask!.frame)

maskBackgroundImageView.image = animationImage



Then, the class enlarges (CAKeyframeAnimation(keyPath: “bounds”)) the mask scale and after a small delay hides or deletes it:

let transformAnimation = CAKeyframeAnimation(keyPath: “bounds”)

transformAnimation.duration = transformAnimationDuration

transformAnimation.beginTime = CACurrentMediaTime() + transformAnimationDelay

let initalBounds = NSValue(CGRect: navController.view.layer.mask!.bounds)

let finalBounds = NSValue(CGRect: CGRect(x: 0, y: 0, width: finishAnimationImageWidth, height: finishAnimationImageWidth))

transformAnimation.values = [initalBounds, finalBounds]

transformAnimation.timingFunctions = [CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)]

transformAnimation.removedOnCompletion = false

transformAnimation.fillMode = kCAFillModeForwards

navController.view.layer.mask!.addAnimation(transformAnimation, forKey: “maskAnimation”)

maskBackgroundImageView.layer.addAnimation(transformAnimation, forKey: “maskAnimation”)

The AnimationView class is responsible for the calculation of the initial positions and sizes of the buttons depending on their number. The following method takes care of this:

private func createAndSetStartPositionRoundActivityButtons() {

let allContentWidth = bounds.width

let buttonWidth = allContentWidth / CGFloat(activitiesCount)

gapBetweenActivityButtons = (buttonWidth / ratioItemWidthToGap)

let deltaWidth = gapBetweenActivityButtons / CGFloat(activitiesCount)

realActivityButtonWidth = (buttonWidth – deltaWidth – gapBetweenActivityButtons)

centerActivityRoundButton = RoundActivityButton.init(frame: CGRectMake(0, 0, realActivityButtonWidth * animateScalFirsteButtonCoefficient, realActivityButtonWidth * animateScalFirsteButtonCoefficient))

centerActivityRoundButton!.backgroundColor = UIColor.clearColor()

centerActivityRoundButton!.center = CGPointMake(bounds.width / 2, (bounds.height / 2) / dropButtonsPositionCoefficient)

centerActivityRoundButtonIndex = activitiesCount%2 == 0 ? -1 : activitiesCount / 2

currentActiveRoundButtonTag = 0


for index in 0..<activitiesCount {

let startFrame = CGRectMake(gapBetweenActivityButtons + (gapBetweenActivityButtons + realActivityButtonWidth) * CGFloat(index), -realActivityButtonWidth, realActivityButtonWidth, realActivityButtonWidth)

let roundButton = RoundActivityButton.init(frame: startFrame)

let activity = activities![index]

let normalStateImage = activity.activityResource.normalActivityButtonImage

let selectStateImage = activity.activityResource.selectedActivityButtonImage

roundButton.setBackgroundImage(normalStateImage, forState: .Normal)

roundButton.setBackgroundImage(selectStateImage, forState: .Selected)

roundButton.setBackgroundImage(selectStateImage, forState: [.Highlighted, .Selected])

roundButton.userInteractionEnabled = false

roundButton.tag = index

roundButton.addTarget(self, action: “roundButtonPressed:”, forControlEvents: .TouchUpInside)




// it’s necessary when collecting all items. First item should be always bring to front


activityContainerView.transform = CGAffineTransformMakeScale(transformScale, transformScale)



The Animations extension to the AnimationView class is responsible for animating all child objects using simple blocks of the view animation, and launches the next animation in every completion block. The extension moves the buttons one by one from the top of the view to the center. After the last button is moved, the extension will launch the animation that will increase the distance between the buttons:

internal func aimateDrop() {

struct Counter {

static var index = 0


if Counter.index == roundActivityButtons.count {




let roundActivityButton = roundActivityButtons[Counter.index]

let delay: NSTimeInterval = Counter.index == 0 ? 1 : 0


delay: delay,

usingSpringWithDamping: animateDropDumping,

initialSpringVelocity: animateDropVelocity,

options: .CurveLinear,

animations: {

let changedFrame = CGRectMake(roundActivityButton.frame.origin.x, (self.bounds.height / 2) / dropButtonsPositionCoefficient – roundActivityButton.frame.height / 2, roundActivityButton.frame.width, roundActivityButton.frame.height)

roundActivityButton.frame = changedFrame

}) { finished in

if finished == true {

Counter.index += 1





private func animateExtendDistance() {


delay: animateExtendDistanceDelay,

options: .CurveLinear,

animations: {

for index in 0..<self.activitiesCount {

if index == self.centerActivityRoundButtonIndex { // the number is odd then the central position of the index does not change



let roundActivityButton = self.roundActivityButtons[index]

let originDeltaX: CGFloat = (self.activitiesCount / (index + 1)) < 2 ? –self.gapBetweenActivityButtons / 2 : self.gapBetweenActivityButtons / 2

let changedFrame = CGRectMake(roundActivityButton.frame.origin.x – originDeltaX, roundActivityButton.frame.origin.y, roundActivityButton.frame.width, roundActivityButton.frame.height)

roundActivityButton.frame = changedFrame


}) { finished in

if finished == true {





The last class – ActivityContainerView – accepts the structure of the Activity type and is responsible for the animated change of the activity using CABasicAnimation class for an animated turn of activity and the delegate method for an animated zoom after the turn:

// MARK – Public methods

func flipView(activity: Activity) {

self.containerView.transform = CGAffineTransformIdentity


let transform = CATransform3DMakeRotation(CGFloat(M_PI), 0, 1, 0) // rotate around Y

let flipAnimation = CABasicAnimation(keyPath: “transform”)

flipAnimation.fromValue = NSValue(CATransform3D: CATransform3DIdentity)

flipAnimation.toValue = NSValue(CATransform3D: transform)

flipAnimation.duration = flipAnimationDuration

flipAnimation.fillMode = kCAFillModeBoth

flipAnimation.removedOnCompletion = true

flipAnimation.delegate = self

subview!.layer.addAnimation(flipAnimation, forKey: “transform”)


override func animationDidStop(animation: CAAnimation, finished flag: Bool) {

if flag == true {



delay: 0,

usingSpringWithDamping: animateSpringDamping,

initialSpringVelocity: animateSpringVelocity,

options: .CurveLinear,

animations: {

self.containerView.transform = CGAffineTransformMakeScale(animateTransformScaleX, 1)

}, completion: nil)



Ta da! You’re ready to go with the animation! Hope you enjoy it in your app.