Making an App – Part 2

From last time, we have a set of map tiles and full map images, so let's see if we can get an app up and running showing a map.

Creating the App

With Xcode, let's create a single view, universal iOS app, call it BotW, using the Swift language, and save it in the botw folder we created last time.

In the main storyboard, add a UIScrollView as a child of the root view and add constraints for top, bottom, leading, and trailing to the superview. If it's indented on the left and right, make sure "Relative to Margin" isn't selected.

Add a UIImageView to the scroll view and constrain it the same way.

To keep things easy, let's use one of the assembled maps, not tiles; level 4 is 3072x2560 and 7.7Mb which is a good size to start with and enough detail to be useful. Drop 4.png into the asset catalog and set the image property of the image view to "4".

That should actually be enough to get a minimally working app. Let's set the simulated device to an iPad Air and run it.

Getting Zooming Working

You can scroll around, but not zoom. That's because the scrollview needs a class that is a delegate that implements UIScrollViewDelegate, specifically the viewForZooming(in:) method which returns which view should be zoomed. That, of course, is the image view, so let's:

  1. Add an outlet for the image view to the view controller.
  2. Wire up the image view to the outlet on the view controller in the storyboard (admission – I always forget to do this at least once in every project!).
  3. Change the minimum and maximum zoom of the scroll view to 0.25 for minimum and 4 for maximum.
  4. Implement the delegate protocol in an extension of the view controller.
  5. Implement the zoom method to return the image view.
  6. Wire up the delegate of the scroll view to the view controller. If your scrollview still won't zoom when you run the app, it's probably because you forgot to do this.

Here's what the view controller looks like:

import UIKit

class ViewController: UIViewController {

    @IBOutlet var imageView: UIImageView?

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }
}

extension ViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
}

Build & run … looks good. Let's commit the changes.

Adding State Restoration

This is a minor thing, but it's easy and makes the app better – let's have the app remember the map configuration between launches. To do this, we add a storyboard ID to the view controller and check "Use Storyboard ID" for the restoration ID. Then we add restoration ID's for the root view, scroll view, and image view. Lastly, we add the following callbacks to the app delegate:

func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
    return true;
}

func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
    return true;
}

To test it, build & run, zoom & scroll to some point, hit the "home button" on the simulator with ⇧⌘H and then stop the app in Xcode. Now, the next time you launch the app the map should return to that same zoom & scroll point.

Commit changes.

Centering the Zoomed Out Image

Two minor annoyances. First, when you zoom out the image anchors to the top left. It would be nicer if it was centered.

While iOS 11 and Xcode 9 have an easy fix for this, the current version needs some more help. Basically, what we want to do is adjust the constraints on the image view to inset enough to center the image when either the height or width is less than the scroll view size. So, compute the difference, divide by 2, and add as constants to the constraints. This means we need references to the constraints, so first let's add outlets and wire them up.

Now, let's adjust the constants when the scroll view zoom changes in the delegate protocol extension.

func scrollViewDidZoom(_ scrollView: UIScrollView) {
    if let view = self.imageView {
        let widthOffset = max(0, (scrollView.frame.width - view.frame.width) / 2)
        let heightOffset = max(0, (scrollView.frame.height - view.frame.height) / 2)

        imageViewTopConstraint?.constant = heightOffset
        imageViewBottomConstraint?.constant = heightOffset

        imageViewLeadingConstraint?.constant = widthOffset
        imageViewTrailingConstraint?.constant = widthOffset
    }
}

That works … until you rotate the device. The constraints need to update on device rotation, too. The view controller will get a callback to viewWillTransition(to:with:) and will get a CGSize which is all we really need anyway.

First, commit changes.

Now, let's pull this code out into a function on the controller:

func adjustContraints(for size: CGSize) {
    if let view = self.imageView {
        let widthOffset = max(0, (size.width - view.frame.width) / 2)
        let heightOffset = max(0, (size.height - view.frame.height) / 2)

        imageViewTopConstraint?.constant = heightOffset
        imageViewBottomConstraint?.constant = heightOffset

        imageViewLeadingConstraint?.constant = widthOffset
        imageViewTrailingConstraint?.constant = widthOffset
    }
}

Now we can update the delegate protocol implementation to call it:

func scrollViewDidZoom(_ scrollView: UIScrollView) {
    adjustContraints(for: scrollView.frame.size)
}

And do the same on the size transition callback in the view controller:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        adjustContraints(for: size)
}

Build & run … working well. Commit changes.

Commit & Take a Break

Good stopping point for now and a good point to commit the code if you haven't been all along. A habit I try to follow is to make one significant change at a time and commit the code as soon as it works. This gives me reliable "rollback" points if a refactor goes bad, or a new feature implementation goes bad. Avoid the temptation to change lots of things at once. Even large changes that touch lots of classes in a big project are good to break up into several small changes; just make sure the app builds & runs (& tests pass if you have them) before each commit.

Why So … Plain?

Some might be wondering why not just use some Cocoapod or Carthage package for tiled, scrolled images, like ARTiledImageView or why we don't do some new functional reactive approach with something like ReactNative. Why are we using storyboards (aren't those supposed be bad)?

First, don't fight the framework. I've had to learn this lesson the hard way so many times, it's drilled into me now. Whenever you choose to go in a different direction from where the framework you are working in is going, you're signing up for extra work now and especially in the future. ReactNative or RxSwift or combinator parsers may get lots of blog posts and seem hopelessly cool, but understand that they are based on opinions that are orthogonal to UIKit or AppKit.

That doesn't make them bad, but it does make them more work. When something doesn't work, is it your bug, are you using the new framework wrong, does it have a bug, or are you not understanding some underlying UIKit issue that's being obscured by the new framework? Long and short, don't sign up to make things harder just for the fun of it. If you are intrigued with one of those new things, by all means learn them. But, and I've seen this mistake so, so many times (and even made it myself), don't use a new project to learn a new technology or approach. Don't try a new cake recipe for the first time for your daughters next birthday party.

Second, don't assume your project is special until it is. Lots of people will tell you how terrible storyboards and interface builder are or how crappy Xcode is or how slow or inelegant or whatever some part of UIKit or Foundation is. And maybe for them and their special snowflake project, that's all true. However, don't volunteer to roll your own thing or avoid features of the platform that clearly have strategic importance to the platform vendor. If Apple is supporting storyboards and making strategic investments in it, there's a reason. They don't get everything right and there will be bugs, but deciding you're too cool for things like storyboards before you actually are is just masochism. Don't decide to raise your own chickens because the eggs from the store aren't fresh enough to make your cake until, you know, they actually aren't fresh enough for you. And stop listening to the prophets of negativity on the Internet; they aren't your friends.

Lastly, be wary of "accidental complexity". For any engineering project, there is something called essential complexity which is the fundamental nature of the problem itself, the actual thing you need to solve. There will be some additional complexity that gets added to the effort that has nothing to do with the actual problem, but comes along because of the approach, the tools, or the platform (e.g. like code signing). This is called accidental complexity. Every extra thing you add to the project increases accidental complexity and while some of it is unavoidable, you should try to keep it to a bare minimum.

So, since this project is all about getting a fast, working map with thousands of markers on my daughter's iPad, I'm not playing around with experimental non-Apple frameworks, rolling my own UI frameworks or layout techniques, or trying out other people's solutions to similar problems. Until I need to.

Home