DEV Community

Cover image for A Step Beyond Hello World: Building a Calculator App with ViewCode
Gustavo Guedes
Gustavo Guedes

Posted on

A Step Beyond Hello World: Building a Calculator App with ViewCode

Whenever I'm looking for content on how to build something, I often stumble upon videos and articles that stop at the classic "Hello World." Today, we’ll go a bit further.

As the title suggests, the goal is to add a bit more complexity when working with ViewCode.

In this article, I won’t cover the initial setup, as I’ve already discussed it in two previous articles: Getting Started with ViewCode in iOS: A Basic Guide and Setting Up ViewCode Projects for Versions Below iOS 13.

Let’s Get to Work

Creating the Main View

The idea is to replicate as much as possible from the native calculator app, especially regarding its functionalities.

gif-usando-a-calculadora

To start, let's create a new file that will be responsible for managing the views of our application. Let’s name it CalculatorView.swift.

import UIKit

class CalculatorView: UIView {}
Enter fullscreen mode Exit fullscreen mode

Now, in our controller, we’ll set our newly created UIView as the main view of our UIViewController.

private var calculatorView: CalculatorView? = nil

override func loadView() {
    view = CalculatorView()
    calculatorView = view as? CalculatorView
}
Enter fullscreen mode Exit fullscreen mode

With this setup, we ensure that the views are handled within the view file, and the controller will only interact with them when necessary.

Creating the First Elements

Looking at the native calculator UI, we notice that the buttons are quite similar, differing only in some characteristics. Before thinking about how to customize them, let’s add the first button to the screen.

In our CalculatorView, we’ll add the following elements:

private lazy var buttonContainer: UIView = {
    let view = UIView()

    view.translatesAutoresizingMaskIntoConstraints = false

    return view
}()

private lazy var button: UIButton = {
    let button = UIButton()

    button.titleLabel?.adjustsFontSizeToFitWidth = true
    button.titleLabel?.font = UIFont.systemFont(ofSize: 40, weight: .medium)
    button.translatesAutoresizingMaskIntoConstraints = false

    return button
}()

init() {
    super.init(frame: .zero)

    backgroundColor = .black

    buttonContainer.backgroundColor = .darkGray
    button.setTitle("1", for: .normal)
    button.setTitleColor(.white, for: .normal)

    addSubview(buttonContainer)
    buttonContainer.addSubview(button)

    NSLayoutConstraint.activate([
        buttonContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
        buttonContainer.centerXAnchor.constraint(equalTo: centerXAnchor),
        buttonContainer.heightAnchor.constraint(equalToConstant: 60),
        buttonContainer.widthAnchor.constraint(equalToConstant: 60),
        button.centerYAnchor.constraint(equalTo: buttonContainer.centerYAnchor),
        button.centerXAnchor.constraint(equalTo: buttonContainer.centerXAnchor),
    ])

    buttonContainer.layer.cornerRadius = 60 / 2
}

required init?(coder: NSCoder) {
    fatalError()
}
Enter fullscreen mode Exit fullscreen mode

Here, we’re doing the following:

  1. Creating a container for the button;
  2. Creating the button itself;
  3. In the view’s init, we apply the initial stylings, add the elements to the screen, and set up their constraints.

The result should look something like this:

first-button

Componentizing the Button

Now that we've taken the first step in building our components, let's create a separate button component, along with a small contract for our programmatic components.

The first step is creating a protocol to be followed by all our components. We can name it ViewCode.swift.

protocol ViewCode {
    func addSubviews()
    func setupConstrainsts()
    func setupStyles()
}

extension ViewCode {
    func setup() {
        addSubviews()
        setupConstrainsts()
        setupStyles()
    }
}
Enter fullscreen mode Exit fullscreen mode

Each component will implement three methods:

  1. addSubviews to add elements;
  2. setupConstraints to configure the constraints;
  3. setupStyles to style the elements.

With the setup method, we create a unified way to call this set of methods.

Now, we can create the button component in CalculatorButtonView.swift.

class CalculatorButtonView: UIView {
    private lazy var button: UIButton = {
        let button = UIButton()

        button.titleLabel?.adjustsFontSizeToFitWidth = true
        button.titleLabel?.font = UIFont.systemFont(ofSize: 40, weight: .medium)
        button.translatesAutoresizingMaskIntoConstraints = false

        return button
    }()

    init() {
        super.init(frame: .zero)

        setup()
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    func config(
        text: String,
        textColor: UIColor? = .white,
        buttonColor: UIColor? = .darkGray
    ) {
        backgroundColor = buttonColor
        button.setTitle(text, for: .normal)
        button.setTitleColor(textColor, for: .normal)
    }
}

//MARK: - ViewCode

extension CalculatorButtonView: ViewCode {
    func addSubviews() {
        addSubview(button)
    }

