Skip to content

Latest commit

 

History

49 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SwiftUI-Surveys

A Swift package that provides an elegant and customizable survey interface for iOS applications using SwiftUI.
SwiftUI-Surveys makes it easy to create professional-looking surveys with a modern design and smooth user experience.

Single-choice survey question shown in a diagonal light and dark mode split Custom SwiftUI content step in dark mode Multiple-choice survey question with three answers selected Discovery-source survey with eight options, real app icons, and Other Purple-accent survey question showing the Other text field

Features

  • 📊 Native progress indicator for survey progress
  • 🧭 Mix questions with non-question content steps
  • ✅ Support for single and multiple choice questions
  • 🔄 Seamless navigation between questions with back/next functionality
  • ✨ Automatic answer validation
  • 🎯 Completion handling
  • 🎨 Clean and modern UI with smooth animations
  • 🌍 Full localization support with LocalizedStringKey
  • 📱 Fully SwiftUI native

Requirements

  • iOS 17.0+
  • Swift 6+
  • Xcode 16.0+

Installation

Swift Package Manager

Add SwiftUI-Surveys to your project through Xcode:

  1. File > Add Packages
  2. Enter package URL: https://github.com/Sedlacek-Solutions/SwiftUI-Surveys.git
  3. Select version requirements
  4. Click Add Package

Usage

Creating Survey Questions

let questions = [
    SurveyQuestion(
        titleKey: "How would you describe your experience?",
        answers: [
            .init(titleKey: "Beginner"),
            .init(titleKey: "Intermediate"),
            .init(titleKey: "Advanced")
        ],
        isMultipleChoice: false
    ),
    SurveyQuestion(
        titleKey: "What features interest you?",
        answers: [
            .init(titleKey: "Analytics"),
            .init(titleKey: "Reporting"),
            .init(titleKey: "Sharing"),
            .init(titleKey: "Export")
        ],
        isMultipleChoice: true
    )
]

Using System Images

Answers can include an optional SF Symbol to show alongside the label:

let questions = [
    SurveyQuestion(
        titleKey: "How would you describe your experience?",
        answers: [
            .init(titleKey: "Beginner", systemImage: "figure.walk"),
            .init(titleKey: "Intermediate", systemImage: "figure.hiking"),
            .init(titleKey: "Advanced", systemImage: "figure.run")
        ]
    ),
    SurveyQuestion(
        titleKey: "What features interest you?",
        answers: [
            .init(titleKey: "Analytics", systemImage: "chart.bar"),
            .init(titleKey: "Reporting", systemImage: "doc.text.magnifyingglass"),
            .init(titleKey: "Sharing", systemImage: "square.and.arrow.up"),
            .init(titleKey: "Export", systemImage: "arrow.down.doc")
        ],
        isMultipleChoice: true
    )
]

Branded Discovery-Source Questions

Use SurveyDiscoveryQuestion for a required, single-choice “Where did you hear about us?” screen. Pass only the presets you want and keep their order under your control:

let discoveryQuestion = SurveyDiscoveryQuestion(
    id: "discovery-source",
    titleKey: "How did you hear about us?",
    sources: [
        .appStore,
        .instagram,
        .tiktok,
        .youtube,
        .referral,
        .other
    ]
)

SurveyFlow(
    flowSteps: [
        .discoveryQuestion(discoveryQuestion)
    ],
    onAnswer: { question, answers in
        // Uses the same SurveyQuestion and SurveyAnswer callback as standard questions.
        trackSource(questionID: question.id, sourceID: answers.first?.id)
    },
    nextStep: { step, answers in
        let instagram = SurveyDiscoverySource.instagram.answer
        if answers[discoveryQuestion.question]?.contains(instagram) == true {
            return .step(id: "social-follow-up")
        }
        return .next
    }
)

SurveyDiscoverySource.allPresets contains App Store, Google, Instagram, TikTok, YouTube, Facebook, X, Reddit, Threads, LinkedIn, Product Hunt, referral, podcast, article/blog, and Other. Selecting Other reveals a required free-text field.

App presets use bundled, full-color App Store artwork. Generic referral sources use SF Symbols, and selection borders and checkmarks continue to follow surveyAccentColor.

Add an unsupported source with a stable ID, a localization key, and any SwiftUI icon:

let newsletter = SurveyDiscoverySource.custom(
    id: "newsletter",
    titleKey: "survey.source.newsletter"
) {
    Image("newsletter-mark")
        .resizable()
        .scaledToFit()
}

Custom source titles resolve from the textBundle passed to SurveyFlow. Discovery questions are runtime-only because custom icons can contain arbitrary SwiftUI views; existing Codable SurveyQuestion and SurveyStep formats are unchanged.

Localization Support

SwiftUI-Surveys supports localization using LocalizedStringKey. You can provide localized strings for both questions and answers:

let questions = [
    SurveyQuestion(
        titleKey: "survey.question.experience",
        answers: [
            .init(titleKey: "survey.answer.beginner", systemImage: "figure.walk"),
            .init(titleKey: "survey.answer.intermediate", systemImage: "figure.hiking"),
            .init(titleKey: "survey.answer.advanced", systemImage: "figure.run")
        ]
    )
]

