```html How to Build a Flashlight App in SwiftUI (2026)

How to Build a Flashlight App in SwiftUI

A Flashlight app gives users one-tap access to their iPhone's LED torch with adjustable brightness and strobe patterns — from a slow pulse to a rapid SOS signal. It's one of the fastest "real app" projects for a beginner learning SwiftUI and AVFoundation together.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

Architecture overview

This app is deliberately thin: a single @Observable class called TorchManager owns all AVFoundation calls and exposes three pieces of state — isOn, brightness, and strobeMode. ContentView reads that state directly via SwiftUI's observation system and renders a large tap-target button plus a brightness Slider. A subsidiary StrobePicker view lets users cycle through off, slow, medium, fast, and SOS patterns. Because there's no persistent data to store, SwiftData is not needed; user preferences (last-used brightness, last strobe mode) can be saved to UserDefaults in under ten lines if you want to go the extra mile.

FlashlightApp/
├── FlashlightApp.swift          # @main entry point, injects TorchManager
├── TorchManager.swift           # @Observable, AVCaptureDevice, Timer
├── Views/
│   ├── ContentView.swift        # main toggle button + brightness slider
│   └── StrobePicker.swift       # strobe mode selector (Picker / segmented)
├── Resources/
│   └── Assets.xcassets
└── PrivacyInfo.xcprivacy        # required for App Store
      

Step-by-step

1. Create the Xcode project and configure permissions

Create a new iOS App target in Xcode 16 (SwiftUI interface, Swift language). Then open Info.plist and add a camera usage string — AVFoundation requires this even though you never show a camera preview. Without it, the OS will silently deny torch access.

<!-- Info.plist -->
<key>NSCameraUsageDescription</key>
<string>FlashlightApp uses the camera hardware to control the LED torch.</string>

// FlashlightApp.swift
import SwiftUI

@main
struct FlashlightApp: App {
    @State private var torchManager = TorchManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(torchManager)
        }
    }
}

2. Build TorchManager with @Observable

All AVFoundation logic lives here. The @Observable macro (iOS 17+) removes the need for manual objectWillChange publishers. The class exposes clean methods — turnOn(), turnOff(), setBrightness(_:) — so views stay declarative.

// TorchManager.swift
import AVFoundation
import Observation

enum StrobeMode: String, CaseIterable, Identifiable {
    case off    = "Off"
    case slow   = "Slow"
    case medium = "Medium"
    case fast   = "Fast"
    case sos    = "SOS"

    var id: String { rawValue }

    /// Seconds between torch state flips. Zero means no strobe.
    var interval: Double {
        switch self {
        case .off:    return 0.0
        case .slow:   return 0.8
        case .medium: return 0.3
        case .fast:   return 0.1
        case .sos:    return 0.15
        }
    }
}

@Observable
final class TorchManager {
    var isOn: Bool       = false
    var brightness: Float = 1.0
    var strobeMode: StrobeMode = .off

    private var strobeTimer: Timer?
    private var strobePhase: Bool = false

    // MARK: - Public API

    func toggle() {
        isOn ? turnOff() : turnOn()
    }

    func turnOn() {
        guard deviceHasTorch else { return }
        if strobeMode == .off {
            setTorchLevel(brightness)
        } else {
            startStrobe()
        }
        isOn = true
    }

    func turnOff() {
        stopStrobe()
        setTorchMode(.off)
        isOn = false
    }

    func applyBrightness(_ value: Float) {
        brightness = max(0.01, min(value, AVCaptureDevice.maxAvailableTorchLevel))
        if isOn && strobeMode == .off {
            setTorchLevel(brightness)
        }
    }

    func applyStrobeMode(_ mode: StrobeMode) {
        strobeMode = mode
        guard isOn else { return }
        if mode == .off {
            stopStrobe()
            setTorchLevel(brightness)
        } else {
            startStrobe()
        }
    }

    // MARK: - Private helpers

    private var deviceHasTorch: Bool {
        AVCaptureDevice.default(for: .video)?.hasTorch == true
    }

    private func setTorchLevel(_ level: Float) {
        guard let device = AVCaptureDevice.default(for: .video),
              device.hasTorch else { return }
        do {
            try device.lockForConfiguration()
            try device.setTorchModeOn(level: level)
            device.unlockForConfiguration()
        } catch {
            print("TorchManager: \(error.localizedDescription)")
        }
    }

    private func setTorchMode(_ mode: AVCaptureDevice.TorchMode) {
        guard let device = AVCaptureDevice.default(for: .video),
              device.hasTorch else { return }
        do {
            try device.lockForConfiguration()
            device.torchMode = mode
            device.unlockForConfiguration()
        } catch {
            print("TorchManager: \(error.localizedDescription)")
        }
    }

    private func startStrobe() {
        stopStrobe()
        strobePhase = true
        let interval = strobeMode.interval
        guard interval > 0 else { return }
        strobeTimer = Timer.scheduledTimer(
            withTimeInterval: interval,
            repeats: true
        ) { [weak self] _ in
            guard let self else { return }
            self.strobePhase.toggle()
            if self.strobePhase {
                self.setTorchLevel(self.brightness)
            } else {
                self.setTorchMode(.off)
            }
        }
        RunLoop.main.add(strobeTimer!, forMode: .common)
    }

    private func stopStrobe() {
        strobeTimer?.invalidate()
        strobeTimer = nil
        strobePhase = false
    }
}

3. Build the main flashlight UI

The main view is a full-screen button: tapping anywhere near the center toggles the torch. A brightness Slider underneath lets users dial back intensity for night-stand use. The background flips to white when the torch is on so the screen itself can supplement the LED — useful on iPads or newer iPhone front cameras.

// ContentView.swift
import SwiftUI

struct ContentView: View {
    @Environment(TorchManager.self) private var torch

    var body: some View {
        @Bindable var torch = torch

        ZStack {
            (torch.isOn ? Color.white : Color.black)
                .ignoresSafeArea()
                .animation(.easeInOut(duration: 0.2), value: torch.isOn)

            VStack(spacing: 40) {
                Spacer()

                Button {
                    torch.toggle()
                } label: {
                    Image(systemName: torch.isOn ? "flashlight.on.fill" : "flashlight.off.fill")
                        .font(.system(size: 80))
                        .foregroundStyle(torch.isOn ? .black : .white)
                        .scaleEffect(torch.isOn ? 1.1 : 1.0)
                        .animation(.spring(duration: 0.25), value: torch.isOn)
                }
                .buttonStyle(.plain)
                .accessibilityLabel(torch.isOn ? "Turn torch off" : "Turn torch on")

                VStack(spacing: 12) {
                    Label("Brightness", systemImage: "sun.max")
                        .font(.subheadline)
                        .foregroundStyle(torch.isOn ? .black : .white)

                    Slider(
                        value: Binding(
                            get: { Double(torch.brightness) },
                            set: { torch.applyBrightness(Float($0)) }
                        ),
                        in: 0.01...1.0
                    )
                    .tint(torch.isOn ? .black : .white)
                    .padding(.horizontal, 40)
                }

                StrobePicker()

                Spacer()
            }
        }
    }
}

#Preview {
    ContentView()
        .environment(TorchManager())
}

4. Add the strobe mode picker

A segmented Picker lets users switch between strobe patterns without leaving the main screen. Changing the mode while the torch is on immediately restarts the timer at the new interval — the applyStrobeMode(_:) method on TorchManager handles the transition cleanly.

// StrobePicker.swift
import SwiftUI

struct StrobePicker: View {
    @Environment(TorchManager.self) private var torch

    var body: some View {
        @Bindable var torch = torch

        VStack(spacing: 10) {
            Text("Strobe")
                .font(.caption)
                .foregroundStyle(torch.isOn ? Color.black.opacity(0.6) : Color.white.opacity(0.6))

            Picker("Strobe mode", selection: Binding(
                get: { torch.strobeMode },
                set: { torch.applyStrobeMode($0) }
            )) {
                ForEach(StrobeMode.allCases) { mode in
                    Text(mode.rawValue).tag(mode)
                }
            }
            .pickerStyle(.segmented)
            .padding(.horizontal, 24)
            // Tint the segmented control to match the current background
            .colorMultiply(torch.isOn ? .black : .white)
        }
    }
}

#Preview {
    StrobePicker()
        .environment(TorchManager())
        .background(Color.black)
}

5. Add the Privacy Manifest (required for App Store)

Since Xcode 15, Apple requires a PrivacyInfo.xcprivacy file for any app that accesses certain APIs. A Flashlight app touches camera hardware, so you must declare it. Without this file your binary will be flagged during App Store processing and the upload will be rejected with a "missing privacy manifest" email.

<!-- PrivacyInfo.xcprivacy -->
<!-- File ▶ New ▶ File from Template ▶ App Privacy in Xcode 16 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <!-- No personal data collected -->

  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryCamera</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
        <!-- "App uses camera hardware to control the device torch" -->
      </array>
    </dict>
  </array>

  <key>NSPrivacyTracking</key>
  <false/>
</dict>
</plist>

Common pitfalls

Adding monetization: Ad-supported

The standard approach for a free utility like this is to integrate Google AdMob via the GoogleMobileAds Swift Package. Add a banner ad anchored to the bottom safe-area inset using a UIViewRepresentable wrapper around GADBannerView. Register your AdMob App ID in Info.plist under the GADApplicationIdentifier key and add the SKAdNetworkItems array that Google provides — Apple requires these for privacy-compliant attribution. If you want a faster path, Apple's own SKAdNetwork framework handles attribution without a third-party SDK, but you'll need to source your own ad demand. Whichever route you take, place ads below the torch button rather than above it — obstructing the primary action is a common cause of 1-star reviews and App Store guideline 4.0 violations for misleading UI.

Shipping this faster with Soarias

Soarias scaffolds the full project structure — TorchManager, ContentView, StrobePicker, the PrivacyInfo.xcprivacy file, and a pre-wired fastlane Fastfile — from a single prompt in Claude Code. It also auto-generates the required App Store metadata: app description, keywords, and six screenshot frames across iPhone 16 Pro Max and iPad Pro sizes, which alone saves an hour of Simulator wrangling. The Privacy Manifest reason codes are filled in automatically based on the APIs your generated code actually calls.

For a beginner-complexity app like this one, most developers spend the majority of their time not writing code but wrestling with App Store Connect forms, provisioning profiles, and screenshot dimensions. Soarias handles the fastlane match setup, signs your build, and submits it to TestFlight in one command. Realistically, a solo developer can go from zero to TestFlight link in a single afternoon instead of a full weekend — leaving the second weekend free to iterate on features your first testers actually ask for.

Related guides

FAQ

Does this work on iOS 16?

The @Observable macro requires iOS 17+. If you need iOS 16 support, replace @Observable with @MainActor class TorchManager: ObservableObject and annotate each stored property with @Published. The AVFoundation torch APIs themselves work on iOS 10+ so there is no hardware compatibility issue — only the SwiftUI observation layer needs adjustment.

Do I need a paid Apple Developer account to test?

You can sideload to your own device with a free Apple ID — Xcode will sign the app with a personal team certificate that's valid for seven days before you need to re-install. However, you cannot distribute via TestFlight or submit to the App Store without the $99/year Apple Developer Program membership. For a solo project where you just want to iterate quickly, the free sideload workflow is fine until you're ready to share with beta testers.

How do I add this to the App Store?

Create an App Store Connect record at appstoreconnect.apple.com, then in Xcode choose Product ▶ Archive. From the Organizer window, click "Distribute App," select App Store Connect, and follow the wizard. You'll need at least one screenshot per supported device family (iPhone and, if you declared iPad support, iPad). App Review typically takes 24–48 hours for a first submission.

My strobe timer keeps pausing — what's wrong?

By default, Timer.scheduledTimer runs in .default run loop mode, which pauses when the user is actively scrolling or dragging a control (like the brightness Slider). Add your timer to RunLoop.main with mode .common as shown in the Step 2 code sample. The .common mode aggregates .default, .eventTracking, and .modalPanel, so the timer fires even during active user interaction.

Last reviewed: 2026-05-12 by the Soarias team.

```