Haiku App Part 2: Custom Navigation

Become a Subscriber

Animated preview of Haiku App at the end of this tutorial

In Part 1 of this tutorial series, we setup a pretty vanilla project, complete with CocoaPods, a couple view controllers, and a custom UINavigation Controller. In this second part, we will populate our navigation with a logo and two links which will be responsible for showing the various view controllers.

Why not just use Storyboards and Interface Builder?

As you develop more iOS applications, a common issue crops up over and over: Interface Builder gets in the way when view hierarchy changes. Storyboards are no doubt the fastest ways to build out views the first time, and if you are confident that you will make a fairly static application that won’t eventually have more views or redesigns, then by all means, feel free to use Interface Builder. Just remember that shortcuts do not always pay off in the long run. By creating abstracted and programmatic interfaces, you have more control and are essentially future-proofing your application.

Working with Constants and Global Variables in Swift

One of the first things we will want to do is create a Constants.swift file under our Config folder. In here we are going to define constants that can be used and reused in multiple areas of our application. This not only helps us avoid naming collisions or human error, but it also allows us to update views everywhere. Let’s start by setting a default padding that we will use throughout the application to space controls away from the edge of the screen and a fixed height for our navigation.

// Config/Constants.swift
import UIKit

struct Constants
{
  struct Layout
  {
    static let padding: CGFloat = 20
    static let navigationHeight: CGFloat = 55
  }
}

Here we use structs because they have a lower memory footprint, pass by value, and make for a nice namespacing of sorts. For example we can now call our padding constant anywhere in our app: Constants.Layout.padding.

Defining the views for our custom UINavigationController

Now that we have a couple layout constants, we are ready to create the subviews that will live in our NavController. Let’s define the following views:

  • navigation - the main square that we will color and add subviews to.
  • navigationBorder - a UIView that will server as our bottom border
  • logo - the application logo which will show information about the author on touch
  • writeNavItem - the UIButton which will show the WriteController
  • entriesNavItem - the UIButton which will show the EntriesController

We can instantiate the views now and set them as let constants, and then override the default size, color and layout properties later on in the application lifecycle. We will also create the setup() method and call it in our viewDidLoad() and we will decorate our view within that function later.

// Controllers/NavController.swift
import UIKit

class NavController: UINavigationController
{
  let navigation = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: Constants.Layout.navigationHeight))
  let navigationBorder = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 1))
  let logo = UIButton(frame: CGRect(x: Constants.Layout.padding, y: 0, width: 44, height: 17))
  let writeNavItem = UIButton()
  let entriesNavItem = UIButton()

  override func viewDidLoad()
  {
    super.viewDidLoad()

    setup()
  }

  func setup() {}
}

Decorating our Navigation Controller

Let’s do some initial decoration in our setup function. We will style the background of the navigation view, add a drop shadow, create a border, and finally style the navigationBar (the bar which shows the time, battery, etc). Notice in the code below that we position and size elements both with fixed sizes and positions that are calculated from the window frame. This is similar to how one might style a responsive web application and will preserve roughly the same layout and spacing on all device sizes. The downside is, if our button text is too big for the frame, we may lose some characters. Thus, we make them a little larger than necessary and set their alignment to avoid throwing off the visual spacing.

