How to Implement Badge Count in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: setBadgeCount Updated: May 11, 2026
TL;DR

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

  1. @Observable BadgeManager — Using the iOS 17 @Observable macro instead of ObservableObject means SwiftUI only re-renders views that read the changed property. The count didSet observer fires applyBadge automatically every time the value changes.
  2. setBadgeCount(_:) — Introduced in iOS 16 as the non-deprecated replacement for UIApplication.shared.applicationIconBadgeNumber. It's async throws, so we call it inside a Task with try? to silently swallow permission-denied errors without crashing.
  3. 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 .badge option is the only one strictly required for badge updates, but pairing it with .alert and .sound covers full notification stacks.
  4. UserDefaults persistence — The count is written to UserDefaults in didSet so the in-memory counter matches what the OS shows on the icon even after a cold launch, where .task re-applies it via setBadgeCount.
  5. .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

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.