Day 90 - 100 Days of Swift

10 minute read

Consolidation X (part 1)

Day 90 is the first of two consolidation days covering projects 25-27. You review the things you learned, including compiler directives and the old way of drawing with Core Graphics, and then he gives you a challenge to built a meme-maker app. The app should have the ability to let the user pick a photo, add a top line of text, add a bottom line of text and be able to export the finished product out via a UIActivityViewController.

First, I laid out the interface. I wanted it to be pretty simple, but also to work decently well on all phone sizes and iPads. So my thought was to have a stack view, which is centered on the screen, but limited to be equal to or less than both the height and the width of the view. That way it can fill up available space, but still hold everything on screen. I also wanted to constrain the image view to be a square, because that seemed like the most meme-y aspect ratio. So here is my layout method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// View properties
private let mainStackView: UIStackView = {
    let stackView = UIStackView()
    stackView.axis = .vertical
    stackView.spacing = 8

    return stackView
}()

private let imageView: UIImageView = {
    let imageView = UIImageView()
    imageView.contentMode = .scaleAspectFill
    imageView.clipsToBounds = true
    imageView.backgroundColor = .secondarySystemBackground

    return imageView
}()

let buttonStackView: UIStackView = {
    let stackView = UIStackView()
    stackView.axis = .horizontal
    stackView.spacing = 8
    stackView.distribution = .fillEqually

    return stackView
}()

private lazy var addImageButton: UIButton = {
    let button = UIButton(type: .system)
    button.setTitle("New Photo",
                    for: .normal)
    button.addTarget(self,
                     action: #selector(pickPhoto),
                     for: .touchUpInside)
    return button
}()

private lazy var addTopTextButton: UIButton = {
    let button = UIButton(type: .system)
    button.setTitle("Top Text",
                    for: .normal)
    button.addTarget(self,
                     action: #selector(setText),
                     for: .touchUpInside)
    return button
}()

private lazy var addBottomTextButton: UIButton = {
    let button = UIButton(type: .system)
    button.setTitle("Bottom Text",
                    for: .normal)
    button.addTarget(self,
                     action: #selector(setText),
                     for: .touchUpInside)
    return button
}()

// In layoutViews()
mainStackView.constrainToSuperView(view,
                                   safeArea: true,
                                   centerX: 0,
                                   centerY: 0)
mainStackView.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor,
                                     constant: -40).isActive = true
mainStackView.heightAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.heightAnchor,
                                      constant: -40).isActive = true

imageView.constrainSelf(aspectWidth: 1)
mainStackView.addArrangedSubview(imageView)
mainStackView.addArrangedSubview(buttonStackView)

buttonStackView.addArrangedSubview(addTopTextButton)
buttonStackView.addArrangedSubview(addImageButton)
buttonStackView.addArrangedSubview(addBottomTextButton)

All pretty straightforward stuff. It does make use of some constraint helper functions that I built though, which I turned into a Swift Package and made publicly available, so that I and anyone else who might want to use them can pull them in with the integrated Swift Package Manager in Xcode and make a lot of the programmatic constraints a little more concise and easier to read.

Next, I needed the ability to add a photo, so I started filling out the pickPhoto method. First, I made my view controller adopt the UIImagePickerControllerDelegate protocol and added it’s methods for handling image picking:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension ViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        dismiss(animated: true)
    }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        guard let image = info[.originalImage] as? UIImage else { return }

        imageView.image = image
        addTopTextButton.isEnabled = true
        addBottomTextButton.isEnabled = true
        dismiss(animated: true)
    }
}

Then I just presented an image picker in pickPhoto:

1
2
3
4
5
@objc func pickPhoto() {
    let imagePicker = UIImagePickerController()
    imagePicker.delegate = self
    present(imagePicker, animated: true)
}

Next, I needed to be able to collect the text that the user wants to put over the photo. I decided to do that with an alert, because it is simple and the keyboard is handled for you:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@objc func setText(_ sender: UIButton) {
    guard imageView.image != nil else { return }
    let title = "What text would you like to add?"
    let alertController = UIAlertController(title: title,
                                            message: nil,
                                            preferredStyle: .alert)
    var memeTextField: UITextField!
    alertController.addTextField { textfield in
        textfield.placeholder = "Your text here"
        textfield.autocapitalizationType = .words
        memeTextField = textfield
    }
    let location: TextLocation = sender == addTopTextButton ? .top : .bottom
    let add = UIAlertAction(title: "Add", style: .default) { _ in
        guard let text = memeTextField.text else { return }
        self.addText(text, for: location)
    }
    alertController.addAction(add)
    alertController.addAction(.cancel)

    present(alertController, animated: true)
}

enum TextLocation {
    case top, bottom
}

