Skip to content
13 min read Guides Swift

SwiftUI Feedback Board Tutorial: Swift SDK 1.1

This tutorial covers Swift SDK 1.1, on its 1.1.1 release. If you are starting today, use the current SwiftUI tutorial, which covers the latest release. The changes in this line are in What's New in Swift SDK 1.1 and What's New in Swift SDK 1.1.1. Use 1.1.1: the original 1.1 release does not compile.

By the end of this guide your iPhone, iPad, Mac or Vision Pro app has a screen where people read the requests others have filed, vote for the ones they want, comment and file their own, without leaving the app. The screens are SwiftUI views that come with the package, in eleven languages. Your part is the package, one configure call and a decision about where the board sits.

The short version

  • Add the package with Swift Package Manager, Up to Next Minor Version from 1.1.1. It needs iOS 26, macOS 26 or visionOS 26.
  • Call SwiftlyFeedback.configure(environment: .production, key:) in your App initialiser.
  • Show FeedbackListView in a sheet or a tab. The submit form, the detail screen and voting come with it.
  • Pass a user id, an email and each user's MRR, so votes carry weight and status emails reach people.
  • Theme it with SwiftlyFeedback.theme and switch parts on and off with SwiftlyFeedback.config.

What you need

The package supports iOS 26, macOS 26 and visionOS 26 or later, with no watchOS or tvOS target. Build with Xcode 26. You also need a FeedbackKit project and its API key, which every plan includes, Free too. Create a project in the web dashboard or the admin app for iPhone, iPad or Mac, and copy the key from the project's settings. Keys start with sf_.

The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.

1. Install the package

In Xcode, choose File > Add Package Dependencies and paste the repository URL:

https://github.com/Swiftly-Developed/SwiftlyFeedbackKit-Swift-SDK.git

Choose Up to Next Minor Version and type 1.1.1 as the lower bound. Up to Next Major from 1.1.1 now resolves 1.3.0, where three lines in this tutorial do not compile: buttons.segmentedControl became buttons.statusFilter, and status and category colours became ThemeColor values in 1.2. Add the SwiftlyFeedbackKit library to your app target. The package has no third-party dependencies, and your app can compile in Swift 6 language mode while the package uses Swift 5 mode for its own sources.

2. Configure the SDK

Configure once, in your App initialiser, before any FeedbackKit view appears:

import SwiftUI
import SwiftlyFeedbackKit

