Same ideas, different names
I work on iOS and Flutter code side by side, and most of what you know transfers. Both frameworks describe the interface as a function of state, both rebuild cheaply, and both reward small, testable pieces. What changes is the vocabulary.
| Idea | SwiftUI | Flutter |
|---|---|---|
| Building block | View | Widget |
| Local state | @State | StatefulWidget and setState |
| Shared observable state | ObservableObject, @Published | ChangeNotifier |
| Data down the tree | @Environment, @EnvironmentObject | InheritedWidget, or Provider and Riverpod on top |
| Navigation | NavigationStack | Navigator, or go_router |
| Async work | async/await, Combine | async/await, Future, Stream |
| Lists | List, ForEach | ListView.builder |
| Layout containers | VStack, HStack, ZStack | Column, Row, Stack |
| Dependencies | Swift Package Manager | pub and pubspec.yaml |
| Tests | XCTest | flutter_test |
Layout: modifiers become wrappers
SwiftUI chains modifiers onto a view. Flutter wraps a widget in another widget. Here is the same two-line label in both.
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
Text(subtitle).foregroundStyle(.secondary)
}
.padding()
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
],
),
)
Two consequences follow. Padding, alignment and sizing are widgets, so trees get deeper and you extract small widgets earlier than you would extract views. And const constructors, as in const SizedBox(height: 8), let Flutter skip rebuilding parts that cannot change.
State and rebuilds
@State and setState both say the same thing: this screen owns a value and should redraw when it changes. The difference is that Flutter makes the notification explicit.
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('$_count')),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
);
}
}
For state shared between screens, the closest match to ObservableObject is ChangeNotifier. Compare the two models.
final class CounterModel: ObservableObject {
@Published private(set) var count = 0
func increment() { count += 1 }
}
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// In a widget:
ListenableBuilder(
listenable: model,
builder: (context, _) => Text('${model.count}'),
)
@Published fires on every assignment. A ChangeNotifier only tells its listeners when you call notifyListeners(). Forget it and the screen quietly stays stale, which is the most common first bug for iOS developers.
Navigation
Flutter's Navigator keeps a stack of routes, much like NavigationStack. Pushing a screen is one call.
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const DetailScreen(id: 7)),
);
For deep links, URLs and the web, most teams adopt a router package such as go_router, which describes every route in one place. If you came from a coordinator-based iOS app, that configuration plays the same role: it is the one place that knows how screens connect. I wrote about that pattern in MVVM-C with SwiftUI and Combine.
Async code and streams
async/await reads almost the same in both languages. Combine publishers map to streams, and a cancellable maps to a subscription that you must cancel yourself.
for await value in stream {
print(value)
}
await for (final value in stream) {
print(value);
}
// Or keep a subscription and cancel it when the widget goes away:
late final StreamSubscription<int> _subscription;
@override
void initState() {
super.initState();
_subscription = stream.listen(print);
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
Combine's AnyCancellable cancels itself when it is released. A StreamSubscription does not, so cancel it in dispose().
Testing
XCTest becomes flutter_test. A widget test builds a widget in memory and lets you tap it and read the result, with no simulator.
testWidgets('increments the counter', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
});
That runs in milliseconds on your machine, so it is cheap to cover screens that would need a slow UI test on iOS.
Habits that break
- Value types. Dart has no structs. Immutable models are classes with
finalfields, usually with acopyWithmethod or a code generator. build()runs often. Treat it like SwiftUI'sbody: no network calls and no side effects. Keep it cheap and push work into state.- No live previews to lean on. Hot reload keeps your app's state while you edit, which covers much of what previews are used for.
- Platform look. Material widgets are the default. For an iOS look, use the Cupertino widgets deliberately, screen by screen.
- Optionals. Dart's null safety is close to Swift optionals, with
String?,??and!. Avoid force unwrapping in Dart the way you avoid it in Swift.
In short
Most of your SwiftUI instincts carry over: declare the interface from state, keep logic out of views and test small units. What changes is the vocabulary, the explicit rebuild signals and the lack of value types. Map the concepts first, then learn the packages.
Next: how a route can draw itself with SVG stroke-dashoffset, the animation behind this site.