Skip to the article
henaifuos.com
Flutter

From iOS to Flutter: what carries over and what does not

If you have shipped iOS apps, Flutter feels familiar within a day and surprising within a week. This guide maps SwiftUI concepts to their Flutter counterparts, then lists the habits that need to change.

By Soufiane H. 7 min read

Four SwiftUI ideas and the Flutter ones that do the same job.

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.

SwiftUI concepts and their Flutter counterparts
IdeaSwiftUIFlutter
Building blockViewWidget
Local state@StateStatefulWidget and setState
Shared observable stateObservableObject, @PublishedChangeNotifier
Data down the tree@Environment, @EnvironmentObjectInheritedWidget, or Provider and Riverpod on top
NavigationNavigationStackNavigator, or go_router
Async workasync/await, Combineasync/await, Future, Stream
ListsList, ForEachListView.builder
Layout containersVStack, HStack, ZStackColumn, Row, Stack
DependenciesSwift Package Managerpub and pubspec.yaml
TestsXCTestflutter_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.

SwiftTitleBlock.swift
VStack(alignment: .leading, spacing: 8) {
    Text(title).font(.headline)
    Text(subtitle).foregroundStyle(.secondary)
}
.padding()
Darttitle_block.dart
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.

Dartcounter_page.dart
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.

SwiftCounterModel.swift
final class CounterModel: ObservableObject {
    @Published private(set) var count = 0
    func increment() { count += 1 }
}
Dartcounter_model.dart
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.

Flutter's Navigator keeps a stack of routes, much like NavigationStack. Pushing a screen is one call.

Dartopen_detail.dart
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.

SwiftStream.swift
for await value in stream {
    print(value)
}
Dartstream.dart
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.

Dartcounter_page_test.dart
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 final fields, usually with a copyWith method or a code generator.
  • build() runs often. Treat it like SwiftUI's body: 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.

About the author

Soufiane H. is an iOS and Flutter developer in Casablanca who designs, builds and publishes mobile apps for iOS and Android.

Keep reading