Add your localized strings to your Localizable.strings file:

// English (en)
"survey.question.experience" = "How would you describe your experience?";
"survey.answer.beginner" = "Beginner";
"survey.answer.intermediate" = "Intermediate";
"survey.answer.advanced" = "Advanced";

// Spanish (es)
"survey.question.experience" = "¿Cómo describirías tu experiencia?";
"survey.answer.beginner" = "Principiante";
"survey.answer.intermediate" = "Intermedio";
"survey.answer.advanced" = "Avanzado";

Included Localizations

The package ships translations for the built-in UI strings (Back, Continue, Next, Other, Select all that apply) in these locales:

  • English (en)
  • Spanish (es)
  • French (fr)
  • German (de)
  • Italian (it)
  • Portuguese (Brazil) (pt-BR)
  • Japanese (ja)
  • Korean (ko)
  • Chinese (Simplified) (zh-Hans)
  • Chinese (Traditional) (zh-Hant)

When using the provided LocalizedStringKey helpers (for example .back, .next), use Text(..., bundle: .module) so the keys resolve from the package bundle.

Custom Accent Color

Use surveyAccentColor(_:) to theme the built-in survey controls:

SurveyFlow(questions: questions)
    .surveyAccentColor(.purple)

The modifier updates the built-in blue survey UI, including answer icons, selected borders, checkmarks, progress, content-step symbols, and the primary action button. Semantic colors, such as success states, keep their original meaning.

Custom Step Animation

Survey screens animate in by default when the user moves between steps. Built-in question and content screens show the title first, then cascade answers or content items into place.

Use surveyFlowAnimation(_:) to tune or disable the entrance animation:

SurveyFlow(questions: questions)
    .surveyFlowAnimation(.disabled)
SurveyFlow(questions: questions)
    .surveyFlowAnimation(
        .init(
            progressAnimation: .smooth(duration: 0.56, extraBounce: 0),
            itemBaseDelay: 0.1,
            itemDelay: 0.04,
            verticalOffset: 8
        )
    )

Custom content steps receive the outer step entrance animation, while their internal layout remains fully controlled by your custom SwiftUI view.

Adding Custom Content Steps

Use SurveyFlowStep when your flow needs screens that are not questions, such as onboarding, education, charts, product previews, or any other custom SwiftUI content:

let steps: [SurveyFlowStep] = [
    .custom {
        VStack(spacing: 24) {
            Text("Designed to help you stay on track")
                .font(.largeTitle.weight(.semibold))
                .frame(maxWidth: .infinity, alignment: .leading)

            CustomChartView()

            Text("Track your habits and stay consistent over time.")
                .font(.title3)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
        }
    },
    .question(
        .init(
            titleKey: "What best describes your goal?",
            answers: [
                .init(titleKey: "Build consistency", systemImage: "calendar"),
                .init(titleKey: "Understand trends", systemImage: "chart.xyaxis.line"),
                .init(titleKey: "Stay accountable", systemImage: "checkmark.circle")
            ]
        )
    ),
    .custom(isScrollable: false) {
        CustomFullScreenSummaryView()
    }
]

SurveyFlow(
    flowSteps: steps,
    onFlowStepContinue: { step in
        // Track every submitted step, including custom content screens
        print("Continued from step: \(step.id)")
        print("Step type: \(step.stepType)")
    },
    onAnswer: { question, answers in
        // Called only for question steps
        print("Question: \(question.title)")
        print("Selected answers: \(answers)")
    },
    onCompletion: { allAnswers in
        // Contains only question answers, not content steps
        processSurveyResults(allAnswers)
    }
)

Use flowSteps: for runtime-only custom SwiftUI screens. Use steps: for Codable SurveyStep arrays that can be loaded from JSON.

By default, .custom wraps your content in the same scroll container as other content screens. Pass isScrollable: false when your custom view owns its own scrolling or fixed layout.

Custom steps can replace the package Continue button with app-owned bottom content. Both builders receive a SurveyFlowContinuation; call advance() only after app-owned work succeeds:

.custom(id: "privacy") { _ in
    PrivacyExplanationView()
} footer: { continuation in
    SignInWithAppleButton(.continue) { request in
        configure(request)
    } onCompletion: { result in
        Task { @MainActor in
            guard await authenticate(with: result) else { return }
            continuation.advance()
        }
    }
    .frame(height: 50)
}

The custom footer stays inside the flow's bottom safe-area inset, while the package continues to own the toolbar, Back button, animated progress, and total step count. Use continuation.goBack() for an app-owned Back action. If a screen owns its entire layout instead of providing a bottom section, use .custom(id: "privacy", footer: .hidden) { continuation in ... }.

Branching Between Steps

Flows advance linearly by default. Pass nextStep when the next screen should depend on the submitted step or the answers collected so far:

