Platform Channels & Native Integration

MethodChannel: iOS Implementation in Swift

16 min Lesson 4 of 11

MethodChannel: iOS Implementation in Swift

Platform channels are Flutter's mechanism for calling native platform APIs that are not yet exposed through Flutter's own plugins or packages. On iOS, you implement the native side of a MethodChannel in Swift — either inside AppDelegate for simple use-cases, or inside a standalone FlutterPlugin class for reusable, well-structured integration. This lesson covers both approaches in detail.

How MethodChannel Works on iOS

When Dart invokes a method on a MethodChannel, Flutter serialises the method name and arguments using the standard message codec and delivers them to the iOS host. The iOS side receives an FlutterMethodCall object containing the method name and an arguments property. You must call the provided FlutterResult callback exactly once — passing back either a result value, nil, or a FlutterError.

Note: The FlutterResult callback is a Swift closure with the signature (Any?) -> Void. You must call it exactly once per method call. Calling it zero times leaves Dart awaiting forever; calling it more than once throws an assertion error in debug builds.

Approach 1 — Registering the Channel in AppDelegate

For small projects or one-off integrations, you can register your FlutterMethodChannel directly inside AppDelegate.swift. The key steps are:

  • Cast the root view controller to FlutterViewController
  • Instantiate a FlutterMethodChannel with a name that matches the Dart side exactly
  • Set a method-call handler closure that switches on call.method
  • For unknown methods, call result(FlutterMethodNotImplemented)

AppDelegate.swift — Registering a MethodChannel

// ios/Runner/AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // 1. Obtain the root FlutterViewController
        let controller = window?.rootViewController as! FlutterViewController

        // 2. Create the channel — name must match Dart exactly
        let batteryChannel = FlutterMethodChannel(
            name: "com.example.myapp/battery",
            binaryMessenger: controller.binaryMessenger
        )

        // 3. Register the method-call handler
        batteryChannel.setMethodCallHandler { [weak self] call, result in
            guard let self = self else { return }

            switch call.method {
            case "getBatteryLevel":
                let level = self.getBatteryLevel()
                if level >= 0 {
                    result(level)          // success: send Int back to Dart
                } else {
                    result(FlutterError(
                        code: "UNAVAILABLE",
                        message: "Battery level not available",
                        details: nil
                    ))
                }

            default:
                result(FlutterMethodNotImplemented)
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func getBatteryLevel() -> Int {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let level = UIDevice.current.batteryLevel
        return level < 0 ? -1 : Int(level * 100)
    }
}

Approach 2 — Implementing a FlutterPlugin

For reusable or more complex native integrations, the recommended pattern is to create a dedicated class that conforms to FlutterPlugin. This keeps AppDelegate lean and makes the plugin independently testable and distributable.

BatteryPlugin.swift — Standalone FlutterPlugin

// ios/Runner/BatteryPlugin.swift
import Flutter
import UIKit

public class BatteryPlugin: NSObject, FlutterPlugin {

    // 1. Register is called once at app startup
    public static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(
            name: "com.example.myapp/battery",
            binaryMessenger: registrar.messenger()
        )
        let instance = BatteryPlugin()
        registrar.addMethodCallDelegate(instance, channel: channel)
    }

    // 2. Handle every inbound method call
    public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "getBatteryLevel":
            let level = fetchBatteryLevel()
            if let level = level {
                result(level)
            } else {
                result(FlutterError(
                    code: "UNAVAILABLE",
                    message: "Could not read battery level",
                    details: nil
                ))
            }

        case "isCharging":
            UIDevice.current.isBatteryMonitoringEnabled = true
            let charging = UIDevice.current.batteryState == .charging
                        || UIDevice.current.batteryState == .full
            result(charging)   // sends Bool to Dart

        default:
            result(FlutterMethodNotImplemented)
        }
    }

    private func fetchBatteryLevel() -> Int? {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let raw = UIDevice.current.batteryLevel
        guard raw >= 0 else { return nil }
        return Int(raw * 100)
    }
}

// In AppDelegate.swift, register the plugin in GeneratedPluginRegistrant
// or call: BatteryPlugin.register(with: registrar)

Sending Results and Errors Back to Dart

The FlutterResult closure accepts three categories of value:

  • Success value — pass any type supported by the standard codec: Int, Double, Bool, String, Data, [Any], [String: Any], or nil.
  • FlutterError — pass a FlutterError(code:message:details:) instance; Dart receives a PlatformException.
  • FlutterMethodNotImplemented — a special sentinel that signals the method is not handled on the native side; Dart throws MissingPluginException.
Tip: Always make the result closure @escaping when you need to call it asynchronously (for example, after a network request or permission dialog). Mark it @escaping FlutterResult in the method signature and store it until you have a value to return.

Parsing Incoming Arguments

Arguments passed from Dart are available as call.arguments typed as Any?. Cast them safely before use:

Safely Casting Arguments from Dart

public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "setVolume":
        // Dart sends: {'level': 0.75}
        guard let args = call.arguments as? [String: Any],
              let level = args["level"] as? Double else {
            result(FlutterError(
                code: "INVALID_ARGS",
                message: "Expected {level: Double}",
                details: nil
            ))
            return
        }
        AudioSession.setOutputVolume(Float(level))
        result(nil)   // success with no return value

    case "getDeviceModel":
        // No arguments expected
        let model = UIDevice.current.model  // e.g. "iPhone"
        result(model)

    default:
        result(FlutterMethodNotImplemented)
    }
}
Warning: Never access UIKit APIs from a background thread. If your method-call handler is dispatched off the main thread (rare but possible), wrap any UIKit calls in DispatchQueue.main.async { ... }. The FlutterResult callback itself is safe to call from any thread.

Registering the Plugin in AppDelegate

When you use the standalone FlutterPlugin approach, you must register it during app startup. The canonical place is AppDelegate.swift, alongside or instead of the auto-generated plugin registrant:

  • If using GeneratedPluginRegistrant, call BatteryPlugin.register(with: registrar) inside application(_:didFinishLaunchingWithOptions:) after GeneratedPluginRegistrant.register(with: self).
  • Alternatively, implement registerPlugins in the Flutter engine's plugin registry.

Dart Side Recap

For completeness, here is the matching Dart code that invokes the channel registered above:

Dart — Calling the iOS MethodChannel

import 'package:flutter/services.dart';

class BatteryService {
  static const _channel = MethodChannel('com.example.myapp/battery');

  Future<int> getBatteryLevel() async {
    try {
      final int level = await _channel.invokeMethod('getBatteryLevel');
      return level;
    } on PlatformException catch (e) {
      throw Exception('Failed to get battery level: ${e.message}');
    }
  }

  Future<bool> isCharging() async {
    final bool charging = await _channel.invokeMethod('isCharging');
    return charging;
  }
}
Key Takeaway: To implement a MethodChannel on iOS you need two things — a FlutterMethodChannel instance (created with a matching name and the engine's binaryMessenger) and a method-call handler that switches on call.method, casts call.arguments safely, and calls the FlutterResult callback exactly once per invocation. Use AppDelegate for simple cases and a dedicated FlutterPlugin class for reusable, production-grade integrations.