How We Developed FitTrack, Fitness Activity Tracking Animation for iOS

This is not the first time we’ve been experimenting with user interface for fitness apps. You probably remember our Eat Fit iOS animation for an activity tracking app inspired by Google Fit.

We believe animations add a lot of motion to sports and fitness apps encouraging people to pull on their running shoes for fitness exercises. Our UI concept designed by Anton Kosolapov pictures an activity tracking app that synchronizes with fitness trackers, bracelets, or smart watches and lets you see a timeline and charts that display your daily activities.

We thought this concept would help other fitness app developers make their app’s UI more intuitive, fun, and engaging. We called our component FitTrack, a compound from fitness tracking, and we’re contributing this animation to the open source.

In this article, we will tell you how you can use the FitTrack app component in your own project. And then, we will 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!

Here ActivityResource 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:

  • ZoomStartupAnimation is 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.

  • ActivityContainerView is 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)

That’s all! Check out the links:

No reviews yet
Remember those Facebook reactions? Well, we aren't Facebook but we love reactions too. They can give us valuable insights on how to improve what we're doing. Whould you tell us how you feel about this article?
Excited to create something outstanding?

We share the same interests.

Let's team up!