Create Your Own One Question Survey Using SwiftUI and AirTable

Chase
14 min readJul 21, 2023

--

Getting feedback from a user is a very quick and simple way to learn how to improve the user experience in your app, improve conversions for a specific part of the app, or just understand how to better serve your customers. In this tutorial, we will walk through how to create our own one question survey for free and add it to our app.

A compilation of all the components that we will build in this tutorial

Initial Setup

Our example will start from a blank app, but this example can be added to match the style of any existing app.

Let’s create a simple one question survey (which is one method of requesting feedback from customers) using AirTable (or Google Sheets, or any other API endpoint you prefer). We will use an API endpoint because this survey is something we would like to turn on or off at any point in time, and to change the question whenever we like.

Getting Our Survey Setup

If you don’t already have an AirTable account, I would greatly appreciate you using my referral link to sign up for an account: https://airtable.com/invite/r/xFtd3WA8

Once you have a free account, we are ready to create our first base (database). In our new database, we will create two tables. One that holds our questions, and one that holds our answers.

Our question table will have:

  • An id field that holds a unique ID for our question
  • A shouldDisplayQuestion checkbox field that will allow us to remotely turn on/off which questions we want to display in our app
  • A field called questionText that holds our question
  • The next two fields are optional for each type of question. If we want a multiple choice field, we will need to know what answers to display; if we want a rating field, we will need to know how many rating options to display.
  • The last field is questionType which will allow us to tell the app what type of question we are going to pass it.

This table will need to have some data in it before we can display any questions in our app.

A screenshot of the questions table for our example survey

Next will will create a table for our answers. This table should be left blank. While we don’t want to put any data in the table, we will need to go ahead and create the fields that will be sent back from the app.

  • This first is an id field that will match the id of the questions table.
  • The second is the questionText that came from our question (this will make it easier to know what we were asking when we look at the results).
  • Then will will need to know what the users response was so we will create an answer field.
  • The last several fields are all number type fields that take an integer. This is useful for answering quick questions like “How many people hid our quiz?” As you can see at the bottom of the screenshot, AirTable will automatically sum up the values in a number field for us. We can also group this data by questionText or id so that we can see all the answers to the same question in a single place.
A screenshot of the answers table for our example survey

We can find the last piece we need from AirTable by going to https://airtable.com/developers/web/api/introduction. This will give us API docs that are specific to what we just created. From here we can also find out how to create our personal access token for our survey, and get useful info like the baseID and TableIDs which we will need next.

In our app, we will create a Constants file that will hold a few useful pieces of info for the API calls while we are testing the app.

//  Constants.swift
import Foundation

class Constants {
static let baseId = "YOUR_BASE_ID_HERE"
static let apiKey = "YOUR_API_KEY_HERE"
static let questionsTableId = "YOUR_QUESTIONS_TABLE_ID_HERE"
static let answersTableId = "YOUR_ANSWERS_TABLE_ID_HERE"
}

Once we have that data, we are done with most of the AirTable work for now. Let’s keep going on building out our app.

Declaring our Data Types

Our question will be made up of a few different pieces but only a few of the pieces are required. The ID, the question, and the questionType are required for every question. The other items are optional because they are specific to each type of question.

// QuestionFields.swift
import Foundation

struct QuestionFields: Codable {
let id: String
let shouldDisplayQuestion: Bool?
let questionText: String
let possibleAnswers: [String]?
let numberOfRatingChoices: Int?
let questionType: QuestionType
}

We will also go ahead and set up the various question types here.

// QuestionType.swift
import Foundation

enum QuestionType: String, Codable {
case multipleChoice, fillInTheBlank, rating
}

We will store the responses that we get from the user by creating an Answer object and sending that data to a table meant to hold the answers in AirTable.

You might think that it would be weird for us to store fields like “sawThisComponent” as an integer, but one question that many data and product people have is, “How many people have seen this component?”
Or, “How many people have answered this question?” When we use a number field in AirTable, it will automatically sum numerical data for us. Which allows anyone who is looking at the data be able to quickly see basic metrics about the survey.

//  AnswerFields.swift
import Foundation

struct AnswerFields: Codable {
let id: String
let questionText: String
let answer: String?
let sawThisComponent: Int
let sawThisQuestion: Int
let dismissedThisComponent: Int
let answeredThisQuestion: Int
}

Now that we have our questions and answers set up, we can combine the two types into an object that AirTable can expect using the following structs. Creating these structs will allow us to easily encode or decode data in a format that AirTable expects, and will help clean up the call sites when we send or receive data from AirTable.