@main
struct TrailmateApp: App {
    init() {
        SwiftlyFeedback.configure(environment: .production, key: "sf_your_api_key")
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Use .production in every build, DEBUG included. A key belongs to the server it was created on, and projects made in the dashboard or the admin apps live on production. The other cases point elsewhere:

EnvironmentServer
.productionFeedbackKit's production server, where your project lives
.testflightFeedbackKit's staging server
.developmentFeedbackKit's development server
.localA FeedbackKit server on your own Mac, at localhost:8080

For the same reason, prefer this explicit call to configureAuto(keys:), which sends DEBUG builds to the development server and TestFlight builds to staging.

The key travels as an X-API-Key header. It is a client key for one project, made to ship inside an app, and anyone who unpacks your binary can read it. It covers end-user actions such as reading, filing, voting and commenting. To keep it out of your repository, put it in an .xcconfig file that git ignores, expose it through Info.plist, and read it with Bundle.main.object(forInfoDictionaryKey:).

configure returns at once and finishes in the background. It works out a user id, then publishes the client as SwiftlyFeedback.shared. The views read shared when they are created. The testing section shows how to handle a board that is on screen at launch.

3. Show the board with FeedbackListView

The smallest useful integration is a button that opens the list in a sheet:

import SwiftUI
import SwiftlyFeedbackKit

struct ContentView: View {
    @State private var showingIdeas = false

    var body: some View {
        Button("Suggest a feature", systemImage: "lightbulb") {
            showingIdeas = true
        }
        .sheet(isPresented: $showingIdeas) {
            FeedbackListView()
        }
    }
}

That one view gives you:

  • A card per request with its status, category, title, the start of its description, the comment count and a vote button.
  • A Filter menu in the toolbar that sorts by votes, newest, oldest or comment count. The list starts sorted by votes.
  • Pull to refresh on iPhone and iPad, and Command-R on the Mac.
  • A plus button that opens the submit form, and an empty state that invites the opening request.
  • Its own NavigationStack, so tapping a card pushes the detail screen.
  • Text in Arabic, Brazilian Portuguese, Chinese (Simplified), English, French, German, Italian, Japanese, Korean, Russian and Spanish, following the device language, and VoiceOver labels on the cards and controls.

The same menu has a Status picker. In this version, choosing a status sends the filter in a form the server does not route, and the list shows an error instead of the filtered requests. Until that changes, hide the picker:

SwiftlyFeedback.config.buttons.segmentedControl.display = false

4. The submit form

The list's plus button presents SubmitFeedbackView in a sheet and reloads the list when it closes. You can also present the form by itself, for example from a "Report a problem" row in Settings:

struct ReportProblemRow: View {
    @State private var showingForm = false

    var body: some View {
        Button("Report a problem", systemImage: "exclamationmark.bubble") {
            showingForm = true
        }
        .sheet(isPresented: $showingForm) {
            SubmitFeedbackView {
                // Runs after a successful submit, and after Cancel.
            }
        }
    }
}

The form has a title, a description, a category picker (Feature Request, Bug Report, Improvement or Other) and an optional email field, which starts filled in with config.userEmail when you have set it. Once an email is typed, a "Subscribe to mailing list" toggle appears, with sub-toggles for operational and marketing emails. Cancel and Submit sit in the toolbar, Submit stays disabled until the title and description are filled in, and on the Mac Command-Return submits.

If filing requests should be a perk, turn it off and say why. The plus button then shows your message, and everyone can still read and vote:

SwiftlyFeedback.config.allowFeedbackSubmission = false
SwiftlyFeedback.config.feedbackSubmissionDisabledMessage = "Trailmate Plus members can suggest new features."

Here is how the board looks in FeedbackKit, with sample data from Trailmate:

One board on every screen

SwiftUI on iPhone, iPad and Mac, Jetpack Compose on Android, plus Flutter and React Native.

5. The detail screen

Tapping a card pushes FeedbackDetailView. It shows the status and category badges, the title, the full description and when it was submitted, a vote button with its count, and the comments, with a field for adding one. Comments from your team are labelled Team. A Rejected request shows the reason you gave under "Reason for Rejection".

You can push it yourself. It takes a Feedback value and expects to be inside your NavigationStack. Here Trailmate's home screen surfaces the most-voted Approved request. It loads every request and picks in memory, which avoids the server-side status filter mentioned above:

struct HomeView: View {
    @State private var mostWanted: Feedback?

    var body: some View {
        List {
            if let mostWanted {
                Section("Most wanted") {
                    NavigationLink {
                        FeedbackDetailView(feedback: mostWanted)
                    } label: {
                        LabeledContent(mostWanted.title) {
                            Text(mostWanted.voteCount, format: .number)
                        }
                    }
                }
            }
            // The rest of your home screen.
        }
        .task {
            guard let client = SwiftlyFeedback.shared else { return }
            let all = (try? await client.getFeedback()) ?? []
            mostWanted = all
                .filter { $0.status == .approved }
                .max { $0.voteCount < $1.voteCount }
        }
    }
}

The client also has getFeedback(id:), submitFeedback(title:description:category:email:), getComments(for:) and addComment(to:content:) if you build screens of your own.

6. Voting

Voting needs no code of yours. A tap on the vote button, on a card or on the detail screen, casts a vote, and a second tap removes it. The rules behind it:

  • A new request starts with its submitter's vote, so it shows a count of 1.
  • Voting stops at Completed and Rejected. The vote button is disabled on those requests, VoiceOver says voting is closed, and the server refuses new votes.
  • Each user id votes once per request. hasVoted on each Feedback tells the views which state to draw.
  • allowUndoVote = false makes votes stick, and showVoteCount = false hides the counts.

To vote from your own UI, the client has the same call. A repeat vote from the same user is a SwiftlyFeedbackError.conflict.

@MainActor
func upvote(_ feedback: Feedback) async throws -> Int? {
    guard let client = SwiftlyFeedback.shared else { return nil }
    let result = try await client.vote(for: feedback.id)
    return result.voteCount
}

Here is how voting looks in FeedbackKit, again with Trailmate's sample data:

Votes from inside the app

Users vote and comment inside your app. A new request starts with its submitter's vote.

7. Who is voting: user id and MRR

Every request, vote and comment belongs to a user id. Without one from you, the SDK uses an id already stored in the Keychain, or creates one and stores it there, so a returning user keeps their votes after a reinstall. When your Info.plist lists a CloudKit container identifier, it can use the iCloud user record instead.

If your app has accounts, pass your own id, so one person is one voter on every device. In this version the running client keeps the id it started with, and a new id takes effect at the next launch. So when you already know who is signed in at launch, set the id before you configure:

init() {
    if let userID = UserDefaults.standard.string(forKey: "trailmateUserID") {
        SwiftlyFeedback.updateUser(customID: userID)
    }
    SwiftlyFeedback.configure(environment: .production, key: "sf_your_api_key")
}

Call updateUser(customID:) after a sign-in too, and the id applies from the next launch. The SDK does not move votes cast under an earlier id.

MRR is what turns a vote count into a priority. Pass what each user pays, and your team can sort requests by the total monthly revenue of the people behind them, on every plan. Give the amount and its period, and the SDK converts it: yearly is divided by 12, quarterly by 3, and weekly is multiplied by 52 and divided by 12.

enum TrailmatePlan {
    case monthly, yearly
}

func reportPlan(_ plan: TrailmatePlan?) {
    switch plan {
    case .monthly:
        SwiftlyFeedback.updateUser(payment: .monthly(4.99))
    case .yearly:
        SwiftlyFeedback.updateUser(payment: .yearly(39.99))
    case nil:
        SwiftlyFeedback.clearUserPayment()
    }
}

A payment needs a live client: a call made while SwiftlyFeedback.shared is still nil does nothing. Call it once your subscription state has loaded, from StoreKit or your own server, and again whenever it changes.

8. Status emails, the vote dialog and the mailing list

Telling people what happened to their request is what brings them back to the board. FeedbackKit sends status emails to two groups, under two separate rules:

  • The submitter. If they filled in the email field, they are emailed when their request moves to a status in the project's email list, on every plan. Choosing which statuses are in that list needs Pro.
  • Voters. A voter can ask to be told as well. Their opt-in is saved, and they are included in the send, when the project owner is on Team.

A Rejected request's reason goes into its email, and a voter's email carries a one-click unsubscribe link. Closing the loop covers why this matters.

The vote dialog

Voters opt in through a dialog the SDK shows when someone votes and it has no email for them. It is titled "Vote for this feedback", with an optional email field, a "Notify me of status changes" toggle and, once an address is typed, the mailing-list opt-in. Skip votes without an email, and Vote sends the address. When someone types an address, the SDK keeps it in config.userEmail and does not ask again. Configuration lives in memory, so to keep the address across launches, save it yourself:

// In your App initialiser, after configure.
SwiftlyFeedback.config.userEmail = UserDefaults.standard.string(forKey: "feedbackEmail")
SwiftlyFeedback.config.onUserEmailChanged = { email in
    UserDefaults.standard.set(email, forKey: "feedbackEmail")
}

If your app already knows the address, set userEmail up front and the dialog does not appear. Votes then send that email with voteNotificationDefaultOptIn as the opt-in, so set that to true when your own settings record that the person wants these emails. To never ask, set showVoteEmailField = false.

The mailing-list opt-in

The opt-in on the form and in the dialog feeds an email-list integration, such as Mailchimp or MailerLite on Pro and up, and the server ignores it when none is set up. If you do not use one, hide it. Leave mailingListDefaultOptIn at false unless you have the person's consent, because with userEmail set it decides the subscription without asking:

SwiftlyFeedback.config.showMailingListOptIn = false
SwiftlyFeedback.config.mailingListDefaultOptIn = false

9. Theming and configuration

Colours live on SwiftlyFeedback.theme:

SwiftlyFeedback.theme.primaryColor = .adaptive(light: .green, dark: .mint)
SwiftlyFeedback.theme.statusColors.testflight = .teal
SwiftlyFeedback.theme.categoryColors.featureRequest = .indigo

primaryColor is a ThemeColor: .default follows your app's accent colour, .color(_:) is fixed, and .adaptive(light:dark:) changes with the colour scheme. Status and category colours are plain SwiftUI colours.

Behaviour lives on SwiftlyFeedback.config:

PropertyDefaultWhat it does
allowUndoVotetrueLets a second tap remove a vote
showVoteCounttrueShows vote counts
showStatusBadgetrueShows each request's status
showCategoryBadgetrueShows each request's category
expandDescriptionInListfalseShows whole descriptions in the list instead of two lines
showCommentSectiontrueShows comments on the detail screen
showEmailFieldtrueShows the email field on the submit form
userEmailnilKnown address. Skips the vote dialog
showVoteEmailFieldtrueShows the vote dialog when there is no email
voteNotificationDefaultOptInfalseStarting state of the notify toggle
showMailingListOptIntrueMailing-list opt-in on the form and in the dialog
mailingListDefaultOptInfalseStarting state of that opt-in
allowFeedbackSubmissiontrueLets users file requests
feedbackSubmissionDisabledMessagenilYour message when filing is off
buttons.addButton.displaytrueShows the plus button
buttons.segmentedControl.displaytrueShows the Status picker in the Filter menu
enableAutomaticViewTrackingtrueRecords an event when an SDK screen opens
loggingEnabledtrueSDK log lines in the console

10. Where the board goes in your app

Keep the submit form one tap from the list. People who arrive to ask for offline maps often find the request already there, and vote for it instead of filing a duplicate.

A sheet

The example in section 3. It suits a "Suggest a feature" row in Settings or a button on an empty state.

A tab

Worth it when the board is a place people come back to. The list brings its own navigation, so it goes straight into the tab:

TabView {
    Tab("Trails", systemImage: "map") {
        TrailsView()
    }
    Tab("Ideas", systemImage: "lightbulb") {
        FeedbackListView()
    }
}

Because FeedbackListView always contains a NavigationStack, avoid pushing it onto a stack you already own. That nests two stacks and draws two navigation bars. Use a sheet or a tab instead.

A window on the Mac

var body: some Scene {
    WindowGroup {
        ContentView()
    }
    #if os(macOS)
    Window("Feature Requests", id: "feedback") {
        FeedbackListView()
    }
    #endif
}

Open it from a menu item or a button with @Environment(\.openWindow) private var openWindow and openWindow(id: "feedback").

11. Testing tips

Wait for the client when the board is on screen at launch. A view created while SwiftlyFeedback.shared is still nil has no client and stays empty. Sheets and tabs the user opens later are fine. If the list is the opening screen, hold it back until the client exists:

struct IdeasScreen: View {
    @State private var isReady = false

    var body: some View {
        if isReady {
            FeedbackListView()
        } else {
            ProgressView()
                .task {
                    while SwiftlyFeedback.shared == nil {
                        try? await Task.sleep(for: .milliseconds(50))
                    }
                    isReady = true
                }
        }
    }
}

A wrong key shows a screen, not an empty list. When the server rejects the key, the views show a "Configuration Error" screen in place of the board. Check the key and the environment together, since a key works on its own server.

Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give DEBUG builds a key from a second one with #if DEBUG around the configure call. Otherwise, delete test requests in the dashboard afterwards.

Start over as a new user. Deleting the app does not reset the id, because the Keychain keeps it. Call UserIdentifier.clearUserId() from a debug menu and relaunch.

On Free, the eleventh request seems to vanish. The Free plan keeps 10 visible requests per project. Past that, a new request is saved and the submit succeeds. Its submitter still sees it, but everyone else's list leaves it out. It is not an error. See pricing.

Use previews with sample data. Feedback has a public initialiser, so the detail screen renders in a preview without a server:

#Preview {
    NavigationStack {
        FeedbackDetailView(feedback: Feedback(
            id: UUID(),
            title: "Offline maps",
            description: "Download a region before a hike so the map works without signal.",
            status: .approved,
            category: .featureRequest,
            userId: "preview",
            userEmail: nil,
            voteCount: 248,
            hasVoted: false,
            commentCount: 0,
            createdAt: Date(),
            updatedAt: Date()
        ))
    }
}

Let the Mac app reach the network. With App Sandbox on, as in Xcode's macOS template, tick Outgoing Connections (Client) under Signing & Capabilities, or every request fails.

Turn logging off in release builds. The SDK logs each request under the subsystem com.swiftlyfeedback.sdk, and in this version that includes request bodies, which carry user ids, emails and MRR. Keep the detail for debugging and switch it off for everyone else:

#if !DEBUG
SwiftlyFeedback.config.loggingEnabled = false
#endif

Where to go next

Once requests come in, the work moves to your side: triage them in the web dashboard or the admin apps for iPhone, iPad and Mac, merge duplicates, and move the ones you build through Approved, In Progress and TestFlight to Completed. On Pro and up you can send requests to GitHub, Linear, Notion or another of the trackers FeedbackKit connects to, and post new ones to Slack. The reference is in the Swift SDK docs. Building for Android too? The same board is in the Kotlin SDK, and Flutter and React Native have SDKs of their own.

Every plan includes the Swift SDK. Start free, copy your project key, and the board can be in your next TestFlight build.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.