SwiftUI Feedback Board Tutorial: Swift SDK 1.0
This tutorial covers Swift SDK 1.0. If you are starting today, use the current SwiftUI tutorial, which covers the latest release. What 1.0 contains is summed up in Introducing the FeedbackKit Swift SDK.
A note on installing 1.0. The 1.0.0 tag points at a commit of our whole workspace, with no Package.swift at its root, so Swift Package Manager cannot resolve it as an exact version. Up to Next Major Version from 1.0.0 now resolves 1.3.0, and three lines in this tutorial do not compile there: buttons.segmentedControl and the bare Color values for status and category colours were changed in 1.2. So install with Up to Next Minor Version from 1.1.1, which resolves 1.1.1. The code in this tutorial compiles against that release.
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. 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(with:baseURL:)with your key and the production address, in yourAppinitialiser. - Show
FeedbackListViewin a sheet or a tab. The submit form, the detail screen and voting come with it. - Pass a user id and each user's MRR, so votes carry weight.
- Theme it with
SwiftlyFeedback.themeand switch parts on and off withSwiftlyFeedback.config.
What you need
The package supports iOS 26, macOS 26 and visionOS 26 or later, with no watchOS or tvOS target, and builds with Xcode 26. You also need a FeedbackKit project and its API key. Create the project in the FeedbackKit 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, paste the repository URL, choose the Up to Next Minor Version rule and type 1.1.1 as the lower bound:
https://github.com/Swiftly-Developed/SwiftlyFeedbackKit-Swift-SDK.git
Xcode then resolves 1.1.1, the release this tutorial is tested against, as the note above explains. Add the SwiftlyFeedbackKit library to your app target. The package has no third-party dependencies.
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(
with: "sf_your_api_key",
baseURL: URL(string: "https://api.feedbackkit.prod.swiftly-developed.com/api/v1")!
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
A key belongs to the server it was created on, and projects made in the admin app live on the production server. That is why the call names the production address and why every build, DEBUG included, should use it. The two shorter calls are for other setups: configure(with:) on its own points at a FeedbackKit server on localhost:8080, and configureAuto(with:) sends DEBUG builds to localhost and TestFlight builds to a staging server, where your key does not exist.
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 or oldest. 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.
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
The views are in English in this version.
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. 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. After a successful submit the form dismisses itself and calls your closure.
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."
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.
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, and the server refuses new votes.
- Each user id votes once per request.
hasVotedon eachFeedbacktells the views which state to draw. allowUndoVote = falsemakes votes stick, andshowVoteCount = falsehides 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, with sample data from Trailmate:
Votes from inside the app
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(
with: "sf_your_api_key",
baseURL: URL(string: "https://api.feedbackkit.prod.swiftly-developed.com/api/v1")!
)
}
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. 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, and again whenever it changes.
8. Status emails and the vote dialog
Telling people what happened to their request is what brings them back. 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. Plans are on the pricing page.
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 and a "Notify me of status changes" toggle. 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.
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:
| Property | Default | What it does |
|---|---|---|
allowUndoVote | true | Lets a second tap remove a vote |
showVoteCount | true | Shows vote counts |
showStatusBadge | true | Shows each request's status |
showCategoryBadge | true | Shows each request's category |
expandDescriptionInList | false | Shows whole descriptions in the list instead of two lines |
showCommentSection | true | Shows comments on the detail screen |
showEmailField | true | Shows the email field on the submit form |
userEmail | nil | Known address. Skips the vote dialog |
showVoteEmailField | true | Shows the vote dialog when there is no email |
voteNotificationDefaultOptIn | false | Starting state of the notify toggle |
allowFeedbackSubmission | true | Lets users file requests |
feedbackSubmissionDisabledMessage | nil | Your message when filing is off |
buttons.addButton.display | true | Shows the plus button |
buttons.segmentedControl.display | true | Shows the Status picker in the Filter menu |
enableAutomaticViewTracking | true | Records an event when an SDK screen opens |
loggingEnabled | true | SDK 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 address 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 admin app 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.
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 FeedbackKit admin app on iPhone, iPad or 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 for every view and method is in the Swift SDK docs, and the next release is described in What's New in Swift SDK 1.1.
Every plan includes the Swift SDK. Start free, copy your project key, and the board can be in your next TestFlight build.