Guide to Flutter for iOS developers

13 min read
November 7, 2024

Transitioning from iOS development to Flutter can be an exciting move if you’re looking to expand your app-building skills across platforms.

Using Flutter’s single codebase, you can create apps for both iOS and Android.

Flutter gives you a fresh approach with its declarative UI framework and Dart language.

However, the core concepts like UI structure, user interaction, and app architecture remain familiar.

In this guide, we’ll explore the key steps for making the switch. We’ll cover everything from setting up Flutter to using your iOS skills to build cross-platform apps.

Key differences between iOS and Flutter

1. Programming language: Swift vs. Dart

iOS primarily uses Swift. Flutter, uses Dart, a language developed by Google for cross-platform development.

While both languages are modern and feature-rich, Dart’s syntax may initially feel unusual to Swift developers. That’s because of its JavaScript-like approach.

However, Dart’s support for asynchronous programming with async/await and its streamlined approach to handling UI elements can quickly become familiar.

Let’s get into the main differences between languages.

1. Structs vs. classes (value vs. reference types)

Swift offers both structs (value types) and classes (reference types), allowing developers to choose based on how data should be handled in memory.

Additionally, Swift includes actors – a new type that helps manage state safely in concurrent environments by isolating mutable state.

In contrast, Dart only has classes, which are reference types, and doesn’t natively include actors for concurrency management, though it provides async and await for asynchronous programming.

Here are some examples: 

Swift:

struct Point {
    var x: Int
    var y: Int
}

var point1 = Point(x: 10, y: 20)
var point2 = point1
point2.x = 30

print(point1.x) // Output: 10, since structs are copied by value

Dart:

class Point {
  int x;
  int y;

  Point(this.x, this.y);
}

var point1 = Point(10, 20);
var point2 = point1;
point2.x = 30;

print(point1.x); // Output: 30, since classes are reference types

2. Factory constructors in Dart vs. convenience initializers in Swift

Dart’s factory constructors allow for custom object creation logic directly within the constructor. Swift uses convenience initializers to achieve similar outcomes.

Swift:

class Product {
    let id: String
    let name: String

    convenience init?(from data: [String: Any]) {
        guard let id = data["id"] as? String,
              let name = data["name"] as? String else { return nil }
        self.init(id: id, name: name)
    }

    init(id: String, name: String) {
        self.id = id
        self.name = name
    }
}

// Usage
let data: [String: Any] = ["id": "001", "name": "Laptop"]
if let product = Product(from: data) {
    print(product.name) // Output: Laptop
}

Dart:

class Product {
  final String id;
  final String name;

  factory Product.fromMap(Map<String, dynamic> data) {
    return Product(id: data['id'], name: data['name']);
  }

  Product({required this.id, required this.name});
}

// Usage
final data = {'id': '001', 'name': 'Laptop'};
final product = Product.fromMap(data);

3. Protocols vs. interfaces and mixins

Swift uses protocols to define blueprints for methods and properties. This allows types to conform and provide their own implementations. Dart uses interfaces through classes and includes mixins for code reuse.

Swift:

// Protocols as interfaces
protocol Drivable {
    func drive()
}

protocol Flyable {
    func fly()
}

// Protocol extension for shared behavior, similar to a mixin
protocol Swimmable {
    func swim()
}

extension Swimmable {
    func swim() {
        print("Swimming")
    }
}

// Implementing protocols and using protocol extension
class Car: Drivable {
    func drive() {
        print("Driving on the road")
    }
}

class Airplane: Drivable, Flyable {
    func drive() {
        print("Taxiing on the runway")
    }
    
    func fly() {
        print("Flying")
    }
}

class AmphibiousVehicle: Drivable, Swimmable {
    func drive() {
        print("Driving on both land and water")
    }
    // No need to implement `swim` since it's provided by the extension
}

// Example usage
let car = Car()
car.drive() // Output: Driving on the road

let airplane = Airplane()
airplane.drive() // Output: Taxiing on the runway
airplane.fly() // Output: Flying

let amphibiousVehicle = AmphibiousVehicle()
amphibiousVehicle.drive() // Output: Driving on both land and water
amphibiousVehicle.swim() // Output: Swimming

