Chapter 14
Assembling the Stars

Estimated Time


It’s time to assemble all 8 layers of the Stars background and hook them up. The list of layers is:

  1. dark background stars
  2. vignette
  3. small background stars
  4. medium background stars
  5. large background stars
  6. lines
  7. small constellation stars
  8. big constellation stars

The way we’re going to do this is like so:

  1. Create an array of speeds so we know how quickly to move each layer
  2. Create and add all the layers based on their speeds
  3. Add a context and observer for creating the parallax effect
  4. Add snapping
  5. Add animations for the lines

Create Speeds and Scrollviews Arrays

This step is dead-easy. Create an array of CGFloat values that we’ll be using throughout the rest of our setup.

Open Stars.swift and add the following variable to your class:

1
let speeds : [CGFloat] = [0.08,0.0,0.10,0.12,0.15,1.0,0.8,1.0]

We have simply taken the values from the design file and added them in order such that the bottom layer’s speed, 0.08 is the first entry (i.e. [0]) in the array, and the top layer’s speed is the last entry.

We label the array : [CGFloat] because we’re going to be passing these values directly to views that are subclassed from UIScrollView and it’s just a bit cleaner to not have to cast from Double when we know we’re only going to be working with CGFloat variables.

Finally, add a variable array of InfiniteScrollview types to your class. Later on we’re going to reference this array so we’ll add it here to be a bit ahead of the game.

1
var scrollviews : [InfiniteScrollView]!

If you worked through the ParallaxBackground chapter, this is where things start to get a bit different.

Next, we also know that we need layers for the lines, small and big stars. So, create variables that will reference those:

1
2
3
var signLines : SignLines!
var bigStars : StarsBig!
var snapTargets : [CGFloat]!

Your class should look like this:

1
2
3
4
5
6
7
8
9
10
class Stars : CanvasController, UIScrollViewDelegate {
    let speeds : [CGFloat] = [0.08,0.0,0.10,0.12,0.15,1.0,0.8,1.0]
    var scrollviews : [InfiniteScrollView]!
    var signLines : SignLines!
    var bigStars : StarsBig!
    var snapTargets : [CGFloat]!

    override func setup() {
    }
}

Create Layers

Since we’ve already built the various star background classes (all subclasses of InfiniteScrollview), we can start working with them right away. The easiest way to add all our layers is to create and add them individually based on their speeds and images.

The previous image shows how varying speeds will actually dictate how much of a scrollview’s contents will actually be seen. Compared to the top layer which encapsulates the full width of all our app’s contents, the first layer with a speed of 0.08 will only need a contentSize that is 8% the width of the top layer’s size.

The Vignette

There is one layer that we haven’t considered yet: the vignette. This is an image that sits behind most of the other layers, but over top of one, to give an added sense of depth. Since the vignette doesn’t move, we don’t need to make a class for it.

However, because all our other layers are subclasses of InfiniteScrollview we want our vignette to be the same. Add the following function:

1
2
3
4
5
6
7
func createVignette() -> InfiniteScrollView {
    let sv = InfiniteScrollView(frame: view.frame)
    let img = Image("1vignette")!
    img.frame = canvas.frame
    sv.add(img)
    return sv
}

Simple.

The First 5 Layers

Here’s how we create the first five layers. Add the following to setup():

1
2
3
4
5
6
7
8
canvas.backgroundColor = COSMOSbkgd

scrollviews = [InfiniteScrollView]()
scrollviews.append(StarsBackground(frame: view.frame, imageName: "0Star", starCount: 20, speed: speeds[0]))
scrollviews.append(createVignette())
scrollviews.append(StarsBackground(frame: view.frame, imageName: "2Star", starCount: 20, speed: speeds[2]))
scrollviews.append(StarsBackground(frame: view.frame, imageName: "3Star", starCount: 20, speed: speeds[3]))
scrollviews.append(StarsBackground(frame: view.frame, imageName: "4Star", starCount: 20, speed: speeds[4]))

I also added a background color.