let quickAnswer = SurveyAnswer(id: "quick", titleKey: "Keep it short")
let detailedAnswer = SurveyAnswer(id: "detailed", titleKey: "Show me more")
let setupQuestion = SurveyQuestion(
    id: "setup-depth",
    titleKey: "How much setup guidance do you want?",
    answers: [quickAnswer, detailedAnswer]
)

let steps: [SurveyFlowStep] = [
    .question(setupQuestion),
    .custom(id: "details") {
        DetailedSetupView()
    },
    .custom(id: "summary") {
        SummaryView()
    }
]

SurveyFlow(
    flowSteps: steps,
    nextStep: { step, answers in
        guard step.question?.id == setupQuestion.id else {
            return .next
        }

        if answers[setupQuestion, default: []].contains(quickAnswer) {
            return .step(id: "summary")
        }

        return .next
    },
    onCompletion: { allAnswers in
        processSurveyResults(allAnswers)
    }
)

nextStep returns SurveyFlowNavigation.next, .step(id:), or .complete. Back navigation follows the user's actual path, so if a branch skips a screen, Back returns to the previous visited screen. Completion results include answers from the completed path, so answers from abandoned branches are not returned.

Custom content steps are runtime-only because SwiftUI views cannot be encoded to JSON. Use SurveyStep and SurveyContentStep when you need Codable, runtime-loaded survey definitions:

let codableSteps: [SurveyStep] = [
    .content(
        .init(
            titleKey: "Welcome",
            bodyKey: "This screen can be loaded from JSON.",
            media: .systemImage("sparkles")
        )
    ),
    .question(
        .init(
            titleKey: "How would you describe your experience?",
            answers: [
                .init(titleKey: "Beginner"),
                .init(titleKey: "Intermediate"),
                .init(titleKey: "Advanced")
            ]
        )
    )
]

JSON Support

SurveyStep, SurveyContentStep, SurveyContentMedia, SurveyQuestion, and SurveyAnswer conform to Codable, so you can serialize data-driven surveys to JSON and load them back at runtime:

import Surveys

let steps: [SurveyStep] = [
    .content(.init(titleKey: "Welcome")),
    .question(
        .init(
            titleKey: "How would you describe your experience?",
            answers: [
                .init(titleKey: "Beginner"),
                .init(titleKey: "Intermediate"),
                .init(titleKey: "Advanced")
            ]
        )
    )
]

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(steps)

let decoder = JSONDecoder()
let decoded = try decoder.decode([SurveyStep].self, from: data)

Basic Implementation

import Surveys
import SwiftUI

struct ContentView: View {
    let questions: [SurveyQuestion] = .mock()

    var body: some View {
        SurveyFlow(
            questions: questions,
            onAnswer: { question, answers in
                // Handle each question's answers immediately when user submits
                print("Question: \(question.title)")
                print("Selected answers: \(answers)")
                
                // Access answer titles
                for answer in answers {
                    print("Answer: \(answer.title)")
                }
            },
            onCompletion: { allAnswers in
                // Handle survey completion with all questions and answers
                print("Survey completed!")
                print("Total questions answered: \(allAnswers.count)")
                
                // Process all answers together
                for (question, answers) in allAnswers {
                    print("Question: \(question.title)")
                    print("Answers: \(answers.map { $0.title })")
                }
                
                // Example: Serialize to JSON and upload
                // let jsonData = encodeToJSON(allAnswers)
                // uploadSurveyResults(jsonData)
            }
        )
    }
}

Handling Only Final Results

If you don't need to process answers individually as they're submitted, you can omit the onAnswer parameter and only handle the final results:

struct ContentView: View {
    let questions: [SurveyQuestion] = .mock()

    var body: some View {
        SurveyFlow(questions: questions, onCompletion: { allAnswers in
            // Handle all answers at once when survey is complete
            processSurveyResults(allAnswers)
        })
    }
    
    func processSurveyResults(_ answers: [SurveyQuestion: Set<SurveyAnswer>]) {
        // Process or serialize all answers together
        print("Survey completed with \(answers.count) questions answered")
    }
}

Handling Back From The First Question

Use onBack when the first question's Back button should dismiss the survey or return to a previous app screen:

SurveyFlow(
    questions: questions,
    onBack: {
        dismiss()
    },
    onCompletion: { allAnswers in
        processSurveyResults(allAnswers)
    }
)

Skip Button Example

import Surveys
import SwiftUI

@MainActor
struct ContentScreen {
    private func skipAction() {
        // TODO: skip the survey
    }
}

extension ContentScreen: View {
    var body: some View {
        NavigationStack {
            SurveyFlow(questions: .mock())
                .toolbar(content: toolbarContent)
        }
    }

    @ToolbarContentBuilder
    private func toolbarContent() -> some ToolbarContent {
        ToolbarItem(
            placement: .confirmationAction,
            content: skipButton
        )
    }

    private func skipButton() -> some View {
        Button(action: skipAction) {
            Text(.skip, bundle: .module)
        }
    }
}

@MainActor
extension LocalizedStringKey {
    static let skip = LocalizedStringKey("Skip")
}

#Preview {
    ContentScreen()
}

About

SwiftUI Surveys

Topics

Resources

Stars

124 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages