A Simple Full-Stack iOS App Example
Table of Contents
One of the benefits of using Swift is its excellent performance and low memory footprint. This is a key point when doing backend programming.
The future of Swift in Linux looks promising. With Swift 5.9, there will be better error logging, and Apple is developing a new open-source cross-platform testing framework with swift-testing that will replace XCTest and works on Linux too.
Let’s hope this encourages more backend systems to be written in Swift.
In this tutorial, we’ll explore how to code a backend in Vapor and establish its connection to a simple iOS app. I hope this example serves as a good starting point for learning about the interaction between mobile and backend systems.
Getting Started: Tech Stack Overview#
Backend#
Some mobile developers prefer using auto-managed backend solutions like Firebase and may not pay much attention to the server side. Nevertheless, I believe that understanding the basic programming and concepts used in the backend can enhance your comprehension of the entire system. This understanding enables us to create solutions that are not only more efficient but also better integrated.
Framework#
Backend programming is a complex topic with various frameworks in the market. In the Swift world, the most widely used backend framework is Vapor.
Vapor is an excellent solution for building backend systems. It utilizes a non-blocking, event-driven architecture built on top of Apple’s SwiftNIO. This offers us great performance, allowing us to create complex systems that scale well.
Keep in mind that some third-party services may not have a ready-to-use Swift/Vapor API. This can introduce additional complexity when integrating these services.
Database#
For the database, we are using PostgreSQL. This database is an open-source relational database and offers great performance with a lot of useful features for any needs we could have.
To install and run our database, we are going to use Docker with a docker-compose file. This is great because we can start a database in seconds and allows us to replicate our configuration in different environments.
App#
We use SwiftUI with modern concurrency (async
/await
), implementing a Network layer to manage requests, and follow the MVVM architecture to organize our code.
This approach enables us to delve into modern iOS development, providing insights into how all elements interconnect and respond to changes.
Coding the Backend#
Installing docker#
Let’s start our project by installing docker, we need it to create the database and the container for our vapor app.
We can use Homebrew to install it via the terminal, or just get it from the Docker site.
If you decide to use Homebrew, we need to have brew
installed and run the following command on the terminal:
brew install --cask docker
Open Docker and let’s continue.
Creating and running the database#
To set up our PostgreSQL database, we’ll need to create a docker-compose.yml
file and define its configuration. Follow these steps:
-
Create a file named
docker-compose.yml
in your project directory. -
Add the following configuration to the
docker-compose.yml
file:
version: '3'
services:
postgres:
image: postgres:latest
container_name: postgres
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: postgres
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- '5432:5432'
networks:
- fullstack-ios-net
volumes:
postgres_data:
networks:
fullstack-ios-net:
driver: bridge
volumes
will tell Docker to create the persistance layer with the given name for us, so we don’t need to specify any directory.
-
Open a terminal in the directory containing the
docker-compose.yml
file. -
Run the following command to start the database container:
docker-compose -p database-vapor up -d
You can change the container name by replacing
database-test
with your preferred name. The-d
flag runs the container in detached mode, freeing up the terminal.
This will create a running database locally, ready for us to use in our backend.
To confirm that the database is running, execute the command docker ps
in your terminal. Alternatively, you can check Docker Desktop if it is installed on your system.
Since we are running separate containers for the database and the vapor app, we need to get the network in which the database is attached, to do so let’s run:
docker network ls
In my case, the network created for this container is called database-vapor_fullstack-ios-net
. We’ll use this network for the vapor app, so keeps your network name saved.
To check the containers attached to a network run:
docker network inspect <network_name/ID>
.
To delete a network run the command:
docker network rm <network_name/ID>
.
Here’s an example of the resulting database running on my system:
Creating the Vapor Container#
Let’s move on to the exciting part – building our Vapor app. For this process, we’ll leverage a development container with VSCode, a fantastic tool that helps maintain a clean local environment and ensures reproducibility for every project.
Feel free to adjust this according to your style and preferences!
Follow these steps to create your empty vapor container:
- Create a root directory for the vapor container.
- Inside the root directory, create a new directory called
.devcontainer
. - Inside
.devcontainer
, create a new file calleddevcontainer.json
and add the following:
{
"name": "Vapor",
"image": "swift:latest",
"runArgs": [
"--name=vapor-local-devcontainer",
"--network=database-vapor_fullstack-ios-net"
],
"postCreateCommand": "bash .devcontainer/vaporinstall.sh",
"customizations": {
"vscode": {
"extensions": [
"sswg.swift-lang",
"streetsidesoftware.code-spell-checker"
]
}
}
}
We are adding this container to the same network as the database, this will allow communication between the two containers.
- Create the Vapor installation script
vaporinstall.sh
file inside the.devcontainer
directory. - Add the following to
vaporinstall.sh
:
#!/bin/bash
echo "Vapor installation is starting"
# Updating and installing packages
apt update && apt upgrade -y
apt install -y make
# Installing Vapor
git clone https://github.com/vapor/toolbox.git
cd toolbox
make install
cd ..
rm -rf toolbox
echo "Vapor installation was successful"
- Open the project in VSCode and then select the option
Reopen in Container
.
To check Vapor is installed, run in a terminal vapor --version
.
This will create the container and install vapor inside, you’re ready to code the backend in Vapor!
Finally, let’s check that both containers are under the same network, run this command in the host terminal:
docker network inspect database-vapor_fullstack-ios-net | grep -A 20 "Containers"
There you can check that our two containers are listed in the output under "Containers"
, we’re good to continue.
Creating a barebone Vapor App#
It’s time to create our app inside the container, this is a simple task thanks to the integrated vapor tools:
- Run in the container’s terminal the following:
vapor new temperature-api -n
-n
will create a barebone project
-
Enter the new directory
cd temperature-api
-
Run the project
swift run
Congratulations, now your Vapor app is up and running. To verify that it works as expected you can check the root endpoint /
in the URL mentioned in the terminal, usually http://127.0.0.1:8080/
Creating the Model for the Database#
Let’s start by codding the Model that will represent our table!
This step is crucial because we are modeling our problem and it’s important that we discuss this thoroughly to get a model that efficiently defines our data and allow us to have a simple and performant representation of the problem’s data.
This is a really simple app, so we are just storing a given temperature and a related date. Since this is simple to model we’re going to use a simple table containing these values and a unique identifier for each new value.
Installing Fluent
and Postgres Driver
#
We’re going to use an ORM to communicate with our database, this is a great tool that allow us to stay in Swift when making queries, models and relationships. In our case we decided to use Fluent and a Postgres Driver.
To install them, follow the next steps:
- Open
Package.swift
- Add the following lines in the
dependencies
array:
.package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
- Add the following inside the second
dependencies
array, nested insidetargets
:
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
If you’re using a different database look into Fluent documentation to find an appropriate driver.
Your Package.swift
should look similar to this:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "temperature-logger",
platforms: [
.macOS(.v13)
],
dependencies: [
// 💧 A server-side Swift web framework.
.package(url: "https://github.com/vapor/vapor.git", from: "4.83.1"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"), // added
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"), //added
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"), // added
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver") //added
]
),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
// Workaround for https://github.com/apple/swift-package-manager/issues/6940
.product(name: "Vapor", package: "vapor"),
])
]
)
These new packages will be automatically added to our project.
Creating our Temperature model#
The models we define in this step will be used to create the tables in our database.
- Create a new file called
Temperature.swift
in the following path:Sources/App/Models/
- Add the following code:
import Vapor
import Fluent
final class Temperature: Model, Content {
static let schema: String = "temperatures"
@ID
var id: UUID?
@Field(key: "temperature")
var temperature: String
@Field(key: "date")
var date: String
init() { }
init(id: UUID? = nil, temperature: String, date: String) {
self.id = id
self.temperature = temperature
self.date = date
}
}
The property wrappers are defined in Fluent
and let us mark the different roles for our database, the names inside @Field
are used to represent the name of the columns in the table. The schema at the top will be used as the table name in our database.
Excellent, we have our schema defined.
Connecting the Postgres Database to our Vapor App#
Oru next goal is to connect our database to our vapor app and create the schema for our system.
Creating the Migration file#
Migrations serve the purpose of defining and applying changes to the database schema. A migration is essentially a set of instructions that describe how to evolve the database structure, creating or modifying tables and their columns.
To create our own migration file follow the next steps:
- Create the file
CreateTemperature.swift
in the path:Sources/App/Migrations/
- Add the following code:
// To run migrations, call `swift run App migrate` from the command line
import Fluent
struct CreateTemperature: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("temperatures")
.id()
.field("temperature", .string, .required)
.field("date", .string, .required)
.create()
}
func revert(on database: Database) async throws {
try await database.schema("temperatures")
.delete()
}
}
The prepare
function is part of the migration and specifies what changes to make to the database when applying the migration.
In simple terms, it says:
- Create a new table named ’temperatures'.
- Add an ‘id’ column (which is typically an identifier for each row in the table).
- Add a ’temperature’ column of type ‘string’ that is required.
- Add a ‘date’ column of type ‘string’ that is also required.
- Ensure that all these changes are applied to the database.
The revert
function is part of the migration and specifies how to undo the changes made by the prepare function.
In simple terms, it says:
- Delete the ’temperatures’ table from the database.
Setting-up the Postgres Database#
We need to configure the credentials for our Postgres database. To do this we need to open the file configure.swift
inside the path: Sources/App/
.
Add the packages needed at the top of the file:
import Fluent
import FluentPostgresDriver
Add the following code at the top inside the configure
function:
The data for these credentials are defined in the docker-file of the database. If you’re hosting your database in a system other than
localhost
, use your URL in place oflocalhost
.
// defining the postgres configuration object
let databaseConfiguration = SQLPostgresConfiguration(
hostname: "localhost",
username: "user",
password: "password",
database: "postgres",
tls: .disable
)
// applying the configuration
app.databases.use(
.postgres(configuration: databaseConfiguration), as: .psql
)
// Adding our migration object
app.migrations.add(CreateTemperature())
// Optional, make the logger more verbose
app.logger.logLevel = .debug
Your configuration.swift
should look similar to this:
import Vapor
import Fluent
import FluentPostgresDriver
// configures your application
public func configure(_ app: Application) async throws {
// uncomment to serve files from /Public folder
// app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
// defining the postgres configuration object
let databaseConfiguration = SQLPostgresConfiguration(
hostname: "localhost",
username: "user",
password: "password",
database: "postgres",
tls: .disable
)
// applying the configuration
app.databases.use(
.postgres(configuration: databaseConfiguration), as: .psql
)
// Adding our migration object
app.migrations.add(CreateTemperature())
// Optional, make the logger more verbose
app.logger.logLevel = .debug
// register routes
try routes(app)
}
Now stop the app and run the following commands to apply the migrations and get the database connected.
# Stop the project by pressing CTRL + C
swift run App migrate # to start migration
Creating the endpoints#
We’re about to finish the backend for our iOS app. This backend is quite simple, it will expose a GET
method that will return all the stored temperatures in the database and a post method that will allow us to save a temperature sent from our iOS app.
Since we are only using two endpoints we can add this logic to the file routes.swift
, found in the path temperature-api/Sources/App/
.
Open the file and add the following modules:
import Fluent
import FluentPostgresDriver
Next, inside the routes
function, delete all its body and add this:
app.get("temperatures") { req async throws in
app.logger.info("Temperatures GET received.")
return try await Temperature.query(on: req.db).all()
}
app.post("temperatures") { req async throws -> Temperature in
app.logger.info("Temperature POST request received.")
let temperature = try req.content.decode(Temperature.self)
try await temperature.create(on: req.db)
return temperature
}
Your routes.swift
file should look like this:
import Vapor
import Fluent
import FluentPostgresDriver
func routes(_ app: Application) throws {
app.get("temperatures") { req async throws in
app.logger.info("Temperatures GET received.")
try await Temperature.query(on: req.db).all()
}
app.post("temperatures") { req async throws -> Temperature in
app.logger.info("Temperature POST request received.")
let temperature = try req.content.decode(Temperature.self)
try await temperature.create(on: req.db)
return temperature
}
}
GET Route for “temperatures”:
- When the server receives a
GET
request at the path"/temperatures"
, it logs an informational message saying"Temperatures GET received."
. - It then attempts to retrieve all temperature records from the database asynchronously using
try await Temperature.query(on: req.db).all()
.
POST Route for “temperatures”:
- When the server receives a
POST
request at the path"/temperatures"
, it logs an informational message saying"Temperature POST request received."
. - It then tries to decode the request’s content into a Temperature model object using
let temperature = try req.content.decode(Temperature.self)
. - After decoding, it attempts to create a new temperature record in the database asynchronously with
try await temperature.create(on: req.db)
. - Finally, it returns the created Temperature object as a response.
Congratulations, you’ve finished your backend using Vapor!
Run it with swift run
and let’s test it!
Testing our backend#
For testing our backend I’m using Postman.
First let’s try the GET
, since the database is empty it should return an empty array []
.
Now let’s test the POST
by adding some data.
Finally, let’s check GET
again to verify we get the new stored data from the DB.
Great, our backend is working as expected.
Let’s continue with the iOS app, great work!
Coding the iOS App#
Let’s start our iOS app that will connect to our vapor backend.
One of the most used design patters for SwiftUI is MVVM, this stands for Model, View and ViewModel.
Model: Represents the application’s data and business logic. It is responsible for managing and manipulating the data, as well as enforcing business rules.
View: Represents the user interface and is responsible for displaying data to the user. It observes changes in the ViewModel and updates accordingly. In some implementations, the View may also handle user input and pass it to the ViewModel.
ViewModel: Acts as an intermediary between the Model and the View. It contains the presentation logic and transforms the data from the Model into a form that can be easily displayed by the View. The ViewModel often exposes properties and commands that the View can bind to.
The key point is that the ViewModel doesn’t tell the view what to do; it just receives queries from the view. When the data is ready, it notifies the subscribers, mostly views, about the changes. Then, the views that get the notifications can act accordingly with the new data received.
Let’s create a new iOS SwiftUI project in Xcode to continue, we could name it Temperatures App
or anything you like.
Creating the Model#
Let’s start with the Model, this model would be quite similar to the one in our backend.
Let’s create a new Group called Models
and inside this group create a new file called Temperature.swift
.
import Foundation
struct Temperature: Codable, Identifiable {
var id: UUID = UUID()
var temperature: String = ""
var date: String = ""
}
This is the model our app will use when creating new temperatures locally. The code acts similarly to the one in the backend; we define a new ID for each new temperature and store the temperature and date for that measurement.
We use
Identifiable
to make every temperature object uniquely identifiable. This is useful in the SwiftUIForEach
view.
The
Codable
protocol guarantees that our data can be encoded and decoded, in our case, to and from JSON.
Creating the Network Layer#
The network layer is next; this layer will handle the communication with the server.
Creating the Request Protocol#
Let’s start by defining a protocol that will tell us the public API for a network request. The network request will handle all the information needed by the network service to perform its work.
Create a new group called Networking
and inside create a new file called NetworkRequestProtocol.swift
. Then add this code:
import Foundation
protocol NetworkRequestProtocol {
var url: URL { get }
var method: String { get }
var headers: [String: String]? { get }
var body: Data? { get }
}
As mentioned before, these are the elements we will need to pass to the network manager to be able to send requests to our backend:
- The URL of our backend.
- The type of HTTP method we want to use.
- The headers for our requests.
- The body of our request in case of POST.
Creating the Temperature Request#
Now let’s conform to this protocol. Create a new file called TemperatureRequest.swift
in the Networking
group and add the following code:
import Foundation
enum TemperatureRequest: NetworkRequestProtocol {
case post(temperature: Temperature)
case get
var url: URL {
switch self {
case .get: URL(string: "http://localhost:8080/temperatures") ?? URL(string: "https://apple.com")!
case .post: URL(string: "http://localhost:8080/temperatures") ?? URL(string: "https://apple.com")!
}
}
var method: String {
switch self {
case .get: "GET"
case .post: "POST"
}
}
// no matter the selected case we always return the same header
var headers: [String : String]? {
["Content-Type": "application/json"]
}
var body: Data? {
switch self {
case .get: nil
case .post(let temperature): try? JSONEncoder().encode(temperature)
}
}
}
We’re making this app simple, so we are not defining custom errors.
enum
s are great for handling our network request parameters. Here, TemperatureRequest
conforms to NetworkRequestProtocol
. This is great because we can change our request object with a different one by only conforming to the same protocol. This helps to lower code coupling and improves testability.
The code itself configures the GET
and POST
method data. In the case of POST
, we send the Temperature
object and convert it into a proper JSON type for our backend. The rest of the code is self-explanatory.
Creating the Network Service Protocol#
As mentioned before, protocols are a great way to lower coupling in code and help with testability because we can create mock objects that can simplify our tests for different parts of the code.
Let’s create a new file called NetworkServiceProtocol.swift
in the Networking
group and add this code:
import Foundation
protocol NetworkServiceProtocol {
func execute(request: NetworkRequestProtocol) async throws -> Data
}
As before, we are defining the public API for this object. In this case, we only have one function that will execute the request using the request data we pass.
Creating the Network Service#
The Network service is the final component we need to implement in our Network layer. This object is responsible for doing the actual communication with the backend. To configure our request, we pass a TemperatureRequest
object with the parameters we need.
In the same Networking
group, create a new file called NetworkService
and add the following code:
import Foundation
struct NetworkService: NetworkServiceProtocol {
func execute(request: NetworkRequestProtocol) async throws -> Data {
// configuring the request
var urlRequest = URLRequest(url: request.url)
urlRequest.httpMethod = request.method
urlRequest.allHTTPHeaderFields = request.headers
urlRequest.httpBody = request.body
// performing the request, we wait for the server response
let (data, _) = try await URLSession.shared.data(for: urlRequest)
// returning the data from the server
return data
}
}
In simpler terms the code above does the following:
- Implementing the protocol
NetworkServiceProtocol
execute
function: takes aNetworkRequestProtocol
as a parameter and performs a network request to our backend.URLRequest
setup: Inside theexecute
function, aURLRequest
is created using information from the providedNetworkRequestProtocol
, such as the URL, HTTP method, headers, and request body.- Network request: The function then uses
URLSession.shared
to perform an asynchronous network request based on the constructedURLRequest
. It awaits the response and extracts the obtained data. - Return data: Finally, the obtained data is returned from the function.
Notice that this layer is not responsible of converting the json data to the proper type for our app.
Creating the View Model#
To create the View Model, we need to create a new group called ViewModels
and inside this new group create a new file called TemperaturesViewModel.swift
. Add the following code to this file:
import Foundation
@MainActor // Runs this code in the main thread to avoid updating the UI outside of it.
final class TemperatureViewModel: ObservableObject {
@Published private(set) var temperatures: [Temperature]
private let networkService: NetworkServiceProtocol
// Injecting dependencies
init(temperatures: [Temperature] = [], networkService: NetworkServiceProtocol) {
self.temperatures = temperatures
self.networkService = networkService
}
/// POST a new temperature to the backend.
func addTemperature(temperature: String, date: String) async {
let newTemperature = Temperature(temperature: temperature, date: date)
let request = TemperatureRequest.post(temperature: newTemperature)
do {
let _ = try await networkService.execute(request: request)
} catch {
print("Error adding temperature")
}
}
/// GET all stored temperatures in the backend.
func getTemperatures() async {
let request = TemperatureRequest.get
// perform request
do {
let data = try await networkService.execute(request: request)
// decode data
guard let decodedData = decodeData(data: data) else { return }
print(decodedData)
// saving data on published var
temperatures = decodedData
} catch {
print("Some error in get")
}
}
}
// MARK: - Helper private functions
private extension TemperatureViewModel {
func decodeData(data: Data) -> [Temperature]? {
do {
return try JSONDecoder().decode([Temperature].self, from: data)
} catch {
print("Error decoding temperatures")
return nil
}
}
}
This code may seem complex, but in reality, it is just doing two things: sending data back to the server and getting data from the server. This happens in the methods addTemperature
and getTemperatures
.
Then we are publishing the values with @Published private(set) var temperatures: [Temperature]
, so any object that subscribes to updates can get a notification for changes in this variable. Notice the private(set)
, this allows read-only access to the value.
All of this is handled in the main thread to avoid updating the UI in a different thread by marking the object with @MainActor
.
addTemperature
function:
This function is responsible for sending a new temperature to the backend using a POST request:
- It creates a new Temperature object with the provided temperature and date.
- It creates a TemperatureRequest for a POST request, encapsulating the new temperature data.
- It tries to execute the request asynchronously using the networkService.
- If an error occurs during the request execution, it catches the error and prints an error message.
getTemperatures
function:
This function is responsible for fetching all stored temperatures from the backend using a GET request:
- It creates a TemperatureRequest for a GET request.
- It tries to execute the request asynchronously using the networkService.
- If the request is successful, it decodes the received data into an array of Temperature objects using the decodeData function.
- It prints the decoded data and assigns it to the temperatures property, which is likely a @Published property that updates the UI when changed.
- If an error occurs during the request execution, it catches the error and prints an error message.
Creating the Views#
Now it’s time to see something on our phone’s screen. Let’s create a new group called Views
. Here, we are going to add all the views for our app.
The system will have two views: one for creating new temps and the other for viewing the current temperatures stored in our system. To manage the navigation of these two views, we are going to use a tab navigation style. Since the tab view doesn’t have a title, we’re going to use a title view that will help us to name our screens. This also allows us to customize the screens to our liking.
So, let’s create these three SwiftUI files inside Views
. Let’s call them:
RootView.swift
: Our tab view will be here.PostTemperaturesView.swift
: It will display a simple UI to send the data to the server.GetTemperaturesView.swift
: It will display a simple table with the temperatures from the server.TitleView.swift
: A helper view that will display the title of the current view.
Creating TitleView
#
The easiest view for this app is the TitleView
. It will be a simple view that displays formatted text on the screen.
Add this code to TitleView.swift
:
import SwiftUI
struct TitleView: View {
let title: String
var body: some View {
Text(title)
.font(.largeTitle)
.fontWeight(.bold)
.padding()
}
}
#Preview(traits: .sizeThatFitsLayout) {
TitleView(title: "Custom Title")
}
In this view we are just applying some font styles to the given title.
Creating PostTemperaturesView
#
This view will present the user with a button and two fields for the temperature and date. We are implementing some extremely basic validation that will disable the button if both text fields are empty. In a production app, we should validate against edge cases and be more thoughtful.
Add the following code to PostTemperaturesView.swift
:
import SwiftUI
struct PostTemperaturesView: View {
@State private var temperature = Temperature()
@EnvironmentObject private var temperatureViewModel: TemperatureViewModel
var body: some View {
ZStack {
VStack {
TitleView(title: "Post your temperature")
Divider()
Spacer()
}
VStack {
inputSection()
.padding()
buttonSection()
}
}
}
}
#Preview {
PostTemperaturesView()
}
private extension PostTemperaturesView {
/// Checks if the temperature components are empty or not.
var invalidTemperatureData: Bool {
temperature.date.isEmpty || temperature.temperature.isEmpty ? true : false
}
/// Based on the validity of the temperature object, it returns a specific color.
var buttonBackground: Color {
invalidTemperatureData ? .gray : .red
}
func inputSection() -> some View {
VStack(spacing: 20) {
TextField("Temperature", text: $temperature.temperature)
.padding()
.background(.gray.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 15.0))
TextField("Date", text: $temperature.date)
.padding()
.background(.gray.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 15.0))
}
.frame(width: 130, height: 120)
.padding()
.overlay(
RoundedRectangle(cornerRadius: 25.0)
.stroke(.red, lineWidth: 3)
)
}
func buttonSection() -> some View {
Button {
Task {
await temperatureViewModel.addTemperature(
temperature: temperature.temperature,
date: temperature.date
)
}
} label: {
Text("POST Temperature")
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.disabled(invalidTemperatureData)
.frame(width: 230, height: 60)
.background(buttonBackground)
.clipShape(RoundedRectangle(cornerRadius: 25.0))
}
}
Since we are not reusing any of the sub-views, I decided to implement them in private functions. This helps to lower the code complexity since all sub-views will have direct access to the parent view properties.
To call async
functions in our views, we need to create an asynchronous context. To do that, we use Task { ... }
. Inside a task, we can call our async
functions from the view model. The main advantage of this is that we don’t block our UI by waiting for the response.
The rest of the code is self-descriptive thanks to the declarative aspect of SwiftUI.
Creating GetTemperaturesView
#
Since we need to get the current data from the server, I’m attaching a .task
action that executes every time the view is rendered. This will download the most up-to-date data from the server. Then we will display this information in a formatted table.
In production apps, it’s important to manage possible errors and inform the users visually about the current state of the task. For this small app, we are not managing that aspect.
import SwiftUI
struct GetTemperaturesView: View {
@EnvironmentObject private var temperatureViewModel: TemperatureViewModel
var body: some View {
VStack {
TitleView(title: "Recorded Temperatures")
Divider()
List {
ForEach(temperatureViewModel.temperatures) { temp in
row(temperature: temp)
}
}
.listStyle(.plain)
.task {
await temperatureViewModel.getTemperatures()
}
}
}
}
#Preview {
GetTemperaturesView()
.environmentObject(TemperatureViewModel(networkService: NetworkService())) // needed for the preview not to crash
}
private extension GetTemperaturesView {
func row(temperature: Temperature) -> some View {
VStack {
Text("Date: \(temperature.date)")
Text("Temperature: \(temperature.temperature) \u{2103}")
}
}
}
As before, thanks to the declarative nature of SwiftUI, the code is self-explanatory.
Creating RootView
#
Finally, let’s piece together all the views and complete our UI.
This last part is quite simple to implement, thanks to the way SwiftUI works. Add the following code to the file RootView.swift
:
import SwiftUI
struct RootView: View {
var body: some View {
TabView {
PostTemperaturesView()
.tabItem { Label("POST", systemImage: "square.and.arrow.up") }
GetTemperaturesView()
.tabItem { Label("GET", systemImage: "square.and.arrow.down") }
}
.tint(.red) // change button color in tab
}
}
#Preview {
RootView()
.environmentObject(TemperatureViewModel( networkService: NetworkService()))
}
Here we are creating the tab view and assigning the post and get views we created early in this tutorial.
Configuring the Environment Object#
You may have noticed that some of our views need an Environment object. In the preview, the app works well, but if you try to run it, the app will crash because we are only injecting this object into the preview context. Let’s fix that now.
Open the entry point app file. It’s the one named as your app and contains the attribute @main
.
Inside the struct, let’s add the view model we will be observing:
@StateObject private var temperatureViewModel = TemperatureViewModel(networkService: NetworkService())
Next let’s change our View entry point to the RootView
we just created, and inject the environment object. This will allow access to the ViewModel to RootView
and all of its sub-views.
We’re also attaching an initial get to our backend, this will get us an up to date temperatures each time our app starts.
WindowGroup {
RootView() // change to our RootView
.environmentObject(temperatureViewModel) // inject the ViewModel
.task { // When open the app, perform an initial GET to the server.
await temperatureViewModel.getTemperatures()
}
}
Your file should look like something like this, (your struct
may have a different name):
import SwiftUI
@main
struct temperature_logger_App: App {
@StateObject private var temperatureViewModel = TemperatureViewModel(networkService: NetworkService())
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(temperatureViewModel)
.task {
await temperatureViewModel.getTemperatures()
}
}
}
}
Congratulations! You just finished the app!
Testing the App#
To test the app, you need to have the Vapor app and the database containers running. Verify that they are up and running.
Now, let’s run the app on the simulator and add some temperatures. Then, check the list to see how they update in real-time!
You can also view the logs in our Vapor app to see the requests we are receiving from our app.
Conclusion#
In concluding our exploration of building a full-stack iOS application, the versatility of Swift has truly shone through. Swift not only excels in crafting engaging iOS applications but also seamlessly extends its capabilities to backend development, as demonstrated by the integration with the Vapor framework.
The collaboration between the iOS app and the Vapor backend illustrates the straightforward nature of Swift in the development landscape. The use of Docker for containerization further streamlined the setup, enabling a hassle-free configuration of a PostgreSQL database synchronized with the Vapor app. This amalgamation of Swift, Docker, and Vapor reflects the smooth interplay of modern development tools.
This experience emphasizes Swift’s adaptability, not just in the iOS environment but also in Linux setups, where it effortlessly integrates and collaborates with container technologies like Docker. Swift emerges as a pragmatic choice for end-to-end development.
As we wrap up the journey of creating a full-stack iOS application, it’s worth acknowledging Swift’s role in orchestrating various technologies. It showcases how Swift fosters a developer-friendly environment, encouraging innovation and blurring the lines between frontend and backend development. Here’s to the practicality of Swift and the possibilities it unlocks in the ever-evolving landscape of software development!