Dart:

// Abstract classes as interfaces
abstract class Drivable {
  void drive();
}

abstract class Flyable {
  void fly();
}

// Mixin for shared behavior
mixin Swimmable {
  void swim() {
    print('Swimming');
  }
}

// Implementing abstract classes and using a mixin
class Car implements Drivable {
  @override
  void drive() {
    print('Driving on the road');
  }
}

class Airplane implements Drivable, Flyable {
  @override
  void drive() {
    print('Taxiing on the runway');
  }

  @override
  void fly() {
    print('Flying');
  }
}

class AmphibiousVehicle with Swimmable implements Drivable {
  @override
  void drive() {
    print('Driving on both land and water');
  }
}

// Example usage
void main() {
  Car car = Car();
  car.drive(); // Output: Driving on the road
  Airplane airplane = Airplane();
  airplane.drive(); // Output: Taxiing on the runway
  airplane.fly(); // Output: Flying
  AmphibiousVehicle amphibiousVehicle = AmphibiousVehicle();
  amphibiousVehicle.drive(); // Output: Driving on both land and water
  amphibiousVehicle.swim(); // Output: Swimming
}

2. UI development approach: UIKit vs. widget-based structure

iOS developers usually use UIKit, which requires managing views and layout constraints explicitly. However, those familiar with SwiftUI will find Flutter’s widget-based architecture more relatable. 

Like SwiftUI, Flutter’s approach is declarative. That means you define the UI based on the current state rather than directly managing the UI elements themselves.

In both Flutter and SwiftUI, you build the UI by composing elements that automatically update when the state changes.

This declarative nature allows for more streamlined UI development in both frameworks.

For example, instead of writing code to update a button’s color or label in response to an action, you describe the UI state in a way that automatically reflects changes. 

While there are differences in syntax and specific implementations, Flutter’s widget system and SwiftUI’s views share this core concept.

So, if you’re experienced with SwiftUI you shouldn’t have a problem. 

Let’s use a list of items for example:

Swift (UIKit):

class ListViewController: UITableViewController {
    let items = ["Apple", "Banana", "Cherry"]
    let icons = ["applelogo", "star", "heart"]

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: “reuseIdentifier”)
        cell.textLabel?.text = items[indexPath.row]
        cell.imageView?.image = UIImage(systemName: icons[indexPath.row])
        return cell
    }
}

Swift (SwiftUI):

struct ContentView: View {
    let items = [
        ("Apple", "applelogo"),
        ("Banana", "star"),
        ("Cherry", "heart")
    ]

    var body: some View {
        List(items, id: \.0) { item in
            HStack {
                Image(systemName: item.1)
                Text(item.0)
            }
            .padding()
        }
    }
}

Flutter:

class MyApp extends StatelessWidget {
  final items = [
    {"label": "Apple", "icon": Icons.apple},
    {"label": "Banana", "icon": Icons.star},
    {"label": "Cherry", "icon": Icons.favorite},
  ];

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Fruit List')),
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return ListTile(
              leading: Icon(items[index]['icon']),
              title: Text(items[index]['label']),
            );
          },
        ),
      ),
    );
  }
}

3. Development environment: Xcode vs. Flutter’s multi-platform tools

iOS developers use Xcode, which provides a suite of tools tailored for iOS and macOS development.

With Flutter, you have the flexibility to use different IDEs, including Android Studio, Visual Studio Code, and IntelliJ.

This cross-platform nature of Flutter also allows hot reload—a feature that lets you see changes in real time without restarting the app on both iOS and Android emulators.

Unlike Xcode Previews, which provide a live UI preview specifically for SwiftUI views within Xcode, Flutter’s hot reload and hot restart work across the entire app UI on both platforms. 

The multi-platform tooling, combined with extensive plugins for Flutter, can simplify development across different environments.

Keep in mind that some iOS-specific tools like Interface Builder and Xcode Previews aren’t available in Flutter, but hot reload offers a versatile, real-time development experience across iOS and Android.

4. State management & architecture: MVC/MVVM vs. Flutter-specific options

In iOS development, the Model-View-Controller (MVC) pattern is standard, often extended to Model-View-ViewModel (MVVM) for better separation of concerns.

For reactive programming, iOS developers use Combine or RxSwift, which streamline data binding between UI and logic, particularly useful in MVVM setups.

In SwiftUI, state management is built-in through @State, @ObservedObject and other property wrappers as well as a new Observation framework. For more complex scenarios, Combine can integrate seamlessly to handle data streams.

Flutter offers diverse state management tools tailored to various app complexities:

  • Stateful Widgets manage local state directly in the UI.
  • Provider offers a simple way to manage and share state across the app, well-suited for Flutter’s equivalent of MVVM.
  • Bloc brings a structured, reactive approach, managing state transitions with streams and events. It’s similar to RxSwift in iOS.

Other options like Riverpod add flexibility, each with unique features suited to different architectural styles.

While iOS favors MVC/MVVM with Combine or RxSwift, Flutter provides tailored tools for anything from simple to highly reactive architectures.

How to start learning Flutter as an iOS developer

Now that we’ve covered the differences let’s talk about how you can learn to develop in Flutter.

Step 1: Learn the basics of Dart and Flutter’s widget system

As an iOS developer, you’re already familiar with object-oriented programming, which will help with Dart. Key points include:

  1. Master Dart basics:

    If you’re transitioning from Swift, Dart’s syntax will feel familiar. Explore data types, control flow, and functions.

    We have some examples above but here’s the Dart Language Tour. It’s a quick way to get up to speed.

  2. Understand Flutter’s widget-based UI:

    Everything in Flutter is a widget. Familiarize yourself with foundational widgets like Container, Column, and Row, which you’ll use to build complex UIs.

    Flutter is declarative like SwiftUI, so focus on understanding state management with StatelessWidget and StatefulWidget.

Step 2: Build a simple Flutter app and explore project structure

Hands-on practice is essential:

  1. Create a new project: Run Flutter, create my_app to scaffold a new project.

  2. Examine project structure:
  • main.dart: The entry point for your Flutter app.
  • lib/: Where most of your app code will reside.
  • pubspec.yaml: Manage dependencies and assets here.
  1. Run and modify: Experiment with Flutter’s Hot Reload by making changes in main.dart and seeing instant updates on your emulator.

Step 3: Explore and evaluate Flutter packages on pub.dev

Flutter has a vibrant ecosystem of packages hosted on pub.dev. Knowing how to find and evaluate packages is crucial:

  1. Identify useful packages: Start by searching for common packages:
  • http: For HTTP requests.
  • bloc: For state management.
  • shared_preferences: For local storage.
  • path_provider: For filesystem paths.
  • url_launcher: To open URLs in the browser or app.
  1. Evaluate package reputation:
  • Look at the popularity score: High popularity indicates a widely used package.
  • Check for likes: Likes reflect the community’s approval and satisfaction with the package.
  • Analyze pub points: Pub points reflect quality, following best practices like null safety and documentation.
  • Review update frequency: Regular updates indicate active maintenance, which is critical for future-proofing your app.
  • Read documentation and example code: Good documentation with usage examples is essential for integration ease.
  1. Inspect open issues: Check the package’s GitHub repository (usually linked on pub.dev) to see if there are unresolved issues or ongoing discussions. A responsive maintainer is a positive sign.

Step 4: Build state management skills with Flutter

Unlike iOS’s MVC/MVVM, Flutter offers already mentioned various state management tools.

As you begin, consider the following:

  1. Start with stateful widgets: Manage local, ephemeral state directly within the widget. This is similar to how @State works in SwiftUI, where you manage simple, temporary states like user input fields.

  2. Experiment with bloc for reactive state: For more complex, scalable applications, Bloc provides a structured approach to managing state, similar to how ViewModels work in iOS MVVM.

    Bloc separates logic from the UI, handling state transitions with streams and events. This parallels the role of RxSwift when creating reactive, testable architectures in iOS.

    Conversely, using StatefulWidget in Flutter is like managing state directly within a SwiftUI view, where the state logic is embedded in the UI component itself.

Step 5: Dive into Flutter’s advanced features