//  Records.swift
import Foundation

struct Records: Codable {
let records: [Record]
}
//  Record.swift
import Foundation

struct Record: Codable {
let id: String?
let createdTime: String?
let fields: Fields
}
//  Fields.swift
import Foundation

enum Fields: Codable {
case questionFields(QuestionFields)
case answerFields(AnswerFields)

func encode(to encoder: Encoder) throws {
switch self {
case .questionFields(let a0):
try a0.encode(to: encoder)
case .answerFields(let a0):
try a0.encode(to: encoder)
}
}

init(from decoder: Decoder) throws {
if let question = try? QuestionFields(from: decoder) {
self = .questionFields(question)
} else if let answer = try? AnswerFields(from: decoder) {
self = .answerFields(answer)
} else {
throw NSError()
}
}
}

Setting up Sample Data

Here we will set up some basic data that we can use to test our views before we make any network requests. This will make our previews more responsive and save us from hitting our database every time we make a change in the views.

class SampleQuestions {
static let fillInTheBlank = QuestionFields(id: UUID().uuidString, shouldDisplayQuestion: true, questionText: "What is the biggest and best way this app has provided value to your life?", possibleAnswers: [], numberOfRatingChoices: nil, questionType: .fillInTheBlank)
static let multipleChoice = QuestionFields(id: UUID().uuidString, shouldDisplayQuestion: true, questionText: "How important is this really cool feature to you?", possibleAnswers: ["Not Important", "Neutral", "Important"], numberOfRatingChoices: nil, questionType: .multipleChoice)
static let rating = QuestionFields(id: UUID().uuidString, shouldDisplayQuestion: true, questionText: "How would you rate this really cool new feature?", possibleAnswers: [], numberOfRatingChoices: 8, questionType: .rating)
}

Creating our Web Service

One thing you may notice here is that we are manually creating the HTTP Method enum ourselves. Swift announced a few weeks ago that a similar implementation will be a built-in feature in the language in the future, but for now we will need to add our own.

We will create two methods in the class, one for getting data from AirTable, and one for sending data to AirTable. We have also added some custom error messaging,and a few print statements that may be helpful for us in our debugging process.

//  WebService.swift
import Foundation

enum httpMethod: String {
case connect = "CONNECT"
case delete = "DELETE"
case get = "GET"
case head = "HEAD"
case options = "OPTIONS"
case patch = "PATCH"
case post = "POST"
case put = "PUT"
case trace = "TRACE"
}

enum NetworkError: Error {
case badUrl
case invalidRequest
case badResponse
case badStatus
case failedToDecodeResponse
}