// setup function in Controllers/NavController.swift
func setup()
{
  // Set our navigation view to the full screen width and set a drop shadow
  // Notice that shadows are added on the layer attribute of the UIView
  // This has to do with how the views are drawn and most custom decorative styles
  // have to be applied to this layer or within the draw() method of the UIView itself
  navigation.backgroundColor = .lightGray
  navigation.frame.size.width = view.frame.width
  navigation.layer.shadowColor = UIColor.black.cgColor
  navigation.layer.shadowOffset = CGSize(width: 0, height: 2)
  navigation.layer.shadowOpacity = 0.20
  navigation.layer.shadowRadius = 2

  // Create the Gradient and add it to the first (0) index of the navigation view.
  // As mentioned above, most aspects of decorating are done via CALayers, QuartzCore and 
  // appended to the UIView
  let gradient = CAGradientLayer()
  gradient.frame = navigation.frame
  gradient.startPoint = CGPoint(x: 0, y: 0)
  gradient.endPoint = CGPoint(x: 0, y: 1)
  gradient.colors = [UIColor.lightGray.cgColor, UIColor.darkGray.cgColor]
  navigation.layer.insertSublayer(gradient, at: 0)

  /* 
   Create and style the logo button

   Most of the stying and positioning should make sense since it is simple math.
   We defined the logo as a fixed width and positioned from the left by our constant padding definition
   This could be potentially be problematic, but as long as you test thoroughly, it's an okay practice
  */
  logo.frame = CGRect(
    x: Constants.Layout.padding,
    y: (navigation.frame.height - 18) / 2,
    width: 50,
    height: 18
  )
  logo.contentHorizontalAlignment = .left
  logo.setTitle("LOGO", for: .normal)
  logo.setTitleColor(.white, for: .normal)
  logo.addTarget(self, action: #selector(aboutHandler), for: .touchUpInside)

  /*
   Create "WRITE" navigation button

   We calculate the position of this as dynamically as possible.
   Since both WRITE and ENTRIES buttons are 90 points wide,
   we calculate x starting from the right side of the screen 
   subtracting our padding and the width of both components (180) 
   and an additional 10 points of spacing between them
  */
  writeNavItem.frame = CGRect(

    x: view.frame.width - Constants.Layout.padding - 190,
    y: (navigation.frame.height - 18) / 2,
    width: 90,
    height: 18
  )
  // should default to true, because our WriteController is the one that is initially loaded
  writeNavItem.isSelected = true
  writeNavItem.setTitle("WRITE", for: .normal)
  writeNavItem.contentHorizontalAlignment = .right
  // We use the UIControlState to set white or yellow for "normal" and "selected"
  // this can be abbreviated to just the .normal and .selected because the method knows it's
  // an enumerated type of UIControlState, long hand would read: UIControlState.normal
  writeNavItem.setTitleColor(.white, for: .normal)
  writeNavItem.setTitleColor(.yellow, for: .selected)
  // We give it a tag for quick lookup later
  writeNavItem.tag = 0
  // Finally we add the navHandler() function as a target and pass it the button itself as a sender
  writeNavItem.addTarget(self, action: #selector(navHandler(sender:)), for: .touchUpInside)

  /*
   Create "ENTRIES" navigation button

   this is almost exactly the same as our write navigation, except it's not selected by default
   the tag is set to 1, and the x positioning is 100 points more to the right.
   Notice too that both the navigation buttons are centered vertically by calculating
   the parent `navigation` view height minus the button height (18) and divided by two
  */
  entriesNavItem.frame = CGRect(
    x: view.frame.width - Constants.Layout.padding - 90,
    y: (navigation.frame.height - 18) / 2,
    width: 90,
    height: 18
  )
  entriesNavItem.setTitle("ENTRIES", for: .normal)
  entriesNavItem.contentHorizontalAlignment = .right
  entriesNavItem.setTitleColor(.white, for: .normal)
  entriesNavItem.setTitleColor(.yellow, for: .selected)
  entriesNavItem.tag = 1
  entriesNavItem.addTarget(self, action: #selector(navHandler(sender:)), for: .touchUpInside)

  // Create a 1px border and position it at the bottom of the navigation view
  navigationBorder.frame.size.width = navigation.frame.width
  navigationBorder.frame.origin.y = navigation.frame.height - 1
  navigationBorder.backgroundColor = .white

  // Add subviews to the `navigation` view
  navigation.addSubview(logo)
  navigation.addSubview(writeNavItem)
  navigation.addSubview(entriesNavItem)
  navigation.addSubview(navigationBorder)

  // Set our navigationBar to be dark and translucent (the bar with date/time/battery)
  navigationBar.isTranslucent = true
  navigationBar.barStyle = .blackTranslucent
  navigationBar.addSubview(navigation)
}

Setting up our navigation item handlers

We have added the logo, write and entries buttons to our navigation, but the actions they call are currently undefined. The “logo” button should display a simple modal window, which we will call from our FlourishUI CocoaPod. First we create the handler function, you can add this to the bottom of NavController.swift

// Controllers/NavController.swift
func aboutHandler()
{
  Modal(
    title: "About the Creators",
    body: "Clay and Oscar are Unicorns in name and deed. Yes, single horned steeds",
    status: .info
  ).show()
}

Reminder, we already have this code from above, but just as an explanation:

We need to call this function as an action on the logo UIButton. We call whatever function in whatever class we wish, but since we are calling a function within the same NavController class, we can simply pass it the aboutHandler method name as a selector:

logo.addTarget(self, action: #selector(aboutHandler), for: .touchUpInside)

Same goes for our navHandler() as we did with the logo button, we will simply give it a selector to a local method in our class for it’s touchUpInside action:

func navHandler(sender: Any? = nil)
{
  if let button = sender as? UIButton
  {
    // Return if the view is already presented
    if (writeNavItem.isSelected && button.tag == 0) || (entriesNavItem.isSelected && button.tag == 1)
    {
      return
    }
  }

  // Since we only have two, we can just inverse selected state values
  entriesNavItem.isSelected = !entriesNavItem.isSelected
  writeNavItem.isSelected = !writeNavItem.isSelected

  if writeNavItem.isSelected
  {
    // Popping the view controller removes the top most view off of the navigation stack
    // in this case, that will always be the EntriesController because our default VC
    // is the WriteController and EntriesController is always "pushed" or added on top
    popViewController(animated: true)
  }
  else
  {
    // Since our WriteController essentially is always there, we basically just
    // instantiate EntriesController and add it to the top of this NavController
    pushViewController(EntriesController(), animated: true)
  }
}

Hiding the Back Button

You probably noticed that the back button appears when you click on the “ENTRIES” button. We can fix this by simply hiding it for this application:

// Controllers/EntriesController
import UIKit

class EntriesController: UIViewController
{

  override func viewDidLoad()
  {
    super.viewDidLoad()

    navigationItem.hidesBackButton = true
  }

}

Wrapping Up

Your app should and behave like the preview above. What you have learned in this lesson will give you great control over making your own, unique navigation interfaces and styling them however you please. Using tab controllers and such are awesome too, but they can really box you in and dampen your creativity. This tactic to building navigation gives you the best of both worlds: lifecycle and memory management by using a UINavigationController while having creative freedom to style and layout as you see fit.