SwiftUI Views for Exit Poll surveys
Overview
We provide SwiftUI views for each of the question types that can be in an ExitPoll survey. You can create your own custom classes using these SwiftUI views as reference.
This documentation page has more information on ExitPolls on the dashboard. This page has information on creating and managing ExitPolls for your project.
TODO: Add images to each of the ExitPoll question type sections.
Boolean question
/// View for presenting a boolean question with "True" and "False" buttons.
public struct BooleanQuestionView: View {
@Binding public var answer: Bool? // Binding to the answer state (nil = no selection)
public let title: String // Title of the question
/// Initializes the `BooleanQuestionView` with the answer binding and title.
/// - Parameters:
/// - answer: A binding to the boolean answer value, or nil for no selection.
/// - title: The title of the question.
public init(answer: Binding<Bool?>, title: String) {
self._answer = answer
self.title = title
}
public var body: some View {
VStack {
// Display the question title
Text(title)
.font(.headline)
.padding(.top)
// Buttons for "True" and "False"
HStack {
Button(action: { answer = true }) {
Text("True")
.padding()
.frame(maxWidth: .infinity)
.background(answer == true ? Color.blue : Color.gray.opacity(0.2))
.foregroundColor(.white)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(answer == true ? Color.blue : Color.clear, lineWidth: answer == true ? 4 : 0)
)
}
Button(action: { answer = false }) {
Text("False")
.padding()
.frame(maxWidth: .infinity)
.background(answer == false ? Color.red : Color.gray.opacity(0.2))
.foregroundColor(.white)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(answer == false ? Color.red : Color.clear, lineWidth: answer == false ? 4 : 0)
)
}
}
.padding()
}
}
}
Happy/Sad question
/// View for presenting a happy/sad question.
public struct HappySadQuestionView: View {
@Binding public var answer: Bool? // Binding to the answer state (nil = no selection)
public let title: String // Title of the question
/// Initializes the `HappySadQuestionView` with the answer binding and title.
/// - Parameters:
/// - answer: A binding to the boolean answer value, or nil for no selection.
/// - title: The title of the question.
public init(answer: Binding<Bool?>, title: String) {
self._answer = answer
self.title = title
}
public var body: some View {
VStack {
// Display the question title
Text(title)
.font(.headline)
.padding(.top)
// Buttons for "Happy" and "Sad" faces
HStack {
Button(action: { answer = true }) {
Text("😊").font(.largeTitle)
.overlay(
Circle()
.stroke(answer == true ? Color.green : Color.clear, lineWidth: answer == true ? 4 : 0)
)
}
Button(action: { answer = false }) {
Text("😢").font(.largeTitle)
.overlay(
Circle()
.stroke(answer == false ? Color.red : Color.clear, lineWidth: answer == false ? 4 : 0)
)
}
}
.padding()
}
}
}
Thumbs Up/Thumbs Down question
/// View for presenting a thumbs-up/thumbs-down question.
public struct ThumbsQuestionView: View {
@Binding public var answer: Bool? // Binding to the answer state (nil = no selection)
public let title: String // Title of the question
/// Initializes the `ThumbsQuestionView` with the answer binding and title.
/// - Parameters:
/// - answer: A binding to the boolean answer value, or nil for no selection.
/// - title: The title of the question.
public init(answer: Binding<Bool?>, title: String) {
self._answer = answer
self.title = title
}
public var body: some View {
VStack {
// Display the question title
Text(title)
.font(.headline)
.padding(.top)
// Buttons for "Thumbs Up" and "Thumbs Down"
HStack {
Button(action: { answer = true }) {
Text("👍").font(.largeTitle)
.overlay(
Circle()
.stroke(answer == true ? Color.green : Color.clear, lineWidth: answer == true ? 4 : 0)
)
}
Button(action: { answer = false }) {
Text("👎").font(.largeTitle)
.overlay(
Circle()
.stroke(answer == false ? Color.red : Color.clear, lineWidth: answer == false ? 4 : 0)
)
}
}
.padding()
}
}
}
Multiple choice question
/// View for presenting a multiple-choice question as a list of buttons.
public struct MultipleChoiceQuestionView: View {
@Binding public var selectedAnswer: String? // Binding to the selected answer (optional)
public let title: String // Title of the question
public let answers: [String] // List of possible answers
private var shouldDisplayClearButton = false
/// Initializes the `MultipleChoiceQuestionView` with the answer binding, title, and answers.
/// - Parameters:
/// - selectedAnswer: A binding to the selected answer.
/// - title: The title of the question.
/// - answers: The list of possible answers.
public init(selectedAnswer: Binding<String?>, title: String, answers: [String]) {
self._selectedAnswer = selectedAnswer
self.title = title
self.answers = answers
}
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Display the question title
Text(title)
.font(.headline)
.padding(.bottom)
// Render answers as a list of buttons
ForEach(answers, id: \.self) { answer in
Button(action: {
selectedAnswer = answer
}) {
Text(answer)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedAnswer == answer ? Color.blue : Color.gray.opacity(0.2))
.foregroundColor(selectedAnswer == answer ? .white : .black)
.cornerRadius(8)
}
}
// Add a "Clear Selection" button if an answer is selected
if shouldDisplayClearButton && selectedAnswer != nil {
Button("Clear Selection") {
selectedAnswer = nil
}
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(Color.red.opacity(0.2))
.foregroundColor(.red)
.cornerRadius(8)
}
}
.padding()
}
}
Scale question
/// A view for presenting a scale question with a slider.
/// Displays min and max numeric values along with corresponding labels.
public struct ScaleQuestionView: View {
@Binding public var answer: Int // Binding to the slider value
public let title: String // Title of the question
public let min: Int // Minimum value for the slider
public let max: Int // Maximum value for the slider
public let minLabel: String // Label for the minimum value
public let maxLabel: String // Label for the maximum value
/// Initializes the `ScaleQuestionView` with the answer binding, title, and range details.
/// - Parameters:
/// - answer: A binding to the current value of the slider.
/// - title: The title of the question to be displayed.
/// - min: The minimum numeric value of the slider.
/// - max: The maximum numeric value of the slider.
/// - minLabel: A descriptive label for the minimum value.
/// - maxLabel: A descriptive label for the maximum value.
public init(answer: Binding<Int>, title: String, min: Int, max: Int, minLabel: String, maxLabel: String) {
self._answer = answer
self.title = title
self.min = min
self.max = max
self.minLabel = minLabel
self.maxLabel = maxLabel
}
public var body: some View {
VStack {
// Title of the question
Text(title)
.font(.headline)
.padding(.bottom)
// Slider for numeric selection
Slider(
value: Binding(
get: { Double(answer) },
set: { newValue in answer = Int(newValue) }
),
in: Double(min)...Double(max),
step: 1
)
.padding(.horizontal)
// Min/Max numeric values with descriptive labels
HStack {
VStack(alignment: .leading) {
Text("\(min)")
.font(.subheadline)
Text(minLabel)
.font(.caption)
.foregroundColor(.white)
}
// Display the current slider value
if (answer != noAnswerSet) {
Spacer()
Text("Current Value: \(answer)")
.font(.subheadline)
.foregroundColor(.white)
}
Spacer()
VStack(alignment: .trailing) {
Text("\(max)")
.font(.subheadline)
Text(maxLabel)
.font(.caption)
.foregroundColor(.white)
}
}
.padding(.horizontal)
}
.padding()
.frame(maxWidth: 400) // Constrain width for consistent layout
.background(.regularMaterial.opacity(0.85)) // Semi-transparent background
.cornerRadius(12) // Rounded corners
.shadow(radius: 4) // Subtle shadow for depth
}
}
Voice Question
/// A view that presents a voice recording question for exit polls
public struct VoiceQuestionView: View {
@EnvironmentObject var viewModel: ExitPollSurveyViewModel
@StateObject private var audioRecorder = SurveyAudioRecorder()
@State private var recordingCompleted = false
@State private var showingPermissionAlert = false
@State private var isPlaying = false
// Time limit for the recording
@State public var recordingTimeLimit: TimeInterval = 10.0
// Binding to enable the confirm button in parent view
@Binding public var isConfirmButtonEnabled: Bool
// Question properties
public let questionIndex: Int
public let title: String
// Public initializer
public init(isConfirmButtonEnabled: Binding<Bool>, questionIndex: Int, title: String) {
self._isConfirmButtonEnabled = isConfirmButtonEnabled
self.questionIndex = questionIndex
self.title = title
}
public var body: some View {
VStack(spacing: 20) {
Text(title)
.font(.headline)
.multilineTextAlignment(.center)
.padding(.horizontal)
if recordingCompleted {
// Recording completed view
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 70))
.foregroundColor(.green)
Text("Voice Recording Completed")
.font(.title3)
.fontWeight(.medium)
HStack(spacing: 20) {
// Play recording button
Button(action: {
if isPlaying {
// Stop playback if already playing
audioRecorder.stopPlayback()
isPlaying = false
} else if let lastRecording = audioRecorder.recordings.first,
let url = lastRecording.fileURL {
// Start playback
audioRecorder.playRecording(url: url)
isPlaying = true
// Auto-reset playing state after duration
if let duration = lastRecording.duration {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
isPlaying = false
}
}
}
}) {
Label(isPlaying ? "Stop" : "Play",
systemImage: isPlaying ? "stop.circle.fill" : "play.circle.fill")
.font(.headline)
}
.buttonStyle(.borderedProminent)
// Record again button
Button(action: {
recordingCompleted = false
isConfirmButtonEnabled = false
// Delete existing recording before recording again
if let recordingId = audioRecorder.recordings.first?.id {
audioRecorder.deleteRecording(id: recordingId)
}
}) {
Label("Record Again", systemImage: "mic.circle.fill")
.font(.headline)
}
.buttonStyle(.bordered)
}
}
.padding()
} else {
// Recording controls
VStack(spacing: 16) {
if audioRecorder.isRecording {
// Recording in progress UI
Text(audioRecorder.recordingTime.formattedString)
.font(.system(size: 60, weight: .bold, design: .monospaced))
.foregroundColor(.red)
// Time remaining indicator
ProgressView(value: audioRecorder.recordingTime, total: recordingTimeLimit)
.progressViewStyle(.linear)
.frame(width: 240)
.padding(.vertical)
Text("Time Remaining: \((recordingTimeLimit - audioRecorder.recordingTime).formattedString)")
.font(.caption)
.foregroundColor(.secondary)
// Auto-stop recording when time limit is reached
.onChange(of: audioRecorder.recordingTime) { _, newValue in
if newValue >= recordingTimeLimit {
stopRecording()
// Notify time's up
NotificationCenter.default.post(name: Notification.Name("OnMicrophoneRecordingTimeUp"), object: nil)
}
}
} else {
// Instructions before recording
Text("Tap the button below to start recording your answer")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Text("Maximum recording time: \(recordingTimeLimit.formattedString)")
.font(.caption)
.foregroundColor(.secondary)
}
// Record/Stop button
Button(action: {
if audioRecorder.isRecording {
stopRecording()
} else {
startRecording()
}
}) {
ZStack {
Circle()
.fill(audioRecorder.isRecording ? Color.red.opacity(0.8) : Color.red)
.frame(width: 80, height: 80)
if audioRecorder.isRecording {
RoundedRectangle(cornerRadius: 6)
.fill(Color.white)
.frame(width: 30, height: 30)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 40))
.foregroundColor(.white)
}
}
}
.accessibilityLabel(audioRecorder.isRecording ? "Stop recording" : "Start recording")
.padding()
}
}
}
.padding(.vertical)
.alert("Microphone Access", isPresented: $showingPermissionAlert) {
Button("Cancel", role: .cancel) { }
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
} message: {
Text("Please allow microphone access in Settings to record audio.")
}
// Error alert
.alert(
"Recording Error",
isPresented: $audioRecorder.showErrorAlert,
presenting: audioRecorder.recordingError
) { _ in
Button("OK") {
audioRecorder.clearError()
}
} message: { error in
Text(error.errorDescription ?? "An unknown error occurred")
}
// Clean up when view disappears
.onDisappear {
cleanupRecordings()
}
}
// Function to start recording
private func startRecording() {
// Clear any existing recordings first
cleanupRecordings()
Task {
let granted = await audioRecorder.requestPermission()
if granted {
await audioRecorder.startRecording(forQuestionIndex: questionIndex)
} else {
showingPermissionAlert = true
}
}
}
// Function to stop recording and process the result
private func stopRecording() {
// First stop the recording to create the file
audioRecorder.stopRecording()
// Add a small delay to ensure file is fully written
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in
// Save recording info immediately without waiting for fetch
self.audioRecorder.saveRecordingInfo()
// Get the base64 encoding and update the view model
if let base64Audio = self.audioRecorder.getLastRecordingAsBase64() {
print("Successfully obtained base64 audio data")
// Update the ExitPollSurveyViewModel with the voice recording
self.viewModel.updateMicrophoneAnswer(for: self.questionIndex, with: base64Audio)
self.recordingCompleted = true
self.isConfirmButtonEnabled = true // Enable the confirm button in parent view
} else {
// Handle the case where we couldn't get the base64 audio
print("Failed to obtain base64 audio data")
// Try again after another short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in
if let base64Audio = self.audioRecorder.getLastRecordingAsBase64() {
print("Second attempt succeeded in getting base64 audio")
self.viewModel.updateMicrophoneAnswer(for: self.questionIndex, with: base64Audio)
self.recordingCompleted = true
self.isConfirmButtonEnabled = true
} else {
print("Second attempt also failed to get base64 audio")
}
}
}
}
}
// Function to clean up all recordings when leaving the view
private func cleanupRecordings() {
// Stop any ongoing recording or playback
if audioRecorder.isRecording {
audioRecorder.stopRecording()
}
// Stop any ongoing playback
audioRecorder.stopPlayback()
isPlaying = false
// Only keep the recording if it's completed and the answer is set
if !recordingCompleted {
// Delete all recordings except the one we want to keep
for recording in audioRecorder.recordings {
audioRecorder.deleteRecording(id: recording.id)
}
}
}
}
If you have a question or any feedback about our documentation please use the Intercom button (purple circle) in the lower right corner of any web page or join our Discord.