The Last 3 Layers

The last 3 layers are a bit different in that the 6th and 8th need to be place in variables. The reason for this is that later on we want to act on the top layer as well as the layer with the lines.

Add the following to your setup():

1
2
3
4
5
6
7
8
9
10
11
signLines = SignLines(frame: view.frame)
scrollviews.append(signLines)

scrollviews.append(StarsSmall(frame: view.frame, speed: speeds[6]))

bigStars = StarsBig(frame: view.frame)
scrollviews.append(bigStars)

for sv in scrollviews {
    canvas.add(sv)
}

We create the signLines variable, then append that to the scrollviews array. Then, we add the small stars, followed by creating the bigStars variable and then append that as well.

Finally, we iterate through all the scrollviews in the array and add them to the canvas one at a time.

Observe the Big Stars

The next step is to start observing the top layer for when it scrolls, then have all the other layers update their positions based on that top layer.

If you want a run through of observing and contexts, etc., have a look at the ParallaxBackground chapter.

Start by adding the following variable to your class:

1
var scrollviewOffsetContext = 0

Then, after creating bigStars add the following two lines to setup():

1
2
bigStars.addObserver(self, forKeyPath: "contentOffset", options: .New, context: &scrollviewOffsetContext)
bigStars.delegate = self

On its own, this won’t do anything. To see the tracking in action we’ll have to add an observeValueForKeyPath function that has a bit of logic to determine how the layers should scroll.

1
2
3
4
5
6
7
8
9
10
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
    if context == &scrollviewOffsetContext {
        let sv = object as! InfiniteScrollView
        let offset = sv.contentOffset
        for i in 0..<scrollviews.count-1 {
            let layer = scrollviews[i]
            layer.contentOffset = CGPointMake(offset.x * speeds[i], 0.0)
        }
    }
}

Switch your project’s WorkSpace to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import UIKit

//three colors we'll use throughout the app, so we make them project-level variables
let COSMOSprpl = Color(red:0.565, green: 0.075, blue: 0.996, alpha: 1.0)
let COSMOSblue = Color(red: 0.094, green: 0.271, blue: 1.0, alpha: 1.0)
let COSMOSbkgd = Color(red: 0.078, green: 0.118, blue: 0.306, alpha: 1.0)


class WorkSpace: CanvasController {
    var background = Stars()
    var stars = Stars()
    
    override func setup() {
        
        canvas.backgroundColor = COSMOSbkgd
        canvas.add(stars.canvas
        
    }
}

Lookin’ good:

However, there are still a couple of things we want to achieve:

  1. Have the constellations snap into place
  2. Have the lines animate in / out when snapped

Snap To It

Jake’s design considers the following behaviour:

When a sign is on screen and the user lets go or scrolling stops, the sign should snap to the center of the screen.

To build this functionality out we need to first answer two question:

  1. How do we know if the view should snap into place?
  2. How do we know when scrolling has stopped, or a user has let go of the screen?

The second requires the first, so I start with the problem of figuring out if the view needs to snap based on its position.

Start by creating a list of target points by adding the following property to the class:

1
var snapTargets : [CGFloat]!

Then, I create the following:

1
2
3
4
5
6
func createSnapTargets() {
    snapTargets = [CGFloat]()
    for i in 0...12 {
        snapTargets.append(gapBetweenSigns * CGFloat(i) * view.frame.width)
    }
}

This method appends center x position for each astrological sign.

This method needs to be called during setup, like so:

1
2
3
4
override func setup() {
   //bunch of other stuff...
   createSnapTargets()
} 

Next, create a method that takes an offset position as input and determines if the view needs to snap or not:

1
2
3
4
5
6
7
8
9
func snapIfNeeded(x: CGFloat, _ scrollView: UIScrollView) {
    for target in snapTargets {
        let dist = abs(CGFloat(target) - x)
        if dist <= CGFloat(canvas.width/2.0) {
            scrollView.setContentOffset(CGPointMake(target,0), animated: true)
            return
        }
    }
}

This iterates over all the targets in snapTargets. For each target it calculates the distance from the target to the current x position, and if the target is less than half the width of the screen away it should snap. If it should snap then it sets the current content offset of the scrollview to the target position.

The method also has a return statement. The purpose of this is that it breaks out of the loop at the proper moment so that the loop doesn’t continue (i.e. if the target is the first in the list it won’t execute the remaining 12).

Now, we have to hook up the snapIfNeeded method to the right moments, which means I now have to figure out when to execute the behaviour.

There are two basic conditions:

  1. The user is dragging quickly and lets go, the scrollview continues scrolling on its own and decelerates until it stops.
  2. The user is dragging slowly and raises their thumb off the screen, but the scrollview doesn’t need to decelerate.

The UIScrollview class has a few methods that can help us out. They are scrollViewDidEndDecelerating for the first case and scrollViewDidEndDragging for the second, both of which are delegate methods.

Add the first delegate method and change it so it looks as follows:

1
2
3
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    snapIfNeeded(scrollView.contentOffset.x, scrollView)
}

This method gets called implicitly when the scrollview stops moving on its own. When it gets called, the method grabs the current offset of the view and sends that to our snapIfNeeded method.

Also add the second delegate method and change it so it looks as follows:

1
2
3
4
5
func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if decelerate == false {
        snapIfNeeded(scrollView.contentOffset.x, scrollView)
    }
}

This one also gets called implicitly when the user stops dragging. If the user’s finger or thumb is moving slow enough then the method will have a decelerate parameter that’s set to false. In this case, we know we need to immediately check if the view should snap. If decelerate is true (when the user stops dragging after a swipe gesture) we know that the previous method will eventually be called, so we do nothing here.

To get these two methods to execute, we need to set the delegate of our top layer.

First, make the entire ParallaxBackground class a UIScrollViewDelegate by changing the class declaration to:

1
class ParallaxBackground : CanvasController, UIScrollViewDelegate { ... }

Second, set the delegate of the top layer in the createBigStars method right before the return statement, like so:

1
2
3
4
5
6
7
func createBigStars() {
     //…
   addDashesMarker(bigStars)
   addSignNames(bigStars)
   bigStars.delegate = self
   return bigStars 
}

Check it:

Animating the Lines

The final piece of polish is to animate a sign’s lines in only when that sign is centered on the screen. We’ve figured out the triggers (i.e. when to snap, etc.) now we just have to set the lines up so that we can animate them in and out.

There are 2 conditions:

  1. The lines should appear when the shape has stopped moving and is entered
  2. The lines should disappear when the user starts dragging

The first case is easy. In snapIfNeeded() add the following right before the return statement:

1
2
3
delay(0.25, closure: { () -> () in
    self.signLines.revealCurrentSignLines()
})

This waits a 1/4 second before animating the lines in.

Next, we’re going to have to create a method that triggers when the user starts dragging… This is also easy. Add the following method to your class:

1
2
3
func scrollViewWillBeginDragging(scrollView: UIScrollView) {
    self.signLines.hideCurrentSignLines()
}

The scrollViewWillBeginDragging is a delegate method that gets called automatically when the user starts dragging. So, all we have to do is trigger the lines to hide.

GoTo

This last step is something we’re going to do in anticipation of later hooking everything up. Add this method to your Stars class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func goto(selection: Int) {
    let target = canvas.width * Double(gapBetweenSigns) * Double(selection)

    let anim = ViewAnimation(duration: 3.0) { () -> Void in
        self.bigStars.contentOffset = CGPoint(x: CGFloat(target),y: 0)
    }
    anim.curve = .EaseOut
    anim.addCompletionObserver { () -> Void in
        self.signLines.revealCurrentSignLines()
    }
    anim.animate()
    
    signLines.currentIndex = selection
}

We’ll talk about it in a future chapter, but for now it’s a method that takes an index then animates the top layer to the proper position.

Fin.

The Stars background class is good to go.

Here’s a copy of Stars.swift.

Nächste Haltestelle.