    func setupConstrainsts() {
        NSLayoutConstraint.activate([
            heightAnchor.constraint(equalToConstant: 60),
            widthAnchor.constraint(equalToConstant: 60),
            button.centerYAnchor.constraint(equalTo: centerYAnchor),
            button.widthAnchor.constraint(equalToConstant: 60),
            button.heightAnchor.constraint(equalToConstant: 60),
        ])
    }

    func setupStyles() {
        layer.cornerRadius = 60 / 2
        translatesAutoresizingMaskIntoConstraints = false
    }
}
Enter fullscreen mode Exit fullscreen mode

The config method will allow future customizations of the buttons as needed.

In CalculatorView, we adjust the button positioning to ensure they are closer to their correct locations on the interface.

class CalculatorView: UIView {
    private lazy var one: CalculatorButtonView = {
        let one = CalculatorButtonView()

        one.config(text: "1")

        return one
    }()

    init() {
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    private func oneConstraints() {
        NSLayoutConstraint.activate([
            one.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            one.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
        ])
    }

}

extension CalculatorView: ViewCode {
    func addSubviews() {
        addSubview(one)
    }

    func setupConstrainsts() {
        oneConstraints()
    }

    func setupStyles() {
        backgroundColor = .black
    }
}
Enter fullscreen mode Exit fullscreen mode

In the controller, we call the setup method from CalculatorView, as it now follows the established project pattern.

override func loadView() {
        // ...
    calculatorView?.setup()
}
Enter fullscreen mode Exit fullscreen mode

With these changes we should have the following result.

first-button-in-right-place

Creating the Other Elements

Adding more simple elements to the screen, we might end up with something like this:

fixed-buttons

The button positioning followed this logic:

  1. Button "one" is anchored to the bottom of the screen and 16px from the leadingAnchor;
  2. Button "two" is 16px from the trailingAnchor of button "one" and centerYAnchor equal to one.centerYAnchor;
  3. Button "four" is 16px from the leadingAnchor and the bottomAnchor 16px above one.topAnchor.

I chose not to use UIStackView in this context.

You may have noticed that the buttons still don’t resemble the native app. That’s because the fixed 60px size makes them look rigid and disproportionate. To fix this, we need to calculate the button sizes based on the screen width.

The logic applied in CalculatorButtonView is as follows: each element’s size will be the screen width minus the spacing, divided by 4. There are other approaches, but this will work for now.

static var elementWidth = (UIScreen.main.bounds.width - 5 * 16) / 4
Enter fullscreen mode Exit fullscreen mode

I added this static variable to make it easier to access this information even outside the context of the element.

And if we replace the 60px we used in the file with CalculatorButtonView.elementWidth. We will have the following result:

resized-buttons

Much better, right?

Final Buttons and Result Label

To complete the UI, we need to add a few more buttons and the field where the calculation result will be displayed.

In the CalculatorView file we create a new element:

private lazy var result: UILabel = {
    let label = UILabel()

    label.text = "0"
    label.font = UIFont.systemFont(ofSize: 80)
    label.textColor = .white
    label.translatesAutoresizingMaskIntoConstraints = false
    label.textAlignment = .right

    return label
}()

// ...

private func resultConstraints() {
    NSLayoutConstraint.activate([
        result.bottomAnchor.constraint(equalTo: divide.topAnchor, constant: -24),
        result.trailingAnchor.constraint(equalTo: divide.trailingAnchor, constant: -16),
        result.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16)
    ])
}

// ...

func addSubviews() {
    // ...
    addSubview(result)
}

func setupConstrainsts() {
    // ...
    resultConstraints(result)
}
Enter fullscreen mode Exit fullscreen mode

If we run the application again, we will have something like this:

result-added

Lastly, let's move on to creating the final buttons. To achieve this, we’ll need to make some adjustments to our CalculatorButtonView so that the elements don’t have fixed sizes.

We start with our config method, allowing us to change the element's alignment and size dynamically.

func config(
    text: String,
    textColor: UIColor? = .white,
    buttonColor: UIColor? = .darkGray,
    alignLeft: Bool = false,
    width: CGFloat? = nil,
    height: CGFloat? = nil
) {
    backgroundColor = buttonColor
    button.setTitle(text, for: .normal)
    button.setTitleColor(textColor, for: .normal)

    if (!alignLeft) {
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: centerXAnchor),
        ])
    } else {
        NSLayoutConstraint.activate([
            button.titleLabel!.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 24),
        ])
    }

    NSLayoutConstraint.activate([
        widthAnchor.constraint(equalToConstant: width ?? CalculatorButtonView.elementWidth),
        button.widthAnchor.constraint(equalToConstant: width ?? CalculatorButtonView.elementWidth),
        button.heightAnchor.constraint(equalToConstant: height ?? CalculatorButtonView.elementWidth),
    ])
}
Enter fullscreen mode Exit fullscreen mode

With this modification we created the possibility of changing the element's alignment and size via the config method.

