How to Implement Badge Count in SwiftUI
Request .badge permission via UNUserNotificationCenter, then call await setBadgeCount(_:) to set or clear the red bubble on your app icon. Pass 0 to remove it.
import UserNotifications
// 1. Request permission (once, e.g. in .task)
try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.badge, .alert, .sound])
// 2. Set badge count
try? await UNUserNotificationCenter.current().setBadgeCount(5)
// 3. Clear badge
try? await UNUserNotificationCenter.current().setBadgeCount(0)
Full Implementation
The example below wires a simple counter to the app icon badge. A BadgeManager observable class centralises the async setBadgeCount calls so any view can drive the badge without scattering Task { } blocks. Permission is requested once when the view appears via .task, and the badge mirrors a local @Published counter that persists across app launches via UserDefaults.
import SwiftUI
import UserNotifications
// MARK: - Badge Manager
@Observable
final class BadgeManager {
var count: Int = UserDefaults.standard.integer(forKey: "badgeCount") {
didSet {
UserDefaults.standard.set(count, forKey: "badgeCount")
Task { await applyBadge(count) }
}
}
func requestPermission() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus != .authorized else { return }
try? await center.requestAuthorization(options: [.badge, .alert, .sound])
}
private func applyBadge(_ value: Int) async {
try? await UNUserNotificationCenter.current().setBadgeCount(max(0, value))
}
}
// MARK: - View
struct BadgeCountView: View {
@State private var manager = BadgeManager()
var body: some View {
NavigationStack {
VStack(spacing: 32) {
// Current count display
ZStack {
Circle()
.fill(Color.red)
.frame(width: 80, height: 80)
.opacity(manager.count > 0 ? 1 : 0)
Text("\(manager.count)")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
}
.animation(.spring(duration: 0.3), value: manager.count)
Text(manager.count == 0 ? "No badge" : "Badge: \(manager.count)")
.font(.headline)
.foregroundStyle(.secondary)
// Controls
HStack(spacing: 20) {
Button {
if manager.count > 0 { manager.count -= 1 }
} label: {
Label("Decrease", systemImage: "minus.circle.fill")
.font(.title2)
}
.disabled(manager.count == 0)
Button {
manager.count += 1
} label: {
Label("Increase", systemImage: "plus.circle.fill")
.font(.title2)
}
}
.labelStyle(.iconOnly)
.buttonStyle(.borderedProminent)
Button("Clear Badge", role: .destructive) {
manager.count = 0
}
.disabled(manager.count == 0)
}
.padding()
.navigationTitle("Badge Count")
}
.task {
await manager.requestPermission()
// Sync badge with stored count on launch
try? await UNUserNotificationCenter.current()
.setBadgeCount(manager.count)
}
}
}
// MARK: - Preview
#Preview {
BadgeCountView()
}
How It Works
-
@Observable BadgeManager — Using the iOS 17
@Observablemacro instead ofObservableObjectmeans SwiftUI only re-renders views that read the changed property. ThecountdidSetobserver firesapplyBadgeautomatically every time the value changes. -
setBadgeCount(_:) — Introduced in iOS 16 as the non-deprecated replacement for
UIApplication.shared.applicationIconBadgeNumber. It'sasync throws, so we call it inside aTaskwithtry?to silently swallow permission-denied errors without crashing. -
requestPermission() — Checks current authorization status before prompting. If the user has already granted permission this is a no-op, which avoids redundant system dialogs on every launch. The
.badgeoption is the only one strictly required for badge updates, but pairing it with.alertand.soundcovers full notification stacks. -
UserDefaults persistence — The count is written to
UserDefaultsindidSetso the in-memory counter matches what the OS shows on the icon even after a cold launch, where.taskre-applies it viasetBadgeCount. -
.animation on the Circle — The badge preview animates in and out with
.spring, giving users immediate visual feedback that the count changed — useful for testing before switching to the home screen to check the real icon.
Variants
Set badge from a push notification payload
// In AppDelegate / UNUserNotificationCenterDelegate
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
// Read server-supplied badge value from payload
if let badge = notification.request.content.badge as? Int {
Task {
try? await center.setBadgeCount(badge)
}
}
completionHandler([.banner, .sound, .badge])
}
Auto-clear badge on app foreground
Attach a .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) modifier and call manager.count = 0 inside the closure. This pattern is common in messaging apps where unread counts live on the server and the local badge is cleared whenever the user opens the app — no separate button needed.
Common Pitfalls
-
iOS 16+ only for setBadgeCount:
UNUserNotificationCenter.setBadgeCount(_:)was introduced in iOS 16. If you still need iOS 15 support useDispatchQueue.main.async { UIApplication.shared.applicationIconBadgeNumber = count }behind a version check — but iOS 17 is now the baseline for new apps so this is rarely needed. -
Permission required even for badge-only: Unlike Android, iOS requires explicit user authorization just to show a badge. If the user denies notifications entirely,
setBadgeCountwill throw and silently do nothing. Always checknotificationSettings().badgeSettingand surface a Settings deep-link (URL(string: UIApplication.openSettingsURLString)) if the user later disables it. -
Background vs foreground behavior:
setBadgeCountcalled while the app is in the foreground updates the icon immediately. Calling it from a backgroundURLSessionorBGTaskalso works, but the OS may throttle or defer the update — always treat badge state as eventually consistent rather than transactional. -
Accessibility: Screen readers don't announce badge counts automatically. If your badge represents unread messages or pending alerts, also surface that count inside the app UI with a proper
accessibilityLabelso VoiceOver users aren't left out.
Prompt This with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement badge count in SwiftUI for iOS 17+. Use UNUserNotificationCenter.setBadgeCount(_:). Request .badge authorization before setting any count. Persist the count in UserDefaults and restore it on launch. Make it accessible (VoiceOver labels on increment/decrement buttons). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this prompt into the implementation step after your screen designs are locked — Claude Code will wire the BadgeManager into your existing app architecture and hook it to your notification service layer automatically.
Related
FAQ
Does this work on iOS 16?
setBadgeCount(_:) was introduced in iOS 16, so the async API used here works on iOS 16 and later. On iOS 15 or below you must fall back to the deprecated UIApplication.shared.applicationIconBadgeNumber property on the main thread. Since Xcode 16 sets the default deployment target to iOS 17, and the App Store no longer requires iOS 15 support for new submissions, we recommend targeting iOS 17+ to avoid the extra version-check boilerplate.
Can I set a badge count without showing any notifications?
Yes — badge updates and push/local notifications are independent. Request only the .badge option in requestAuthorization(options:) and the system will grant badge-only permission without showing a banner or playing a sound. Users can also independently toggle badges, banners, and sounds in Settings → Notifications → Your App, so never assume all three are on simultaneously.
What's the UIKit equivalent?
In UIKit you historically set UIApplication.shared.applicationIconBadgeNumber = 5 directly on the main thread — no async, no permission check required before iOS 16. That property still compiles but triggers a deprecation warning in Xcode 15+. The modern equivalent in any app (UIKit or SwiftUI) is await UNUserNotificationCenter.current().setBadgeCount(5), which must be called after authorization is granted.
Last reviewed: 2026-05-11 by the Soarias team.