Beyond state management, Flutter offers unique tools that expand your app’s capabilities:

  1. Navigation and routing: Flutter uses Navigator and Routes to handle navigation.

    Familiarize yourself with Navigator.push and Navigator.pop. For complex navigation, look up something like a go-router package to streamline your navigation.

  2. Explore plugins for native features: Access device-specific features like camera, GPS, and storage with plugins on pub.dev.

    For example, image_picker helps access the device camera or gallery, and geolocator provides location data.

  3. Flutter DevTools: Flutter offers a suite of debugging tools (DevTools) that provide insights into performance, memory usage, and widget structure. They’re essential for optimizing app performance and catching errors.

Step 6: Use resources tailored for iOS Developers

Take advantage of resources specifically designed for iOS developers making the switch to Flutter:

  1. Official documentation: Flutter’s iOS devs page provides a direct comparison between iOS and Flutter concepts.

  2. Codelabs and tutorials: Google’s Flutter Codelabs are hands-on and cover various topics, from building a UI to handling data.

  3. Community and courses: Join communities like Flutter Dev on Reddit and the Flutter Slack channel. Platforms like Udemy, Udacity, and Kodeco (formerly Ray Wenderlich) offer Flutter courses ranging from beginner to advanced.

  4. YouTube channels: The official Flutter YouTube channel offers tutorials and updates. Channels like Reso Coder also provide practical Flutter tutorials.

Step 7: Start with small projects and experiment

As you gain confidence, apply what you’ve learned in small projects.

Build a to-do list app, a weather app, or a simple e-commerce interface.

Try to use different packages, experiment with state management, and don’t hesitate to dive into complex topics like animations or custom widgets as you go.

Common pitfalls when switching from iOS to Flutter

1. Avoid returning widgets directly from functions

Returning widgets directly from functions within the build method can lead to cluttered and hard-to-read code.

As the widget tree grows, this approach becomes increasingly difficult to maintain and test.

Example:

@override
Widget build(BuildContext context) {
  return Column(
    children: [
      buildHeader(), // Returning widgets directly from functions
      buildContent(),
    ],
  );
}

Widget buildHeader() {
  return Text('Header');
}

Solution: Move reusable UI components into separate widget classes. This makes your code modular and more maintainable.
@override
Widget build(BuildContext context) {
  return Column(
    children: [
      HeaderWidget(),
      ContentWidget(),
    ],
  );
}

class HeaderWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('Header');
  }
}

2. Avoid performing expensive operations in build method

Placing heavy computations or expensive operations in the build method can lead to performance bottlenecks, as the build method is called frequently during app lifecycle changes.

Example:

@override
Widget build(BuildContext context) {
  final result = heavyComputation(); // Avoid running heavy computations here
  return Text('Result: $result');
}

Solution: Move heavy computations outside the build method. Use initState for one-time setup, or consider using FutureBuilder or StreamBuilder for asynchronous operations.
@override
void initState() {
  super.initState();
  _result = heavyComputation();
}

3. Avoid using BuildContext after dispose

Attempting to use BuildContext after the widget is disposed leads to runtime errors, especially when working with async operations. This is a common mistake when triggering navigation or accessing widget properties after dispose.

Example:

@override
void dispose() {
  super.dispose();
  Future.delayed(Duration(seconds: 1), () {
    Navigator.pushNamed(context, '/next'); // Incorrect usage
  });
}

Solution: Use the mounted property to check if the widget is still active before accessing BuildContext.
@override
void dispose() {
  super.dispose();
  Future.delayed(Duration(seconds: 1), () {
    if (mounted) {
      Navigator.pushNamed(context, '/next');
    }
  });
}

These pitfalls cover common mistakes in Flutter development.

By following the solutions mentioned above, you’ll improve both the performance and maintainability of your Flutter apps.

Conclusion

Switching to Flutter as an iOS developer opens up new opportunities to build cross-platform apps with ease. 

Sure, there’s a bit of a learning curve but many concepts will already feel like second nature. 

By staying mindful of good state management and keeping your code tidy, you’ll be set to build high-quality, scalable apps that run smoothly across platforms.

If you need to go even further with your Flutter development and include some native code directly, read our Flutter platform channels guide.

Categories
Written by

Viktor Mauzer

Software Engineer

Related articles