Important! We need to remove the constraints previously set in the setupConstraints method. If this isn't done, there will be conflicts due to multiple constraints being applied to the same element.

It will look like this:

func setupConstrainsts() {
    NSLayoutConstraint.activate([
        heightAnchor.constraint(equalToConstant: CalculatorButtonView.elementWidth),
        button.centerYAnchor.constraint(equalTo: centerYAnchor),
    ])
}
Enter fullscreen mode Exit fullscreen mode

Now, we can create our final elements in the CalculatorView file.

private lazy var zero: CalculatorButtonView = {
    let zero = CalculatorButtonView()
    zero.config(text: "0", alignLeft: true, width: CalculatorButtonView.elementWidth * 2 + 16)
    return zero
}()

private lazy var comma: CalculatorButtonView = {
    let comma = CalculatorButtonView()
    comma.config(text: ",")
    return comma
}()

private lazy var equal: CalculatorButtonView = {
    let equal = CalculatorButtonView()
    equal.config(text: "=", buttonColor: .orange)
    return equal
}()
Enter fullscreen mode Exit fullscreen mode

As you can see, the "zero" button will be the size of two buttons plus the 16px spacing between elements.

Next, we can configure the constraints for these elements.

private func zeroConstraints() {
    NSLayoutConstraint.activate([
        zero.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
        zero.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
    ])
}

private func commaConstraints() {
    NSLayoutConstraint.activate([
        comma.leadingAnchor.constraint(equalTo: zero.trailingAnchor, constant: 16),
        comma.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
    ])
}

private func equalConstraints() {
    NSLayoutConstraint.activate([
        equal.leadingAnchor.constraint(equalTo: comma.trailingAnchor, constant: 16),
        equal.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
    ])
}
Enter fullscreen mode Exit fullscreen mode

Additionally, the constraint for the "one" button will need to change since it should be linked to the "zero" button rather than the screen itself.

private func oneConstraints() {
    NSLayoutConstraint.activate([
        one.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
        one.bottomAnchor.constraint(equalTo: zero.topAnchor, constant: -16),
    ])
}
Enter fullscreen mode Exit fullscreen mode

Por ultimo colocamos os elementos na tela e disparamos as configs que fizemos:

addSubview(zero)
addSubview(comma)
addSubview(equal)

//...

zeroConstraints()
commaConstraints()
equalConstraints()
Enter fullscreen mode Exit fullscreen mode

Finally, we place the elements on the screen and apply the configurations we made.

With this last modification, when we run the app, the result should look something like this:

finished-ui

Interactivity

Now that our UI is ready, we need to add some interaction between the buttons and the result field at the top. We can follow these steps:

In the CalculatorButtonView file, we create a delegate to capture which button was pressed. We also configure the UIButton to trigger this callback.

protocol CalculatorButtonViewDelegate: AnyObject {
    func didTapButton(_ sender: UIButton)
}
Enter fullscreen mode Exit fullscreen mode

This allows us to capture which button was pressed. Still within this file we need to configure our UIButton with this callback:

private lazy var button: UIButton = {
        // ...
    button.addTarget(self, action: #selector(onTap), for: .touchUpInside)

    return button
}()

weak var delegate: CalculatorButtonViewDelegate?

@objc
private func onTap(sender: UIButton) {
    delegate?.didTapButton(sender)
}
Enter fullscreen mode Exit fullscreen mode

These small changes make the component "clickable."

Next, in CalculatorView, we set up the delegate for the buttons. Here, I opted for the willSet approach to configure all buttons at once.

weak var buttonsDelegate: CalculatorButtonViewDelegate? {
    willSet {
        one.delegate = newValue
        two.delegate = newValue
        three.delegate = newValue
        plus.delegate = newValue
        four.delegate = newValue
        five.delegate = newValue
        six.delegate = newValue
        minus.delegate = newValue
        seven.delegate = newValue
        eight.delegate = newValue
        nine.delegate = newValue
        multiply.delegate = newValue
        clear.delegate = newValue
        positiveNegative.delegate = newValue
        percent.delegate = newValue
        divide.delegate = newValue
        zero.delegate = newValue
        comma.delegate = newValue
        equal.delegate = newValue
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, in our controller, we implement the necessary logic to handle the interactions.

override func viewDidLoad() {
    super.viewDidLoad()
    // ...
    calculatorView?.buttonsDelegate = self
}

extension ViewController: CalculatorButtonViewDelegate {
    func didTapButton(_ sender: UIButton) {
        if let buttonValue = sender.currentTitle {
            print(buttonValue)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The result is:

pressed-buttons

Conclusion

With everything we've done so far, you now have the foundation to build the rest of the interactivity and logic for when each button is pressed. If you want to see the full project, check out the repository, where I’ve added some additional details not covered here, along with unit tests for the button logic.

Here’s how it turned out:

full-project

That’s it for today, folks. Thanks, and see you next time!

Let me know if you need any further adjustments!

Top comments (0)