You can see that I call addText(_:for:) in the handler for the add action. Here’s what that looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private func addText(_ text: String, for location: TextLocation) {
        guard let image = imageView.image else { return }

        let size = imageView.frame.size * 2
        let renderer = UIGraphicsImageRenderer(size: size)

        let newImage = renderer.image { context in

            let rect = aspectFillRectFor(size: image.size, in: size)
            image.draw(in: rect)

            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = .center

            let shadow = NSShadow()
            shadow.shadowColor = UIColor(white: 0, alpha: 0.6)
            shadow.shadowBlurRadius = 4
            shadow.shadowOffset = CGSize(width: 2, height: 2)

            let attrs: [NSAttributedString.Key: Any] = [
                .font: UIFont.boldSystemFont(ofSize: size.width / 10),
                .shadow: shadow,
                .foregroundColor: UIColor.white,
                .paragraphStyle: paragraphStyle
            ]
            let attributedString = NSAttributedString(string: text,
                                                      attributes: attrs)
            let stringRect: CGRect
            switch location {
            case .top:
                stringRect = CGRect(x: 12,
                                    y: 0,
                                    width: size.width - 24,
                                    height: size.height / 2)
                attributedString.draw(with: stringRect,
                                      options: .usesLineFragmentOrigin,
                                      context: nil)
            case .bottom:
                stringRect = CGRect(x: 12,
                                    y: size.height - 18,
                                    width: size.width - 24,
                                    height: size.height / 2)
                attributedString.draw(with: stringRect,
                                      options: [.truncatesLastVisibleLine],
                                      context: nil)
            }

        }

        imageView.image = newImage
        switch location {
        case .top: addTopTextButton.isEnabled = false
        case .bottom: addBottomTextButton.isEnabled = false
        }
    }

There’s obviously a lot going on here, but the basic flow is pretty simple. Make sure there is an image and build a renderer based off of the imageView’s size. I did this to provide some consistency in the output of the meme’s that are generated, but also to have them render at a reasonable size for the device they are being built on. Use that renderer to draw the image at its own size. Set up the text attributes and draw the text based off of the location passed in. Render the image and set it as the image view’s image. Turn off the button for the location that was just rendered, so the user doesn’t create conflicting text.

This code does use a few helpers I wrote to try to clean things up a little bit. One is multiplying a size by 2. To do that I added this function and defined a custom operator:

1
2
3
4
5
6
7
8
extension CGSize {
    func multiply(by factor: CGFloat) -> CGSize {
        return CGSize(width: self.width * factor, height: self.height * factor)
    }
}

infix operator *: MultiplicationPrecedence
func * (left: CGSize, right: CGFloat) -> CGSize { return left.multiply(by: right) }

I don’t know why this isn’t a built in operator, but it isn’t. Fortunately you can add that stuff yourself in Swift.

The other helper is a function I wrote so that I could get the CGRect that I would need to draw the image in for it to fill a given size. I did this because I wanted the image to be drawn inside the image view with what is elsewhere called “scale aspect fill” but I couldn’t figure out a way to do that with the api available in the Core Graphics drawing space so I wrote my own. Basically you pass it the size of the image, and the size of the space you want it to fill (I’m assuming that space is square, and didn’t test the logic in any other case) and it will give you back the CGRect that retains the original aspect ratio and is centered on the size:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private func aspectFillRectFor(size: CGSize, in square: CGSize) -> CGRect {
    if size.width < size.height {
        // calculate for tall image
        let width = square.width
        let height = square.width * size.height / size.width
        let x: CGFloat = 0
        let y = -(height - square.width) / 2
        return CGRect(x: x, y: y, width: width, height: height)
    } else {
        // calculate for wide image
        let height = square.height
        let width = square.height * size.width / size.height
        let x = -(width - square.height) / 2
        let y: CGFloat = 0
        return CGRect(x: x, y: y, width: width, height: height)
    }
}

With that, the user can add a photo and add text to the top and bottom of it. All that is left is to let them share it. To do that, I first embedded the view controller into a navigation controller and added a title and a bar button in layoutViews()

1
2
3
4
5
6
title = "Meme Maker"
navigationController?.navigationBar.prefersLargeTitles = true

navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action,
                                                    target: self,
                                                    action: #selector(sharePhoto))

And added the sharePhoto method:

1
2
3
4
5
6
7
8
@objc func sharePhoto() {
    guard let image = imageView.image,
        let imageData = image.pngData() else { return }
    let activityViewController =
        UIActivityViewController(activityItems: [imageData],
                                 applicationActivities: nil)
    present(activityViewController, animated: true)
}

And with that, the app does everything it is supposed to. It looks like this:

Screenshots of working app in light mode.

Bonus, it also works pretty well in dark mode:

Screenshot of working app in dark mode.

You can find my version of this project at the end of day 90 on Github here.