Flutter⇄SwiftUI
A working Rosetta Stone for moving a Bloc/Cubit brain into modern SwiftUI — file layout, property wrappers, threading, and one complete production-style reference screen.
◈ Fact-check pass (Aug 2026) — what's drifted since these notes were first written▸
Everything in this document targets iOS 17+ / stable @Observable — that API surface is unchanged and safe to build on today. A few things moved around it since WWDC 2026 (June) that are worth knowing about before you publish or ship against them:
- App Store submissions now require Xcode 26+ / iOS 26 SDK. Apple enforced this as a hard minimum starting April 28, 2026 — builds from older Xcode versions are rejected outright. If you're targeting the App Store today, build with Xcode 26 or later regardless of your app's deployment target.
- iOS 26 shipped a system-wide redesign ("Liquid Glass"). Standard SwiftUI controls adopt it automatically with no code changes, but custom chrome (toolbars, tab bars, sheets) is worth a visual pass on iOS 26 before release.
- SwiftData gained new APIs at WWDC26 — sectioned
@Query(sectionBy:),@Attribute(.codable)for third-party types, andResultsObserver/HistoryObserverfor observing changes outside a View. These ship with iOS 27, which was still in public beta as of this writing — the@Queryexample in the Local Storage section below uses the stable iOS 17+ syntax on purpose. - Swift Testing (
@Test/#expect) is now the default recommendation for new unit tests as of 2026, not just an alternative — confirmed current.XCTestremains required specifically for UI tests (XCUITest) and performance tests, which Swift Testing still doesn't cover. - String Catalogs (
.xcstrings) are confirmed as the current standard for localization, unchanged since Xcode 15 — this notes set's Localization section reflects that.
Nothing above changes any of the code in this document — it's all still accurate. Treat this box as the "what to double-check before publishing" list rather than a correction log.
Mental model cheatsheet
If you understand Flutter's widget tree and reactivity, you already understand ~80% of SwiftUI. This is the direct term-for-term dictionary.
| Concept | Flutter (Dart) | SwiftUI (Swift) |
|---|---|---|
| Foundational building block | Widget | View (struct) |
| Render method | Widget build(context) | var body: some View |
| Vertical stack | Column(children:[...]) | VStack { ... } |
| Horizontal stack | Row(children:[...]) | HStack { ... } |
| Z-axis overlay | Stack(children:[...]) | ZStack { ... } |
| Recycled scroll list | ListView.builder() | List { ForEach(...) } |
| Local UI state | StatefulWidget + setState() | @State private var x |
| Extracted logic layer | Cubit<State> | @Observable class ViewModel |
| Scoped provider | BlocProvider(create:...) | @State var model = ViewModel() |
| Deep tree access | BlocProvider.of<T>(context) | @Environment(T.self) var model |
| Async execution | Future<T> / async-await | Task { await ... } |
| Screen navigation | Navigator.push() / GoRouter | NavigationStack + NavigationLink |
Project structure
lib/ becomes conceptual "Groups" in Xcode. This is the standard, production-ready MVVM mapping.
lib/ ├── main.dart # entry point ├── models/ │ └── todo_item.dart ├── cubits/ # logic layer │ └── todo_cubit.dart ├── screens/ │ ├── profile_screen.dart │ └── detail_screen.dart └── assets/ # via pubspec.yaml
YourAppName/
├── YourAppNameApp.swift # entry point
├── Models/
│ └── TodoItem.swift
├── ViewModels/ # logic layer
│ └── TodoViewModel.swift
├── Views/
│ ├── ProfileView.swift
│ └── DetailView.swift
└── Resources/
└── Assets.xcassets
pubspec.yaml. Add dependencies via File → Add Package Dependencies in Xcode — no text file to hand-edit.New project creation
No CLI equivalent to memorize — Xcode's New Project wizard replaces flutter create.
flutter create my_app cd my_app flutter pub get flutter run # targeting a device flutter devices flutter run -d "iPhone 15"
File → New → Project → iOS → App → Interface: SwiftUI → Language: Swift → Storage: None / SwiftData ⌘R to run on Simulator
| Setting | Flutter | SwiftUI (Xcode wizard) |
|---|---|---|
| App identifier | applicationId in build.gradle / Info.plist | "Bundle Identifier" field at project creation |
| Min OS version | minSdkVersion / iOS deployment target in Podfile | "Minimum Deployments" dropdown |
| Run on simulator | flutter run -d | ⌘R with a Simulator selected in the toolbar |
| Hot reload | Automatic on save (r in terminal) | SwiftUI #Preview canvas updates live; full rebuild for logic changes |
| App entry file | lib/main.dart | YourAppNameApp.swift — marked @main |
import SwiftUI
@main
struct YourAppNameApp: App {
var body: some Scene {
WindowGroup {
ProfileView() // like runApp(MyApp()) in main.dart
}
}
}
Info.plist — you rarely hand-edit that file directly.Packages & dependencies
pub.dev has one clear winner. Swift has three coexisting systems — know which one a library expects.
| Concept | Flutter | SwiftUI / Xcode |
|---|---|---|
| Package registry | pub.dev | Swift Package Manager (SPM) — GitHub URLs, no central index |
| Manifest file | pubspec.yaml | Package.swift / hidden Package.resolved lockfile |
| Install command | flutter pub get | File → Add Package Dependencies (paste repo URL) |
| Legacy alternative | n/a | CocoaPods — Podfile + pod install, still common in older/hybrid projects |
| Version pinning | ^1.2.0 in pubspec.yaml | "Up to Next Major Version" rule set per-package in Xcode |
| Common HTTP package | http or dio | Usually none needed — URLSession is built in |
| JSON parsing package | json_serializable | Built in — Codable protocol, no codegen step |
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
provider: ^6.1.0
cached_network_image: ^3.3.0
dev_dependencies:
flutter_test:
sdk: flutter
Paste repo URL, e.g.: https://github.com/kean/Nuke https://github.com/apple/swift-collections Xcode resolves the version, writes Package.resolved, and links the target for you — no manifest to hand-edit.
http, JSON codegen, state management) have no SwiftUI equivalent to install — URLSession, Codable, and @Observable ship in the standard library.Property wrappers
SwiftUI coordinates state through dedicated wrappers instead of manual emit() / notifyListeners() calls.
Local, owned value
Allocates a private, mutable memory cell owned by the view. Use for screen-isolated primitives — booleans, counters, text buffers. Always mark it private.
StatefulWidget, mutated via setState().Reference back to a parent
Passes a direct read/write link to a parent's @State. Use it to build reusable leaf controls like custom switches or field inputs.
onChanged: (val) {} callback.The Cubit itself
A macro that upgrades a plain class into a reactive broadcaster. SwiftUI observes property reads and redraws only the views that touched a changed value.
Cubit<State> class — but no manual emit() required.Two-way link into a class
Lets a subview generate $-prefixed two-way bindings directly to fields on an injected @Observable instance it doesn't own.
Invisible structural wrapper
Zero layout impact. Lets you attach modifiers to switch/if blocks, and bypasses the 10-child ViewBuilder compiler limit.
Forces main-thread execution
Swift is multi-threaded by default — await can hop to background threads. Put this on ViewModels so mutations always land safely back on the UI thread.
@State private var model = ViewModel() creates and owns the instance. @Bindable var model: ViewModel only accepts a passed-in reference so it can mutate it.Worked examples
@State — owns a primitive, redraws on setState-style mutation▸
struct ContentView: View {
// Always mark it 'private' — it's screen-owned
@State private var count = 0
@State private var isPremiumUser = false
var body: some View {
VStack(spacing: 20) {
Text("Score: \(count)")
.font(.largeTitle)
Text(isPremiumUser ? "Premium Account" : "Standard Account")
.foregroundColor(isPremiumUser ? .green : .gray)
Button("Score point") {
count += 1 // mutate directly, no setState()
}
.buttonStyle(.borderedProminent)
Button("Toggle Membership") {
isPremiumUser.toggle()
}
.buttonStyle(.bordered)
}
.padding()
}
}
@Binding — a child mutates a value it doesn't own▸
// PARENT — owns the source of truth
struct ParentView: View {
@State private var isLightOn = false
var body: some View {
VStack {
Circle()
.fill(isLightOn ? .yellow : .gray)
.frame(width: 50, height: 50)
// '$' passes a live reference down, not a copy
LightSwitchComponent(isOn: $isLightOn)
}
}
}
// CHILD — doesn't own the data, just edits it for the parent
struct LightSwitchComponent: View {
@Binding var isOn: Bool
var body: some View {
Button(isOn ? "Turn Off" : "Turn On") {
isOn.toggle() // this updates the PARENT's circle color
}
}
}
Flutter equivalent: passing a value down alongside an onChanged: (val) {} callback into a custom widget's constructor.
@Observable + @MainActor — the Cubit, thread-safe by default▸
// @MainActor forces every mutation back onto the UI thread,
// even after an 'await' hops onto a background worker thread.
@MainActor
@Observable class TodoViewModel {
var tasks: [TodoItem] = []
func loadRemoteTasks() async {
let fetchedData = await NetworkService.fetchData() // background thread
// Swift jumps back to Main automatically before this write —
// no manual emit() or notifyListeners() needed.
self.tasks = fetchedData
}
}
Flutter equivalent: Dart's single-threaded event loop hides this problem entirely — @MainActor is Swift's explicit guardrail against threading crashes.
@Bindable — pattern 1: parent creates, child mutates▸
@State on the parent creates and owns the instance. @Bindable on the child accepts that same instance so it can generate two-way $ bindings back to it.
struct ParentDashboardView: View {
// 1. @State CREATES the object here — this view owns it
@State private var globalCubit = ProfileViewModel()
var body: some View {
VStack {
Text("Logged in as: \(globalCubit.username)")
// 2. Pass the created instance down into the
// child view's constructor parameter
SettingsScreen(viewModel: globalCubit)
}
}
}
struct SettingsScreen: View {
// 3. @Bindable ACCEPTS the passed object here so you
// can write back to its properties from this screen
@Bindable var viewModel: ProfileViewModel
var body: some View {
// 4. This directly mutates the string back in the
// PARENT view's memory — true two-way binding
TextField("Change username", text: $viewModel.username)
}
}
Flutter equivalent: passing an existing Cubit instance down into a child widget's constructor so a text controller can mutate its fields directly.
@Bindable — pattern 2: binding straight into a Form▸
The most common shape in practice — a settings screen with several fields, all bound to one @Observable model.
@Observable class ProfileViewModel {
var username: String = "John"
var notificationsEnabled: Bool = true
}
struct SettingsScreen: View {
// Allows this screen to create direct '$' binding
// properties to the model's fields
@Bindable var viewModel: ProfileViewModel
var body: some View {
Form {
// Without @Bindable above, writing $viewModel
// here would fail to compile
TextField("Edit Name", text: $viewModel.username)
Toggle("Alerts", isOn: $viewModel.notificationsEnabled)
}
}
}
TextField or Toggle inside that subview needs to write back to it, the property must be marked @Bindable, or the build fails.Networking & HTTP
URLSession + Codable replace http/dio + json_serializable — both are built into the standard library, no package install required.
| Concept | Flutter | Swift |
|---|---|---|
| HTTP client | http / Dio package | URLSession — built in, no import needed |
| JSON model mapping | hand-written fromJson/toJson or codegen | Codable protocol — auto-synthesized, zero codegen |
| Decode a response | jsonDecode(res.body) | JSONDecoder().decode(Type.self, from: data) |
| Error handling | try/catch around http.get() | do/catch around try await URLSession... |
| Loading images from URL | Image.network() / cached_network_image | AsyncImage(url:) — built in, no caching by default |
| Interceptors / auth headers | Dio interceptors | URLRequest.setValue(_:forHTTPHeaderField:) per call, or a wrapping service |
Worked examples
final res = await http.get(
Uri.parse('https://api.example.com/user'),
);
if (res.statusCode == 200) {
final json = jsonDecode(res.body);
return User.fromJson(json);
} else {
throw Exception('Failed to load');
}
let url = URL(string: "https://api.example.com/user")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
Full example — Codable model + a NetworkService your ViewModel calls▸
// Codable = Decodable + Encodable, synthesized automatically
// as long as every property type is itself Codable.
struct User: Codable, Identifiable {
let id: Int
let name: String
let email: String
}
enum NetworkError: Error { case badResponse, decodingFailed }
struct NetworkService {
static func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw NetworkError.badResponse
}
do {
return try JSONDecoder().decode(User.self, from: data)
} catch {
throw NetworkError.decodingFailed
}
}
}
@MainActor
@Observable class UserViewModel {
var user: User?
var loadingState: FetchState = .idle
func loadUser(id: Int) async {
loadingState = .loading
do {
user = try await NetworkService.fetchUser(id: id)
loadingState = .success
} catch {
loadingState = .error(message: "Couldn't load user profile.")
}
}
}
Codable types (String, Int, Bool, arrays, other Codable structs...), conforming to Codable generates the parsing code for you — no build_runner, no generated .g.dart files.Local storage
Three separate Flutter packages map to three built-in Apple frameworks — pick by data shape, not habit.
| Data shape | Flutter | SwiftUI / Swift |
|---|---|---|
| Small key-value flags | SharedPreferences | UserDefaults.standard |
| Structured local database | sqflite / Hive / Isar | SwiftData (iOS 17+) or Core Data (older targets) |
| Secrets / tokens | flutter_secure_storage | Keychain via Security framework |
| Files / cache directory | path_provider + dart:io | FileManager.default + URL.documentsDirectory |
| Reactive persisted state | Hive box listener + ValueListenableBuilder | @Query property wrapper (SwiftData) — auto-refreshes the view |
Worked examples
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarded', true);
final seen = prefs.getBool('onboarded') ?? false;
// direct API
UserDefaults.standard.set(true, forKey: "onboarded")
let seen = UserDefaults.standard.bool(forKey: "onboarded")
// or, INSIDE a View — auto-persisting @State
@AppStorage("onboarded") var onboarded = false
SwiftData — a persisted, queryable model (Hive/Isar equivalent)▸
import SwiftData
@Model
class Note {
var title: String
var body: String
var createdAt: Date
init(title: String, body: String) {
self.title = title
self.body = body
self.createdAt = .now
}
}
struct NotesListView: View {
// Like a Hive box listener, but the view redraws itself —
// no manual ValueListenableBuilder wiring
@Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
@Environment(\.modelContext) private var context
var body: some View {
List(notes) { note in Text(note.title) }
.toolbar {
Button("Add") {
context.insert(Note(title: "Untitled", body: ""))
}
}
}
}
SwiftData once you're modeling real entities.Animations & gestures
SwiftUI animates value changes, not widget swaps — you wrap the mutation, not the container.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Implicit animation | AnimatedContainer | .animation(_:value:) modifier |
| Explicit / triggered animation | AnimationController + Tween | withAnimation { ... } around a state mutation |
| Shared element transition | Hero widget | .matchedGeometryEffect(id:in:) |
| Tap gesture | GestureDetector(onTap:) | .onTapGesture { } |
| Drag gesture | GestureDetector(onPanUpdate:) | DragGesture() + .gesture() |
| Physics-based spring | SpringSimulation | .spring(response:dampingFraction:) — built into the curve type |
Worked examples
Implicit vs explicit — the two animation styles▸
struct BadgeView: View {
@State private var isActive = false
var body: some View {
Circle()
.fill(isActive ? .green : .gray)
.frame(width: isActive ? 60 : 40, height: isActive ? 60 : 40)
// ANY change to a value this modifier watches gets animated
.animation(.easeInOut(duration: 0.3), value: isActive)
.onTapGesture { isActive.toggle() }
}
}
Button("Add to cart") {
// only the mutation inside this closure animates —
// nothing else on screen is affected
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
cart.items.append(product)
}
}
Gestures — tap, long-press, and drag▸
struct DraggableCard: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.opacity(0.2))
.frame(width: 160, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { _ in withAnimation(.spring) { offset = .zero } }
)
.onTapGesture { print("tapped") }
.onLongPressGesture { print("long pressed") }
}
}
Forms & validation
No Form widget package to reach for — Form is a built-in container, and focus/keyboard control is its own property wrapper.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Form container | Form + GlobalKey<FormState> | Form { } — grouped, styled sections built in |
| Field validation | TextFormField(validator:) | No built-in validator — check on submit or with .onChange(of:) |
| Keyboard type | keyboardType: TextInputType.emailAddress | .keyboardType(.emailAddress) |
| Focus control | FocusNode + FocusScope | @FocusState property wrapper |
| Secure field | obscureText: true | SecureField("Password", text:) |
| Submit on return key | onFieldSubmitted: | .onSubmit { } |
A login form — focus chaining + inline validation▸
struct LoginView: View {
@State private var email = ""
@State private var password = ""
@State private var errorMessage: String?
// like tracking which FocusNode currently has focus
enum Field { case email, password }
@FocusState private var focusedField: Field?
var body: some View {
Form {
Section {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.focused($focusedField, equals: .email)
.onSubmit { focusedField = .password } // return key -> next field
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.onSubmit { submit() }
}
if let errorMessage {
Text(errorMessage).foregroundStyle(.red).font(.caption)
}
Button("Sign In", action: submit)
}
.onAppear { focusedField = .email } // auto-focus first field, like autofocus: true
}
private func submit() {
guard email.contains("@") else {
errorMessage = "Enter a valid email address"
return
}
guard password.count >= 8 else {
errorMessage = "Password must be at least 8 characters"
return
}
errorMessage = nil
// proceed with sign-in
}
}
validator: callback. Where TextFormField validates per-field automatically, SwiftUI expects you to check values yourself — usually on submit, as shown above, or reactively with .onChange(of: email) if you want live inline errors as the person types.Theming & dark mode
No central ThemeData object — colors live in the Asset Catalog and adapt automatically per color scheme.
| Concept | Flutter | SwiftUI |
|---|---|---|
| App-wide theme object | ThemeData on MaterialApp | No single object — colors/fonts are referenced per-view or via Assets |
| Light/dark aware color | Theme.of(context).colorScheme.primary | Named color set in Assets.xcassets ("Any/Dark" variants) |
| Read current mode | MediaQuery.platformBrightnessOf(context) | @Environment(\.colorScheme) var scheme |
| Force a mode | ThemeMode.dark on MaterialApp | .preferredColorScheme(.dark) |
| Text style scale | TextTheme | .font(.title), .headline — semantic Dynamic Type styles |
| Reusable custom style | Extension methods on BuildContext | Custom ViewModifier + a .myStyle() extension on View |
Worked examples
Reacting to dark mode without hardcoding colors▸
struct CardView: View {
@Environment(\.colorScheme) private var scheme
var body: some View {
Text("Weekly summary")
.padding()
// "BrandSurface" is defined once in Assets.xcassets
// with separate Any Appearance / Dark values —
// this line never needs an if/else for dark mode
.background(Color("BrandSurface"))
.foregroundStyle(scheme == .dark ? .white : .black)
.cornerRadius(14)
}
}
Reusable style — the ViewModifier pattern▸
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding(16)
.background(Color("BrandSurface"))
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(radius: 4, y: 2)
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
// usage anywhere:
Text("Total balance").cardStyle()
Color("BrandSurface")) everywhere — no if (isDark) branching scattered through the UI.Localization
String Catalogs replace intl + .arb files — one JSON-backed .xcstrings file with a dedicated Xcode editor, no codegen step.
| Concept | Flutter | SwiftUI / Swift |
|---|---|---|
| Translation package | intl + flutter gen-l10n | Built in — no package, no codegen |
| Translation file | app_en.arb, app_es.arb (one per locale) | Localizable.xcstrings — one file, all locales, in Xcode's editor UI |
| Usage in a widget | AppLocalizations.of(context)!.greeting | Text("greeting") — SwiftUI resolves it implicitly |
| Usage outside a View | Same accessor via context | String(localized: "greeting") |
| Pluralization rules | ICU plural syntax in the .arb file | Built into the String Catalog editor per-key, per-locale |
| Auto-extraction from code | Manual — you add every key to the .arb file | Automatic — Xcode scans Text/Button/etc. on every build |
Adding a String Catalog and using it▸
lib/l10n/
app_en.arb
app_es.arb
// app_en.arb
{
"greeting": "Hello, {name}!",
"@greeting": {
"placeholders": { "name": {} }
}
}
// usage
Text(AppLocalizations.of(context)!
.greeting(userName))
Resources/
Localizable.xcstrings // JSON, all languages
// SwiftUI resolves the key automatically —
// no lookup call needed inside a View
Text("greeting \(userName)")
// outside a View (e.g. in a ViewModel)
let msg = String(localized: "greeting \(userName)")
Add languages from the "+" button inside the .xcstrings editor in Xcode — every key already extracted from your Text/Button views shows up ready for translation, no manual key registration.
Text(verbatim: "SKU-4821") for values that should never be sent to translators — usernames, codes, raw data — the equivalent of simply not wrapping a string in AppLocalizations.Accessibility
SwiftUI infers a lot from your layout already — most work is fixing a handful of ambiguous cases, not annotating every view.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Screen reader label | Semantics(label: '...') | .accessibilityLabel("...") |
| Extra context / hint | Semantics(hint: '...') | .accessibilityHint("...") |
| Group children into one stop | MergeSemantics() | .accessibilityElement(children: .combine) |
| Hide decorative content | ExcludeSemantics() | .accessibilityHidden(true) |
| Dynamic Type / text scaling | Manual — scale with MediaQuery.textScaleFactor | Automatic for semantic fonts (.body, .title, etc.) |
| Announce a live change | SemanticsService.announce() | AccessibilityNotification.Announcement |
| Test with a screen reader | TalkBack (Android) / VoiceOver (iOS simulator) | VoiceOver — ⌘F5 on the simulator/device |
Merging a custom row into one VoiceOver stop▸
struct OrderRow: View {
let order: Order
var body: some View {
HStack {
Text(order.title)
Spacer()
Text(order.status).foregroundStyle(.secondary)
}
// without this, VoiceOver reads the title and status
// as two separate stops — merge them into one
.accessibilityElement(children: .combine)
.accessibilityLabel("\(order.title), \(order.status)")
.accessibilityHint("Double tap to view order details")
}
}
Button containing only an SF Symbol has no readable label by default — always pair it with .accessibilityLabel("Delete") or VoiceOver announces nothing useful.Permissions & push notifications
No runtime permission-request package — usage strings in Info.plist plus a per-framework request call. Push notifications follow the same pattern, then hand off to APNs.
| Permission | Flutter | SwiftUI / Swift |
|---|---|---|
| Request flow | permission_handler package | Per-framework request call — no unified package |
| Camera | Permission.camera.request() | AVCaptureDevice.requestAccess(for: .video) |
| Photo library | Permission.photos.request() | PHPhotoLibrary.requestAuthorization(for:) |
| Location | Permission.location.request() | CLLocationManager().requestWhenInUseAuthorization() |
| Push notifications | firebase_messaging request | UNUserNotificationCenter.requestAuthorization(options:) |
| Declare the "why" text | AndroidManifest.xml + Info.plist usage strings | Info.plist keys only — e.g. NSCameraUsageDescription |
Camera permission — the full request + check flow▸
import AVFoundation
enum CameraPermission {
static func request() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
case .denied, .restricted:
return false
@unknown default:
return false
}
}
}
Push notifications, end to end
| Step | Flutter | Swift |
|---|---|---|
| Register for remote notifications | FirebaseMessaging.instance.requestPermission() | UIApplication.shared.registerForRemoteNotifications() |
| Get the device token | FirebaseMessaging.instance.getToken() | didRegisterForRemoteNotificationsWithDeviceToken delegate callback |
| Handle a tap on the notification | FirebaseMessaging.onMessageOpenedApp | UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:) |
| Foreground presentation | Handled automatically by the plugin | willPresent delegate method — you choose banner/sound/badge explicitly |
| Backend delivery | Firebase Cloud Messaging (cross-platform) | Direct APNs, or FCM if you're already using it for Android too |
AppDelegate bridge — request permission, register, capture the token▸
import UIKit
import UserNotifications
@MainActor
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationManager()
func requestAuthorization() async -> Bool {
let center = UNUserNotificationCenter.current()
center.delegate = self
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
// must happen on the main thread — like firebase_messaging does internally
UIApplication.shared.registerForRemoteNotifications()
}
return granted
} catch {
return false
}
}
// called by AppDelegate once APNs hands back a token
func didReceiveDeviceToken(_ deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// send `token` to your backend, same as FirebaseMessaging.instance.getToken()
print("APNs device token: \(token)")
}
// notification arrives while the app is in the FOREGROUND
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound, .badge] // explicit — Flutter's plugin does this for you
}
// user TAPPED the notification (foreground, background, or terminated launch)
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
// route to the relevant screen, e.g. push.append(...) on your NavigationPath
print("Notification tapped with payload: \(userInfo)")
}
}
import SwiftUI
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
PushNotificationManager.shared.didReceiveDeviceToken(deviceToken)
}
}
@main
struct YourAppNameApp: App {
// SwiftUI's App protocol has no native push-token delegate,
// so it bridges to UIKit's AppDelegate for this one thing
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ProfileView()
.task { _ = await PushNotificationManager.shared.requestAuthorization() }
}
}
}
NS...UsageDescription string in Info.plist — if it's missing, iOS terminates the app the moment you request that permission, with no dialog shown at all..apns file).App lifecycle
One environment value replaces the observer pattern — no subscribe/unsubscribe dance.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Observe app state changes | WidgetsBindingObserver + didChangeAppLifecycleState | @Environment(\.scenePhase) |
| States available | resumed / inactive / paused / detached | .active / .inactive / .background |
| React to a transition | Override the observer method, check the enum | .onChange(of: scenePhase) { old, new in } |
| Background task (finish work after backgrounding) | flutter_background_service / native platform channel | BGTaskScheduler — register + schedule a BGAppRefreshTask |
| React to memory warnings | didHaveMemoryPressure | UIApplication.didReceiveMemoryWarningNotification |
Pausing work when the app backgrounds▸
struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var viewModel = SyncViewModel()
var body: some View {
ProfileView()
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
Task { await viewModel.resumeSync() }
case .background:
viewModel.pauseSync()
viewModel.persistDraftState()
case .inactive:
break // brief transition state — e.g. control center pulled down
@unknown default:
break
}
}
}
}
scenePhase only has three cases — when the app is force-quit or killed by the system, there's no callback at all, exactly like Flutter's detached state gives you almost nothing actionable either.Dependency injection
No DI container package required for most apps — @Environment plus plain initializer injection covers the common cases.
| Concept | Flutter | SwiftUI |
|---|---|---|
| DI container package | get_it / riverpod / provider | Usually none — @Environment + initializers |
| App-wide singleton service | GetIt.instance.registerSingleton() | A plain static let shared, or injected via .environment() |
| Inject into a screen | Constructor param, or context.read<T>() | Constructor param, or @Environment(T.self) |
| Swap real vs. mock for tests | Register a fake in GetIt before the test | Pass a protocol-typed mock into the initializer directly |
| Scoped to one feature only | ProxyProvider further down the tree | .environment() attached at a lower view, not the root |
Protocol-based service + environment injection (the testable pattern)▸
protocol UserServiceProtocol {
func fetchUser(id: Int) async throws -> User
}
struct LiveUserService: UserServiceProtocol {
func fetchUser(id: Int) async throws -> User {
try await NetworkService.fetchUser(id: id)
}
}
struct MockUserService: UserServiceProtocol {
func fetchUser(id: Int) async throws -> User {
User(id: id, name: "Test User", email: "test@example.com")
}
}
@MainActor
@Observable class UserViewModel {
private let userService: UserServiceProtocol
var user: User?
// constructor injection — same idea as passing a repository
// into a Cubit's constructor in Flutter
init(userService: UserServiceProtocol = LiveUserService()) {
self.userService = userService
}
func load(id: Int) async {
user = try? await userService.fetchUser(id: id)
}
}
// production
let vm = UserViewModel()
// test — swap the dependency, no container needed
let testVM = UserViewModel(userService: MockUserService())
Testing
Apple's newer Testing framework (Swift 6) reads a lot like flutter_test — XCTest is still the safe default for wider tooling support.
| Concept | Flutter | Swift |
|---|---|---|
| Unit test target | test/ + flutter_test | YourAppNameTests/ + XCTest or Testing |
| Assertion | expect(value, equals(x)) | XCTAssertEqual(value, x) / #expect(value == x) |
| Async test | test('...', () async { await ... }) | func test...() async throws { ... } |
| UI / widget test | testWidgets() + WidgetTester | XCUITest — separate YourAppNameUITests target |
| Run tests | flutter test | ⌘U in Xcode, or xcodebuild test |
Worked example
test('addNewTask adds a task', () {
final cubit = TodoCubit();
cubit.taskInputBuffer = 'Buy milk';
cubit.addNewTask();
expect(cubit.state.tasks.length, 1);
});
import Testing
@testable import YourAppName
@Test func addNewTaskAddsATask() {
let vm = TodoViewModel()
vm.taskInputBuffer = "Buy milk"
vm.addNewTask()
#expect(vm.tasks.count == 1)
}
async and awaiting the call — the compiler will tell you exactly where.Release & deployment
This is the section furthest from Flutter's CI/CD model — no fastlane-only path, and Apple's tooling/timeline requirements shift more often than the language does.
| Concept | Flutter | SwiftUI / Xcode |
|---|---|---|
| Build for release | flutter build ipa | Product → Archive in Xcode, or xcodebuild archive |
| Signing | Fastlane match, or manual provisioning profiles | Automatic signing in Xcode, or Fastlane match — same tool works for both |
| Beta distribution | Firebase App Distribution / TestFlight (via Codemagic etc.) | TestFlight — builds expire after 90 days |
| CI/CD | Codemagic, GitHub Actions + custom runners | Xcode Cloud (native, 25 free compute hrs/mo) or Fastlane + GitHub Actions on a macOS runner |
| Store listing config | Google Play Console + App Store Connect (two consoles) | App Store Connect only |
| Privacy declarations | Play Data Safety form | App Privacy "nutrition label" + a Privacy Manifest per third-party SDK |
| Review turnaround | Google Play — hours to a few days | Apple App Review — ~90% within 24 hours as of 2026, longer for complex/flagged apps |
The submission checklist
- 01Enroll in the Apple Developer Program$99/year. Individual accounts use your legal name; organization accounts need a D-U-N-S number and show a company name on the Store.
- 02Register a Bundle ID and create the App RecordIn App Store Connect — this is the one-time setup step that minor updates skip.
- 03Build with the current required Xcode/SDKXcode 26+ / iOS 26 SDK as of this writing (see the verified box above) — check developer.apple.com/news for the current baseline before every release, since it moves roughly once a year.
- 04Archive and upload the same build to TestFlight firstTest the exact binary you intend to ship, not a separate debug build — this matches uploading a signed release APK/AAB to Play's internal track before promoting it.
- 05Declare API usage in your Privacy ManifestAny "required reason" API your app or its third-party SDKs touch (file timestamps, UserDefaults, disk space, etc.) needs an approved reason code — a common, fully automatic rejection cause if skipped.
- 06Fill in App Privacy details and the age rating questionnaireBoth are checked against your actual app behavior during review, not just taken at face value.
- 07Submit for reviewMost builds clear within 24 hours; expedited review exists for critical fixes but isn't guaranteed.
Fastlane — the one CI/CD tool that works the same for both stacks▸
If your AppGenie pipeline already runs Fastlane for Android builds, the iOS half of that setup carries over almost unchanged — match for signing, pilot for TestFlight, deliver for App Store Connect metadata.
lane :beta do
match(type: "appstore") # pulls signing certs from your shared repo
build_app(scheme: "YourAppName") # archives with the project's current Xcode
upload_to_testflight(skip_waiting_for_build_processing: true)
end
lane :release do
match(type: "appstore")
build_app(scheme: "YourAppName")
upload_to_app_store(
submit_for_review: true,
automatic_release: false # you flip the switch manually after review
)
end
Async & the Task bridge
Button actions in SwiftUI are strictly synchronous closures — you can't mark one async directly. Task {} is the bridge into an async context.
onPressed: () async { await cubit.fetch(); }
Dart lets you mark the callback itself as async — no wrapper needed.
Button("Load") { Task { await vm.fetch() } }
Swift needs an explicit sandbox to open the async lane inside a synchronous closure.
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
Text("Profile Screen Layout")
.task {
// Fires automatically on appear.
// Auto-cancels if the user navigates away mid-request.
await viewModel.fetchProfileData()
}
}
}
.task { } is the closest match to firing a Cubit's initial load inside a screen's initState() — except it also handles cancellation for free.
Memory & lifecycle
No more manual bloc.close(). Swift's ARC handles teardown automatically the moment a view leaves the navigation stack.
| Behavior | Flutter (Dart) | SwiftUI (Swift) |
|---|---|---|
| Memory model | Garbage Collector (GC) | Automatic Reference Counting (ARC) |
| Cleanup trigger | Manual bloc.close() | Automatic — pointer count hits zero on pop |
| Screen mount | initState() | .onAppear { } |
| Screen unmount | dispose() | .onDisappear { } |
| Explicit destructor | n/a | deinit { } |
deinit {} only to verify teardown or clean up persistent items like open sockets and tracking loops — not for routine state cleanup, which ARC already handles.File types in an Xcode project
Relevant if bridging into an existing Objective-C codebase (e.g. a legacy UIKit module) rather than a pure SwiftUI app.
| File type | Extension | Used for |
|---|---|---|
| SwiftUI view | .swift | Layout + logic in one file — live Canvas preview |
| Storyboard | .storyboard | Legacy visual XML layout — still standard for Objective-C/UIKit |
| XIB | .xib | Single-screen visual layout, same era as storyboards |
| Objective-C | .h / .m | Header + implementation — requires UIKit, not SwiftUI-compatible directly |
Storyboards persist mainly in large legacy or Objective-C-only codebases — merge conflicts on the generated XML and slower Xcode load times are the usual reasons teams migrate off them.
Full code reference
One cohesive, production-style screen: model, @Observable ViewModel, async loading states, a recycled list with swipe-to-delete, and routed navigation.
TodoItem.swift & FetchState — data layer▸
struct TodoItem: Identifiable {
let id = UUID() // auto-managed row-tracking id
let title: String
var isCompleted: Bool
}
/// Maps directly to a Bloc/Cubit state enum.
enum FetchState {
case idle
case loading
case success
case error(message: String) // associated value, like a Dart sealed class field
}
TodoViewModel.swift — the Cubit▸
@MainActor
@Observable class TodoViewModel {
var tasks: [TodoItem] = []
var loadingState: FetchState = .idle
var taskInputBuffer: String = ""
func loadRemoteTasks() async {
loadingState = .loading
do {
try await Task.sleep(nanoseconds: 1_200_000_000) // simulated network delay
tasks = [
TodoItem(title: "Learn SwiftUI Basics", isCompleted: true),
TodoItem(title: "Master State & ViewModels", isCompleted: true),
TodoItem(title: "Build Scrollable Lists & Feeds", isCompleted: false),
TodoItem(title: "Implement Navigation Architecture", isCompleted: false)
]
loadingState = .success
} catch {
loadingState = .error(message: "Failed to download cloud records.")
}
}
func addNewTask() {
guard !taskInputBuffer.trimmingCharacters(in: .whitespaces).isEmpty else { return }
tasks.append(TodoItem(title: taskInputBuffer, isCompleted: false))
taskInputBuffer = ""
}
func deleteItems(at offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
}
deinit {
print("ViewModel cleared from memory — zero manual close() calls.")
}
}
ProfileView.swift — root screen▸
struct ProfileView: View {
@State private var viewModel = TodoViewModel()
var body: some View {
NavigationStack {
VStack {
// --- input row ---
VStack(spacing: 8) {
@Bindable var bindableModel = viewModel
HStack {
TextField("Create a new task...", text: $bindableModel.taskInputBuffer)
.textFieldStyle(.roundedBorder)
Button(action: { viewModel.addNewTask() }) {
Image(systemName: "plus.circle.fill").font(.title2)
}
}
}
.padding()
// --- state machine (BlocBuilder equivalent) ---
Group {
switch viewModel.loadingState {
case .idle:
ContentUnavailableView("No Sync Active",
systemImage: "tray.and.arrow.down",
description: Text("Trigger sync below."))
case .loading:
ProgressView("Loading...").padding()
case .error(let message):
ContentUnavailableView("Connection Failed",
systemImage: "wifi.exclamationmark",
description: Text(message))
.foregroundColor(.red)
case .success:
List {
ForEach(viewModel.tasks.indices, id: \.self) { index in
HStack {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) {
viewModel.tasks[index].isCompleted.toggle()
}
} label: {
Image(systemName: viewModel.tasks[index].isCompleted
? "checkmark.circle.fill" : "circle")
.foregroundColor(viewModel.tasks[index].isCompleted ? .green : .gray)
}
.buttonStyle(.plain)
NavigationLink(destination: TodoItemDetailView(todoItem: viewModel.tasks[index])) {
Text(viewModel.tasks[index].title)
.strikethrough(viewModel.tasks[index].isCompleted)
}
}
}
.onDelete { viewModel.deleteItems(at: $0) }
}
}
}
.animation(.easeInOut(duration: 0.25), value: viewModel.tasks.count)
Button(action: { Task { await viewModel.loadRemoteTasks() } }) {
Label("Sync Core Database", systemImage: "arrow.clockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding()
}
.navigationTitle("Dashboard")
}
}
}
struct TodoItemDetailView: View {
let todoItem: TodoItem
var body: some View {
VStack(spacing: 20) {
Image(systemName: todoItem.isCompleted ? "doc.plaintext.fill" : "doc.plaintext")
.font(.system(size: 60))
.foregroundColor(todoItem.isCompleted ? .green : .orange)
Text(todoItem.title).font(.title).bold()
Spacer()
}
.padding(.top, 50)
.navigationTitle("Verification Tracker")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
ProfileView()
}
6 translation rules
Keep these in your head while porting any Bloc/Cubit screen over.
- 01The ViewModel is your Cubit.No separate State classes — properties on the
@Observableclass are the state; methods are your Cubit methods. - 02SwiftUI is your BlocBuilder.Reading
viewModel.tasks.countinside a view body auto-creates a targeted observer — no wrapper widget needed. - 03No manual disposes.ARC drops the ViewModel from memory the instant a screen leaves the navigation stack.
- 04Use
Task {}for async hooks.Button closures are synchronous — wrap async calls explicitly, or use.task {}for auto-fire-on-appear. - 05Declare navigation tree-side.Instead of
Navigator.push()in a callback, wrap the destination inline:NavigationLink(destination:) { ... }. - 06Use indices to mutate list items.
ForEachloops pass copies — iterate.indiceswhen you need direct array write-back.