class WebService {
func downloadData<T: Codable>(fromURL: String) async -> T? {
do {
guard let url = URL(string: fromURL) else { throw NetworkError.badUrl }
var request = URLRequest(url: url)
request.setValue("Bearer \(Constants.apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
// ----------------
guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
let decodedResponse = try JSONDecoder().decode(T.self, from: data)

return decodedResponse
} catch NetworkError.badUrl {
print("There was an error creating the URL")
} catch NetworkError.badResponse {
print("Did not get a valid response")
} catch NetworkError.badStatus {
print("Did not get a 2xx status code from the response")
} catch NetworkError.failedToDecodeResponse {
print("Failed to decode response into the given type")
} catch {
print(error)
print("An error occured downloading the data")
}

return nil
}

func create(records: Records, toURL: String) async {
do {
guard let url = URL(string: toURL) else { throw NetworkError.badUrl }
var request = URLRequest(url: url)
request.httpMethod = httpMethod.post.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(Constants.apiKey)", forHTTPHeaderField: "Authorization")

guard let jsonBody = try? JSONEncoder().encode(records) else {return}

let (data, response) = try await URLSession.shared.upload(for: request, from: jsonBody)

print("\nData", String(data: data, encoding: .utf8))
print("\n\nResponse", response)
guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
guard response.statusCode >= 200 && response.statusCode < 300 else {
print("Status Code", response.statusCode)
throw NetworkError.badStatus
}

// we could use this to display the record update that was successfull.
// but this implementation doesn't use the next line
guard let decodedResponse = try? JSONDecoder().decode(Records.self, from: data) else { throw NetworkError.failedToDecodeResponse }

print("Successfully updated the record")
} catch NetworkError.badUrl {
print("There was an error creating the URL")
} catch NetworkError.badResponse {
print("Did not get a valid response")
} catch NetworkError.badStatus {
print("Did not get a 2xx status code from the response")
} catch NetworkError.failedToDecodeResponse {
print("Failed to decode response into the given type")
} catch {
print("An error occured downloading the data")
}
}
}

If you want to learn more about the web service, feel free to check out this article on fetching data from an API in Swift: https://medium.com/@jpmtech/make-and-parse-an-api-call-using-swiftui-32f970e2b067

Setting up the View Model

Many mobile applications use the MVVM pattern, and this tutorial is no different. We create a view model to help prepare or collect the data before passing it into the view. Using a view model will also help us make the code more testable, and more modular, so that it is easier to extract into its own package (or sharable component) in the future.

//  SurveyViewModel.swift
import Foundation

@MainActor class SurveyViewModel: ObservableObject {
@Published var surveyData = [QuestionFields]()
@Published var shouldShowRequestFeedbackButton = false
@Published var shouldShowSurveyQuestion = false
@Published var usersResponse = ""
var question: QuestionFields?

func fetchData() async {
guard let result: Records = await WebService().downloadData(fromURL: "https://api.airtable.com/v0/\(Constants.baseId)/\(Constants.questionsTableId)") else {return}

surveyData = result.records
.map(\.fields)
.compactMap(\.question)
.filter { $0.shouldDisplayQuestion ?? false }

if !questionHasAlreadyBeenAnswered(surveyData.first) {
shouldShowRequestFeedbackButton = surveyData.first?.shouldDisplayQuestion ?? false
question = surveyData.first ?? nil
}
}

func sendAnswer() async {
//make sure we have a question before trying to submit an ID
guard let question = question else {return}
let recordToSend: Records = Records(
records: [
Record(
id: nil, createdTime: nil, fields: Fields.answerFields(
AnswerFields(
id: question.id,
questionText: question.questionText,
answer: usersResponse,
sawThisComponent: 0,
sawThisQuestion: 0,
dismissedThisComponent: 0,
answeredThisQuestion: 1
)
)
)
]
)

await WebService().create(records: recordToSend, toURL: "https://api.airtable.com/v0/\(Constants.baseId)/\(Constants.answersTableId)")

dontShowThisQuestionAgain(question)
}

func sendSawThisComponent() async {
//make sure we have a question before trying to submit an ID
guard let question = question else {return}
let recordToSend: Records = Records(
records: [
Record(
id: nil, createdTime: nil, fields: Fields.answerFields(
AnswerFields(
id: question.id,
questionText: question.questionText,
answer: usersResponse,
sawThisComponent: 1,
sawThisQuestion: 0,
dismissedThisComponent: 0,
answeredThisQuestion: 0
)
)
)
]
)

await WebService().create(records: recordToSend, toURL: "https://api.airtable.com/v0/\(Constants.baseId)/\(Constants.answersTableId)")
}

func sendDismissedThisComponent() async {
//make sure we have a question before trying to submit an ID
guard let question = question else {return}
let recordToSend: Records = Records(
records: [
Record(
id: nil, createdTime: nil, fields: Fields.answerFields(
AnswerFields(
id: question.id,
questionText: question.questionText,
answer: usersResponse,
sawThisComponent: 0,
sawThisQuestion: 0,
dismissedThisComponent: 1,
answeredThisQuestion: 0
)
)
)
]
)

await WebService().create(records: recordToSend, toURL: "https://api.airtable.com/v0/\(Constants.baseId)/\(Constants.answersTableId)")

dontShowThisQuestionAgain(question)
}

func sendSawThisQuestion() async {
//make sure we have a question before trying to submit an ID
guard let question = question else {return}
let recordToSend: Records = Records(
records: [
Record(
id: nil, createdTime: nil, fields: Fields.answerFields(
AnswerFields(
id: question.id,
questionText: question.questionText,
answer: usersResponse,
sawThisComponent: 0,
sawThisQuestion: 1,
dismissedThisComponent: 0,
answeredThisQuestion: 0
)
)
)
]
)

await WebService().create(records: recordToSend, toURL: "https://api.airtable.com/v0/\(Constants.baseId)/\(Constants.answersTableId)")
}

func questionHasAlreadyBeenAnswered(_ question: QuestionFields?) -> Bool {
guard let question = question else {return false}
//check UserDefaults to see if the id has been saved
if UserDefaults.standard.string(forKey: question.id) != nil {
return true
}

return false
}

func dontShowThisQuestionAgain(_ question: QuestionFields?) {
guard let question = question else {return}

UserDefaults.standard.set(question.id, forKey: question.id)
}
}

// this extension helps us ensure we are decoding the question fields from our questions table
fileprivate extension Fields {
var question: QuestionFields? {
switch self {
case .questionFields(let record):
return record
default:
return nil
}
}
}

One thing to note here is that we are saving to and reading from UserDefaults. If you want to learn more about other ways of using UserDefaults in SwiftUI, feel free to check out this article where we use UserDefaults with the AppStorage wrapper: https://medium.com/@jpmtech/how-to-ask-a-user-to-review-your-app-in-swiftui-bab7a255197d

Creating a Server Driven UI

In its simplest terms, server driven UI just means that we will change what gets displayed to the user based on what we get from the server.

For our question component we want to display 3 different types of questions, a fill in the blank, a multiple choice, and a rating component. Each of these components takes a question that we declared above, and will use various pieces of the question object to display the correct data.

Below we have added the 3 types of questions that we want to display in the app. If you have another question type that you want to display, these can also be created here. The screenshots below each component are from our SurveyView (which we will create below); this is where all of the conditional display logic lives, and where we combine the question text and display options for each component.

//  FillInTheBlankView.swift
import SwiftUI

struct FillInTheBlankView: View {
@EnvironmentObject var vm: SurveyViewModel

var body: some View {
VStack(alignment: .leading) {
TextEditor(text: $vm.usersResponse)
.border(Color.secondary)
}
}
}

struct FillInTheBlankView_Previews: PreviewProvider {
static var previews: some View {
FillInTheBlankView()
.environmentObject(SurveyViewModel())
}
}
A screenshot of the sample fill in the blank view component
A screenshot from the SurveyView Preview
//  RatingView.swift
import SwiftUI

struct RatingView: View {
let numberOfChoices: Int
@EnvironmentObject var vm: SurveyViewModel
@State private var selectedChoice = 0

var body: some View {
HStack {
ForEach(1...numberOfChoices, id: \.self) { index in
Button {
selectedChoice = index
vm.usersResponse = String(selectedChoice)
} label: {
Image(systemName: "star")
.symbolVariant(index <= selectedChoice ? .fill : .none)
}
}
}
}
}

struct RatingView_Previews: PreviewProvider {
static var previews: some View {
RatingView(numberOfChoices: 5)
.environmentObject(SurveyViewModel())
}
}
A screenshot of the sample rating view component
A screenshot from the SurveyView Preview
//  MultipleChoiceSurveyView.swift
import SwiftUI

struct MultipleChoiceView: View {
let possibleAnswers: [String]
@EnvironmentObject var vm: SurveyViewModel

var body: some View {
VStack {
ForEach(possibleAnswers, id: \.self) { answer in
Button {
vm.usersResponse = answer
} label: {
Text(answer)
.frame(maxWidth: .infinity)
.foregroundColor(vm.usersResponse == answer ? .white : .blue)
}
.padding()
.background(vm.usersResponse == answer ? Color.accentColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(lineWidth: 1)
.foregroundColor(Color.accentColor)
)
.padding(.bottom, 4)
}
}
}
}

struct MultipleChoiceView_Previews: PreviewProvider {
static var previews: some View {
MultipleChoiceView(possibleAnswers: ["Answer 1", "Answer 2"])
.padding()
.environmentObject(SurveyViewModel())
}
}
A screenshot of the sample rating view component
A screenshot from the SurveyView Preview

You may have noticed that all of these question views have a binding to the userResponse string. That is because we want to pass the user’s response from these child views back to the view model so that we can send what the user said back to our AirTable database.

We put this data all together in a component we call the SurveyView. This view will display the correct view for each different type of question.

// SurveyView.swift
import SwiftUI

struct SurveyView: View {
let surveyQuestion: QuestionFields
@EnvironmentObject var vm: SurveyViewModel

var body: some View {
VStack(alignment: .leading) {
Text("Question")
.bold()

Text(surveyQuestion.questionText)
.padding(.bottom)

if let possibleAnswers = surveyQuestion.possibleAnswers,
surveyQuestion.questionType == QuestionType.multipleChoice {
MultipleChoiceView(possibleAnswers: possibleAnswers)
} else if surveyQuestion.questionType == QuestionType.fillInTheBlank {
FillInTheBlankView()
} else if surveyQuestion.questionType == QuestionType.rating {
RatingView(numberOfChoices: surveyQuestion.numberOfRatingChoices ?? 0)
} else {
FillInTheBlankView()
}

HStack {
Button("Cancel", role: .destructive) {
// close the modal
vm.shouldShowSurveyQuestion = false
}

Spacer()

Button("Submit") {
// post to the AirTable endpoint
Task {
await vm.sendAnswer()
}

// hide the survey button
vm.shouldShowRequestFeedbackButton = false

// close the modal
vm.shouldShowSurveyQuestion = false
}
.buttonStyle(.borderedProminent)
.disabled(vm.usersResponse.isEmpty)
}.padding(.top)
}
.padding()
.task {
await vm.sendSawThisQuestion()
}
}
}

struct SurveyView_Previews: PreviewProvider {
static var previews: some View {
// some of these preview are commented out so that you can see what each component looks like as a whole view
SurveyView(surveyQuestion: SampleQuestions.multipleChoice)
.environmentObject(SurveyViewModel())

// SurveyView(surveyQuestion: SampleQuestions.fillInTheBlank)
// .environmentObject(SurveyViewModel())

// SurveyView(surveyQuestion: SampleQuestions.rating)
// .environmentObject(SurveyViewModel())
}
}

Adding a Two-In-One Button to Display/Hide the Survey

Just pushing a survey in front of a user is not a great way to get good user feedback; we should invite the user to share their feedback first. So we will create a nice looking custom button that will invite the user to either share their feedback, or allow them to dismiss our request. I want this to appear to the user like a single button and have the ability to both launch the survey, or dismiss the request. I also want the button to stand out from the rest of the UI of the app, so we will create our own custom button from scratch.

//  ShareFeedbackButton.swift
import SwiftUI

struct ShareFeedbackButton: View {
let surveyQuestion: QuestionFields
@EnvironmentObject var vm: SurveyViewModel

var body: some View {
HStack {
Button {
// launch partial sheet that holds feedback component
vm.shouldShowSurveyQuestion.toggle()
} label: {
HStack {
Label("Share Your Feedback", systemImage: "message.fill")
Spacer()
}
}

Button {
Task {
await vm.sendDismissedThisComponent()
}
vm.shouldShowRequestFeedbackButton = false
} label: {
Image(systemName: "xmark")
}.padding(.leading)
}
.padding()
.foregroundColor(.white)
.background(
LinearGradient(colors: [.cyan, .blue, .purple], startPoint: .leading, endPoint: .trailing)
)
.clipShape(RoundedRectangle(cornerRadius: 7))
.sheet(isPresented: $vm.shouldShowSurveyQuestion) {
SurveyView(surveyQuestion: surveyQuestion)
.presentationDetents([.height(250), .medium, .large])
//presentationDetents allows us to have a sheet that only takes part of the height (or full sheet height with the .large option)
}
.task {
await vm.sendSawThisComponent()
}
}
}

struct FeedbackButton_Previews: PreviewProvider {
static var previews: some View {
ShareFeedbackButton(surveyQuestion: SampleQuestions.fillInTheBlank)
.padding()
.environmentObject(SurveyViewModel())
}
}
A screenshot of the Share Your Feedback Button

Now that we have everything ready in order to make our survey, let’s add the component to our app.

Displaying our survey in the app

One thing to note is that we are passing the view model in the app as an environment object. Architecting the app in this way allows us to keep the survey logic contained to the sections that need it, without having to pass the view model around to every screen that may or may not use it (referred to as prop drilling in other languages).

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@StateObject var vm = SurveyViewModel()

var body: some View {
ZStack(alignment: .bottom) {
VStack {
Spacer()
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
Spacer()
}

if vm.shouldShowRequestFeedbackButton && vm.question != nil {
ShareFeedbackButton(surveyQuestion: vm.surveyData.first ?? SampleQuestions.fillInTheBlank)
}
}
.padding()
.task {
if vm.surveyData.isEmpty {
await vm.fetchData()
}
}
.environmentObject(vm)
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(SurveyViewModel())
}
}

If you got value from this article, please consider following me, clapping for this article, or sharing it to help others more easily find it.

If you have any questions on the topic, or know of another way to accomplish the same task, feel free to respond to the post or share it with a friend and get their opinion on it.

If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@jpmtech

If you want to see apps that have been built with native mobile development, you can check out my apps here: https://jpmtech.io/apps

Thank you for taking the time to check out my work!

--

--