Introduction to Programmatic UIKit
Table of Contents
Hello world, I’m back with a new entry on UIKit. In iOS 17 Apple added the ability to preview our views and ViewControllers using the SwiftUI canvas. This brings the possibility of faster programmatic UIKir programming. Let’s make a small project that illustrates how to leverage this new functionality and revising how we can create an app in UIKit 100% programmatically. 🚀
The app#
We will be coding an app that will ask for user information and then send it back to the main ViewController, this will showing us a simple way to pass information between views, a small introduction intro programmatic navigation and view reusability.
Configuring the project to work without Storyboard#
Deleting storyboard files and references#
UIKit only works on iOS and iPadOS, so you’ll need to create an iOS app in Xcode and select Storyboard, but don’t worry we’re going to delete all the stuff related to it. We’re going full code this time.
Select iOS App and Storyboard for the interface, remember Storyboard means UIKit under the hood.
Once Xcode is open with our project we need to delete all the stuff related to storyboard configuration.
First, let’s delete the Storyboard by moving it to the trash (you can leave the LaunchScreen).
Now let’s delete the references for the deleted Storyboard, follow the images and delete the selected sections.
Congratulations, you have deleted storyboards from your UIKit project. Now run the app and see a cool black screen.
Instantiate our navigation and set the root view controller#
Now it’s time to instantiate and show our first screen!
Open SceneDelegate.swift
and locate the function func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
, next replace the code inside the function with this.
guard let windowScene = (scene as? UIWindowScene) else { return }
let viewController = ViewController()
let navigationController = UINavigationController(rootViewController: viewController)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
Here is an explanation of the code above:
-
Scene Detection and Guard Clause:
guard let windowScene = (scene as? UIWindowScene) else { return }
This line ensures thescene
parameter is of typeUIWindowScene
. If not, it exits the block of code to prevent errors. This is essential for apps supporting multiple scenes in iOS 13 and later. -
View Controller Initialization:
let viewController = ViewController()
Creates a new instance ofViewController
, which should be a subclass ofUIViewController
. This controller will manage the app’s initial view. -
Navigation Controller Setup:
let navigationController = UINavigationController(rootViewController: viewController)
Initializes aUINavigationController
with theviewController
as its root. This setup provides a navigation bar and a stack-based navigation mechanism. -
Window Initialization:
window = UIWindow(windowScene: windowScene)
Creates a newUIWindow
using thewindowScene
from the first line, setting up the environment where the app’s content will be displayed. -
Setting the Root View Controller:
window?.rootViewController = navigationController
Assigns thenavigationController
as the window’s root view controller, makingviewController
the first visible view. -
Making the Window Key and Visible:
window?.makeKeyAndVisible()
This method sets the window as the key window and ensures it is visible, making it the main focus for user interactions.
Each
ViewController
has an associated optionalNavigationController
, this object is used to manage the push and pop of different ViewControllers, some architectures like to manage this navigation using a coordinator.
Congratulations on having the basic components to navigate your app and show your first screen.
Creating the Model#
The app is quite simple, the model just consist of two Strings that will be change according to what the user inputs in the form.
To create a Model we just need to create a struct with the fields we need. Create a new file called TrainerInfo.swift
and add the following code.
import Foundation
struct TrainerInfo: Identifiable {
let id: UUID
var name: String
var favoritePokemon: String
}
extension TrainerInfo: CustomStringConvertible {
var description: String {
"Trainer \(name) likes \(favoritePokemon)"
}
}
This model will store the trainer’s name and their favorite Pokémon. The extension is a good way to get a string representation of the object, we will use this string later in our views.
Creating reusable components#
Our app is quite simple, yet we are using several repeated components in our Views, to avoid repeating code and ensure the UI elements behave the same way through the app we are going to create simple reusable components. To do this we are going to extend the class element of the component we need and add a function that will create the component configured for our use.
Let’s start by creating a new Group called Extensions
by pressing CMD + OPC + A
, inside this new group let’s create a new file called UIExtensions.swift
and add the following code:
import UIKit
extension UIButton {
static func makeSimpleButton(title: String, action: UIAction) -> UIButton {
let button = UIButton()
button.setTitle(title, for: .normal)
button.addAction(action, for: .touchUpInside)
button.configuration = UIButton.Configuration.filled()
button.translatesAutoresizingMaskIntoConstraints = false
return button
}
}
extension UILabel {
static func makeSimpleLabel(
text: String = "",
textAlignment: NSTextAlignment = .center,
font: UIFont = .preferredFont(forTextStyle: .body)
) -> UILabel {
let label = UILabel()
label.textAlignment = textAlignment
label.text = text
label.font = font
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
}
extension UIStackView {
static func makeSimpleStack(axis: NSLayoutConstraint.Axis ) -> UIStackView {
let stack = UIStackView()
stack.axis = axis
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
}
extension UITextField {
static func makeSimpleTextField(delegate: (any UITextFieldDelegate)?) -> UITextField {
let textField = UITextField()
textField.borderStyle = .roundedRect
// Don't forget to add delegate to call the function that sends data back
textField.delegate = delegate
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}
}
Done, now we can use these methods to create preconfigured UI elements, we can configure further to tailor our needs, if the systems grows quite complex we could even implement Factories. This is a great way to avoid repeating code and ensuring UI uniformity for our app.
Creating our first screen#
Now that we have all the needed code in place, it’s time to see some changes in our phone’s screen. Let’s open ViewController.swift
and let’s start adding our UI elements!
For this screen we are going to use a UILabel
and a UIButton
, it’s time to use our handy make methods we defined in our previous step.
In the body class, let’s add the following properties that will contain the references to the UI elements.
private lazy var button: UIButton = .makeSimpleButton(title: "Add Trainer Data", action: buttonAction)
private lazy var label: UILabel = .makeSimpleLabel()
Now you will get an error because buttonAction
is not defined, let’s fix that now!
Create a private extension
of your ViewController
class and add the missing method there.
private extension ViewController {
var buttonAction: UIAction {
UIAction { [weak self] action in
print("Button pressed")
}
}
}
This should fix the error. Later on, we will add the logic for the pressed button.
Previewing your ViewController#
iOS 17 allow us to see in the Canvas the current state of our ViewController. This addition makes creating UIKit code easier and faster since we can preview in realtime our changes.
To use this new feature we just need to add the following at the bottom of our ViewController.swift
file. This uses the new macro features to creates a preview canvas that instantiate a copy of our view controller
#Preview {
ViewController()
}
Now you will have this white iPhone on your right.
But wait, we just added two UI components to our ViewController, why aren’t they visible? This is because we need to tell the view controller where we want to place them in the View. to achieve this we need to use a nifty tool called Auto Layout.
Adding constraints to our UI elements#
We have to elements loaded on memory now it’s time to define where in the view they will be and add them to the current View.
To make our process of adding these elements to the current view lets create a simple extension, place it in the UIExtensions
file.
extension UIStackView {
func addArrangedSubviews(_ views: [UIView]) {
views.forEach {
self.addArrangedSubview($0)
}
}
}
To make Auto Layout to work on our UI elements we need to remember to set
translatesAutoresizingMaskIntoConstraints
to false.
Now it’s time to add and configure how the UI will be displayed on the View.
To make this process easy, let’s make a method that will do this for us. Inside the ViewController
class we need to add the following functions.
func configureView() {
view.backgroundColor = .systemBackground
view.addSubviews([button, label])
configureConstraints()
}
func configureConstraints() {
let buttonConstraints = [
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 64),
label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16)
]
NSLayoutConstraint.activate(buttonConstraints)
}
Understanding Auto Layout#
When creating user interfaces in iOS under UIKit, Auto Layout is used to calculate the size and position of views based on the constraints we specify.
The code above establishes rules for placing a button and a label within their container view, centering the button and positioning the label near the top and stretching it from side to side. These constraints ensure that the UI elements are correctly laid out across different devices and orientations.
Setting Up the Scene#
Here is a small analogy that I hope can help you to understand better the Auto Layout code:
Imagine you’re arranging furniture in a room (the view
), with a chair (button
) and a painting (label
). You want these items to be placed according to specific rules:
-
Centering the Chair:
button.centerXAnchor.constraint(equalTo: view.centerXAnchor)
:- Align the chair’s center with the room’s horizontal center.
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
:- Align the chair’s center with the room’s vertical center.
-
Positioning the Painting:
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 64)
:- Place the top of the painting slightly below the top of the safe area (like giving it some space from the ceiling).
label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16)
:- Set the painting’s left edge a bit away from the room’s left wall.
label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16)
:- Set the painting’s right edge a bit away from the room’s right wall.
Applying the Rules#
NSLayoutConstraint.activate(buttonConstraints)
:- This activates the constraints, like finalizing the arrangement and instructing the system to place the furniture accordingly.
Remeber to call
configureView()
insideviewDidLoad
or this code will never execute!
Now you can get your UI in the Canvas, congratulations!
If run the app and try to press the button nothing interesting will happen, let’s fix that next!.
Creating the Second Screen#
The second screen will contain a Form that will ask for certain date to the user, this form is a great way to introduce reusable UIView
Creating the Reusable Field View#
To make our reusable view we need to create a UIView
.
Create a new Swift file and call it FormSectionView.swift
, inside that file let’s create our class that inherit from UIView
(all views in UIKit inherit from this class).
final class FormSectionView: UIView {
// Our code will be here
}
Our View
needs a way to communicate internal information outward and to receive information from the outside. To solve this problem we have several common methods in UIKit:
- Delegation
- Closures
- Notification Center
In this example let’s explore using closures.
Creating the communication path#
To receive information we need a closure that returns the information from the outside when this changes.
To send information we need to send the information as the closure parameter so the outside world can get and manipulate this information.
For our app we are receiving and sending text from this View.
Let’s define the communication path, inside FormSectionView
class define the following variables.
var textDidChange: ((String) -> Void)?
var sectionTitle: (() -> String)? {
didSet {
sectionLabel.text = sectionTitle?()
}
}
Here is what the code is doing:
Understanding the textDidChange
Closure#
-
Purpose: This property is a closure that takes a string as input and performs an action with no return value (Void). It’s designed to execute when text data changes, allowing for dynamic responses to user input or programmatic changes.
-
Usage: You use textDidChange to update other components of your application when the user modifies the text in a UITextField. This closure provides a way to react immediately to changes, facilitating actions like validation, interface updates, or data transformations without needing additional delegation or observation mechanisms.
Dynamic Title Setting with sectionTitle
#
-
Purpose:
sectionTitle
is a closure that, when called, returns a String representing the title of a section. It’s used to dynamically provide text for UI labels. -
Behavior on Change (
didSet
): ThedidSet
observer attached tosectionTitle
automatically updates thesectionLabel.text
each time the closure’s value is set or updated. The safe callsectionTitle?()
ensures that the closure is only called if it’s notnil
, preventing runtime errors. -
Practical Use
: This property can be particularly useful in adaptive UIs where the section title might change based on user interactions or other application logic. By tying the label text directly to a closure, you maintain flexibility and reduce the need for explicit refresh or update calls throughout your codebase.
Creating the UI elements#
You may have noticed that the code inside the didSet
failed because we didn’t add any UI to our UIView
. Let’s fix that by adding the UI elements from this View.
As you remember, we extended some UI elements to create some preconnfigured objects, this will come handy in this class too.
Inside FormSectionView
class add the following elements.
private lazy var sectionLabel: UILabel = .makeSimpleLabel(
text: "No Text Received, sectionTitle not set.",
textAlignment: .natural,
font: .systemFont(ofSize: 24, weight: .semibold)
)
private lazy var sectionTextField: UITextField = .makeSimpleTextField(delegate: self)
These are the two elements we need to make our view, as before we also need to use Auto Layout to position them on the screen and add the elements so the View knows about them, to do that add this code at the bottom of the file.
private extension FormSectionView {
func configureView() {
addSubviews([sectionLabel, sectionTextField])
configureConstraints()
}
func configureConstraints() {
let sectionLabelConstraints = [
sectionLabel.topAnchor.constraint(equalTo: topAnchor),
sectionLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
sectionLabel.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let sectionTextFieldConstraints = [
sectionTextField.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor),
sectionTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
sectionTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
sectionTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
]
let constraints = sectionLabelConstraints + sectionTextFieldConstraints
NSLayoutConstraint.activate(constraints)
}
}
In ViewControllers we usually place the configuration in viewDidLoad
but since this is a View, we don’t have that life cycle so we need to use a normal init
. Add the following code to the class.
override init(frame: CGRect) {
super.init(frame: frame)
configureView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
In UIKit, the required
init?(coder: NSCoder)
initializer is a crucial component of the view and view controller initialization process when they are being instantiated from storyboards or XIB files.
To finalize our view we need a way to know when the text in the UITextField
change, to achieve this we can use a delegate of this class. This is way we set this View as the delegate of the custom UITextField
.
Add the following extension at the end of the file.
extension FormSectionView: UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
guard let currentText = textField.text,
let textRange = Range(range, in: currentText) else { return false }
let updatedText = currentText.replacingCharacters(in: textRange, with: string)
textDidChange?(updatedText)
return true
}
}
- Function
textField(_:shouldChangeCharactersIn:replacementString:)
: This delegate method is called whenever the user types a character, deletes a character, or pastes content into the text field. It asks the delegate (in this case,FormSectionView
) if the specified text should be changed.
Inside the Function#
Parameters:#
- textField: The text field containing the text.
- range: The range of characters to be replaced.
- string: The replacement string that is typed or pasted into the text field.
Guard Statement:#
- Checks if it can obtain the current text of the text field and convert the
NSRange
to aRange<String.Index>
suitable for Swift string operations. - If it can’t, it returns false, preventing the change to the text field’s content.
Updating Text:#
updatedText
: A new string is created by replacing the characters within the specified range with the new string.- This is done using
replacingCharacters(in:with:)
, which handles the text replacement safely, considering character encoding and composition.
Closure Call:#
textDidChange?(updatedText)
: If thetextDidChange
closure is set, it’s called with theupdatedText
. This allows the class to react to text changes, potentially updating models, views, or triggering other logic.
Return Value:#
- Returns true to allow the text change to proceed. If you return false, the text field would not update its display to reflect the new text.
Previewing the View#
As before, we can preview our View thanks to the canvas.
Add the following code at the end of the file to activate the Preview.
#Preview(traits: .sizeThatFitsLayout) {
FormSectionView()
}
Here is our view!
Creating the Form View using the Reusable View component#
It’s time to create the Form View that will be present in the view controller, thanks to the reusable view we can simplify the creation of this view.
Create a new Swift file called TrainerFormView.swift
and add the following code.
import UIKit
final class TrainerFormView: UIView {
}
Next, let’s add the UI elements we need for this.
Add the following inside the class.
private lazy var headerTitle: UILabel = .makeSimpleLabel(
text: "Trainer Form",
font: .preferredFont(forTextStyle: .extraLargeTitle)
)
private lazy var formStack: UIStackView = .makeSimpleStack(axis: .vertical)
private lazy var saveButton: UIButton = .makeSimpleButton(
title: "Save Trainer Data",
action: saveAction
)
The button’s action saveAction
will mark an error, we need to define this action. As before let’s create a private extension and add the action.
private extension TrainerFormView {
var saveAction: UIAction {
UIAction { [weak self] action in
guard let self else { return }
self.saveButtonAction?(self.trainerData)
}
}
And add the following property inside the class, we are using this tuple as a way to store the current state of the entered data, this represents an instance of our model.
private var trainerData: TrainerInfo = .init(id: UUID(), name: "Name", favoritePokemon: "Pokemon")
Adding the custom fields to the stack#
Stacks are a great way to add elements in a ordered way. In our case we are using them to store the custom text fields.
Let’s configure the stack with the UI we need.
Inside private extension TrainerFormView
add the following function.
func configureStack() {
let nameSection = FormSectionView()
nameSection.sectionTitle = { "Trainer Name" }
nameSection.textDidChange = { [weak self] text in
self?.trainerData.name = text
}
let pokemonSection = FormSectionView()
pokemonSection.sectionTitle = { "Favorite Pokémon"}
pokemonSection.textDidChange = { [weak self] text in
self?.trainerData.favoritePokemon = text
}
formStack.addArrangedSubviews([nameSection, pokemonSection])
formStack.spacing = 16
}
Here we are setting the closure for communication, passing the text name and receiving the input data to then store it the tuple we have in this View.
Adding constraints#
So far our elements cannot be rendered in the View because we still need to add them. As usual we need to set the Auto Layout for these elements, add the following function inside private extension TrainerFormView
.
func configureConstraints() {
let headerTitleConstraints = [
headerTitle.topAnchor.constraint(equalTo: topAnchor),
headerTitle.leadingAnchor.constraint(equalTo: leadingAnchor),
headerTitle.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let formStackConstraints = [
formStack.topAnchor.constraint(equalTo: headerTitle.bottomAnchor, constant: 32),
formStack.leadingAnchor.constraint(equalTo: leadingAnchor),
formStack.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let saveButtonConstraints = [
saveButton.topAnchor.constraint(equalTo: formStack.bottomAnchor, constant: 32),
saveButton.heightAnchor.constraint(equalToConstant: 48),
saveButton.leadingAnchor.constraint(equalTo: leadingAnchor),
saveButton.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let viewConstraints = headerTitleConstraints + formStackConstraints + saveButtonConstraints
NSLayoutConstraint.activate(viewConstraints)
}
Adding the init#
To make all this work we need to add the init and call the configuration functions, like in the previous UIView
we need to add the following to the class.
override init(frame: CGRect) {
super.init(frame: frame)
addSubviews([headerTitle, formStack, saveButton])
configureConstraints()
configureStack()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
If you don’t add the
subviews
before the constraints the simulator will crash.
Previewing the result#
As normal, we can preview the view by using the canvas. Add the following code at the end of the file.
#Preview(traits: .sizeThatFitsLayout) {
TrainerFormView()
}
Here is the final View!
Creating the FormViewController#
The View Form is read, now we need a Controller that manages it!, it’s time to create our FormViewController.
Add a new file called FormViewController.swift
and add the following code.
import UIKit
class FormViewController: UIViewController {
}
Adding the View#
We could create a factory that help us to create the Views but this time let’s use a simple closure that returns the View.
inside the class add the following UI element (the view we just created).
lazy var trainerForm: TrainerFormView = {
let form = TrainerFormView()
return form
}()
We also need a way to communicate the result of our View Form to any parent view controller, to achieve this we are using again a closure with the data received from the view.
Add this to the class.
var saveAction: ((TrainerInfo) -> Void)?
As always we need to add the view (we could override the default view also) and set the constraints, here we are also setting the closure that will receive the data from the View.
This closure will call the closure of our ViewController and send the information to the next object that needs this information.
The dismiss is used to pop this view from the screen.
Usually we have two way of presenting screens in iOS:
- With a Push
- With a Modal
To do that add this function to the class.
private func configureView() {
trainerForm.translatesAutoresizingMaskIntoConstraints = false
trainerForm.saveButtonAction = { [weak self] trainerData in
self?.saveAction?(trainerData)
self?.dismiss(animated: true)
}
view.addSubview(trainerForm)
NSLayoutConstraint.activate([
trainerForm.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
trainerForm.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
trainerForm.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
trainerForm.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
}
Wiring up the ViewController#
To finish this ViewController just call the functions inside the viewDidLoad
.
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
configureView()
}
As always you can also add a Preview, add at the end of the file.
#Preview {
FormViewController()
}
Congratulations, you have the second view controller ready to use!
Next step is to present it from the first view controller and setting up the communication path.
Presenting the form#
Open ViewController.swift
and let’s modify the button action. Locate the var buttonAction: UIAction
computed proerty and let’s modify it to have this code instead.
var buttonAction: UIAction {
UIAction { [weak self] action in
let formViewController = FormViewController()
formViewController.saveAction = { [weak self] trainerData in
self?.label.text = trainerData.description
}
self?.present(formViewController, animated: true)
}
}
Here, we are updating the UIAction
, we are creating the controller we want to present and configure its closure. then we use the present()
method to present modally the view controller.
In the closure we are receiving the data every time we press the button Save Trainer Data
in the FormViewController. We are using this data to update the label in this ViewController.
By doing this change the app is ready.
Press run and let’s test it!
Testing the final app#
Here is a video of the resulting app.
Congratulations, you made it to the end!
Conclusion#
In this blog post, we’ve explored the benefits of programmatic UIKit development in iOS 17, showcasing the power and flexibility of bypassing storyboards to create dynamic user interfaces directly in code. By delving into practical examples—from configuring the project environment to creating reusable components and leveraging the SwiftUI canvas for real-time previews—we have demonstrated how a UIKit app can be constructed from the ground up with clarity and precision.
The ability to preview UIViews and ViewControllers within Xcode enhances development by providing immediate visual feedback, a feature that significantly streamlines the process of building and refining user interfaces. Moreover, our dive into the creation of reusable components not only promotes cleaner, more maintainable code but also fosters a modular approach to UI development that can be easily adapted or expanded upon.
By the end of our project, we effectively utilized closures and extensions to keep our codebase efficient and readable, and demonstrated the seamless flow of data between views, emphasizing the robustness of UIKit in handling complex user interactions.
As iOS continues to evolve, embracing these programmatic techniques provides a solid foundation for both new and experienced developers to build sophisticated and responsive apps. Whether you are transitioning from storyboards or enhancing your existing skills, the insights from this tutorial will undoubtedly aid in your journey towards mastering programmatic UIKit development. Happy coding! 🚀
Appendix#
Here are the full code separated by files:
Controllers#
ViewController
#
final class ViewController: UIViewController {
// MARK: - UI Elements
private lazy var button: UIButton = .makeSimpleButton(title: "Add Trainer Data", action: buttonAction)
private lazy var label: UILabel = .makeSimpleLabel()
// MARK: - ViewController Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
// To show the title I need a navigation controller initialized
title = "Trainer Log"
configureView()
}
}
// MARK: - Private Methods
private extension ViewController {
var buttonAction: UIAction {
UIAction { [weak self] action in
let formViewController = FormViewController()
formViewController.saveAction = { [weak self] trainerData in
self?.label.text = trainerData.description
}
self?.present(formViewController, animated: true)
}
}
func configureView() {
view.backgroundColor = .systemBackground
view.addSubviews([button, label])
configureConstraints()
}
func configureConstraints() {
let buttonConstraints = [
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 64),
label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16)
]
NSLayoutConstraint.activate(buttonConstraints)
}
}
FormViewController
#
class FormViewController: UIViewController {
var saveAction: ((TrainerInfo) -> Void)?
private lazy var trainerForm: TrainerFormView = {
let form = TrainerFormView()
return form
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
configureView()
}
private func configureView() {
trainerForm.translatesAutoresizingMaskIntoConstraints = false
trainerForm.saveButtonAction = { [weak self] trainerData in
self?.saveAction?(trainerData)
self?.dismiss(animated: true)
}
view.addSubview(trainerForm)
NSLayoutConstraint.activate([
trainerForm.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
trainerForm.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
trainerForm.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
trainerForm.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
}
}
Extensions#
import UIKit
extension UIView {
/// Add the array of subviews to the current View
/// - Parameter views: The views to add
func addSubviews(_ views: [UIView]) {
views.forEach {
self.addSubview($0)
}
}
}
extension UIStackView {
func addArrangedSubviews(_ views: [UIView]) {
views.forEach {
self.addArrangedSubview($0)
}
}
}
extension UIButton {
static func makeSimpleButton(title: String, action: UIAction) -> UIButton {
let button = UIButton()
button.setTitle(title, for: .normal)
button.addAction(action, for: .touchUpInside)
button.configuration = UIButton.Configuration.filled()
button.translatesAutoresizingMaskIntoConstraints = false
return button
}
}
extension UILabel {
static func makeSimpleLabel(
text: String = "",
textAlignment: NSTextAlignment = .center,
font: UIFont = .preferredFont(forTextStyle: .body)
) -> UILabel {
let label = UILabel()
label.textAlignment = textAlignment
label.text = text
label.font = font
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
}
extension UIStackView {
static func makeSimpleStack(axis: NSLayoutConstraint.Axis ) -> UIStackView {
let stack = UIStackView()
stack.axis = axis
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
}
extension UITextField {
static func makeSimpleTextField(delegate: (any UITextFieldDelegate)?) -> UITextField {
let textField = UITextField()
textField.borderStyle = .roundedRect
// Don't forget to add delegate to call the function that sends data back
textField.delegate = delegate
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}
}
Views#
TrainerFormView
#
import UIKit
final class TrainerFormView: UIView {
private var trainerData: TrainerInfo = .init(id: UUID(), name: "Name", favoritePokemon: "Pokemon")
var saveButtonAction: ((TrainerInfo) -> Void)?
private lazy var headerTitle: UILabel = .makeSimpleLabel(
text: "Trainer Form",
font: .preferredFont(forTextStyle: .extraLargeTitle)
)
private lazy var formStack: UIStackView = .makeSimpleStack(axis: .vertical)
private lazy var saveButton: UIButton = .makeSimpleButton(
title: "Save Trainer Data",
action: saveAction
)
override init(frame: CGRect) {
super.init(frame: frame)
// If you don't add the subviews before the constraints the simulator will crash
addSubviews([headerTitle, formStack, saveButton])
configureConstraints()
configureStack()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension TrainerFormView {
func configureConstraints() {
let headerTitleConstraints = [
headerTitle.topAnchor.constraint(equalTo: topAnchor),
headerTitle.leadingAnchor.constraint(equalTo: leadingAnchor),
headerTitle.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let formStackConstraints = [
formStack.topAnchor.constraint(equalTo: headerTitle.bottomAnchor, constant: 32),
formStack.leadingAnchor.constraint(equalTo: leadingAnchor),
formStack.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let saveButtonConstraints = [
saveButton.topAnchor.constraint(equalTo: formStack.bottomAnchor, constant: 32),
saveButton.heightAnchor.constraint(equalToConstant: 48),
saveButton.leadingAnchor.constraint(equalTo: leadingAnchor),
saveButton.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let viewConstraints = headerTitleConstraints + formStackConstraints + saveButtonConstraints
NSLayoutConstraint.activate(viewConstraints)
}
var saveAction: UIAction {
UIAction { [weak self] action in
// If I don't safe unwrap self this wont work because trainerData despite not being optional
// by the weak self it becomes optional when accessing via self?. .
guard let self else { return }
self.saveButtonAction?(self.trainerData)
}
}
func configureStack() {
let nameSection = FormSectionView()
nameSection.sectionTitle = { "Trainer Name" }
nameSection.textDidChange = { [weak self] text in
self?.trainerData.name = text
}
let pokemonSection = FormSectionView()
pokemonSection.sectionTitle = { "Favorite Pokémon"}
pokemonSection.textDidChange = { [weak self] text in
self?.trainerData.favoritePokemon = text
}
formStack.addArrangedSubviews([nameSection, pokemonSection])
formStack.spacing = 16
}
}
#Preview(traits: .sizeThatFitsLayout) {
TrainerFormView()
}
FormSectionView
#
import UIKit
final class FormSectionView: UIView {
var textDidChange: ((String) -> Void)?
var sectionTitle: (() -> String)? {
didSet {
sectionLabel.text = sectionTitle?()
}
}
private lazy var sectionLabel: UILabel = .makeSimpleLabel(
text: "No Text Received, sectionTitle not set.",
textAlignment: .natural,
font: .systemFont(ofSize: 24, weight: .semibold)
)
private lazy var sectionTextField: UITextField = .makeSimpleTextField(delegate: self)
override init(frame: CGRect) {
super.init(frame: frame)
configureView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension FormSectionView {
func configureView() {
addSubviews([sectionLabel, sectionTextField])
configureConstraints()
}
func configureConstraints() {
let sectionLabelConstraints = [
sectionLabel.topAnchor.constraint(equalTo: topAnchor),
sectionLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
sectionLabel.trailingAnchor.constraint(equalTo: trailingAnchor)
]
let sectionTextFieldConstraints = [
sectionTextField.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor),
sectionTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
sectionTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
sectionTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
]
let constraints = sectionLabelConstraints + sectionTextFieldConstraints
NSLayoutConstraint.activate(constraints)
}
}
extension FormSectionView: UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
guard let currentText = textField.text,
let textRange = Range(range, in: currentText) else { return false }
let updatedText = currentText.replacingCharacters(in: textRange, with: string)
textDidChange?(updatedText)
return true
}
}
#Preview(traits: .sizeThatFitsLayout) {
FormSectionView()
}
Model#
TrainerInfo
#
import Foundation
struct TrainerInfo: Identifiable {
let id: UUID
var name: String
var favoritePokemon: String
}
extension TrainerInfo: CustomStringConvertible {
var description: String {
"Trainer \(name) likes \(favoritePokemon)"
}
}
SceneDelegate modified function#
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let viewController = ViewController()
let navigationController = UINavigationController(rootViewController: viewController)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
}