← Articles

Adding native iOS code to a Flutter app

By Ann Tech · 16 April 2025

Flutter apps often need to call native iOS APIs that have no Flutter equivalent: HealthKit, CoreBluetooth, ARKit, custom hardware SDKs, or existing Objective-C/Swift libraries your team already has. Platform channels let you bridge Dart to native Swift or Objective-C without writing a full plugin.

When to use platform channels vs a plugin

Write a plugin when the functionality is reusable and you want to publish it. Use a direct platform channel (inside your app, not a separate package) when it's app-specific one-off integration.

Setting up the channel in Dart

// lib/platform/ios_health_service.dart
import 'package:flutter/services.dart';

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

  Future<double?> getStepCount(DateTime date) async {
    try {
      final result = await _channel.invokeMethod<double>('getStepCount', {
        'date': date.toIso8601String(),
      });
      return result;
    } on PlatformException catch (e) {
      if (e.code == 'HEALTH_NOT_AUTHORIZED') return null;
      rethrow;
    }
  }

  Future<bool> requestHealthPermissions() async {
    return await _channel.invokeMethod<bool>('requestPermissions') ?? false;
  }
}

iOS AppDelegate

Register the channel handler in AppDelegate.swift:

import UIKit
import Flutter
import HealthKit

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        let controller = window?.rootViewController as! FlutterViewController
        let healthChannel = FlutterMethodChannel(
            name: "com.example.myapp/health",
            binaryMessenger: controller.binaryMessenger
        )

        healthChannel.setMethodCallHandler { [weak self] call, result in
            switch call.method {
            case "requestPermissions":
                self?.requestHealthPermissions(result: result)
            case "getStepCount":
                let args = call.arguments as! [String: Any]
                let dateStr = args["date"] as! String
                self?.getStepCount(dateString: dateStr, result: result)
            default:
                result(FlutterMethodNotImplemented)
            }
        }

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

    private func requestHealthPermissions(result: @escaping FlutterResult) {
        guard HKHealthStore.isHealthDataAvailable() else {
            result(false)
            return
        }
        let store = HKHealthStore()
        let types: Set<HKObjectType> = [
            HKObjectType.quantityType(forIdentifier: .stepCount)!
        ]
        store.requestAuthorization(toShare: nil, read: types) { success, _ in
            DispatchQueue.main.async {
                result(success)
            }
        }
    }

    private func getStepCount(dateString: String, result: @escaping FlutterResult) {
        let formatter = ISO8601DateFormatter()
        guard let date = formatter.date(from: dateString) else {
            result(FlutterError(code: "INVALID_DATE", message: "Invalid date format", details: nil))
            return
        }

        let store = HKHealthStore()
        let type = HKQuantityType.quantityType(forIdentifier: .stepCount)!
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: date)
        let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
        let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: endOfDay)

        let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, statistics, _ in
            let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
            DispatchQueue.main.async {
                result(steps)
            }
        }
        store.execute(query)
    }
}

Event channels for continuous data

For location updates, sensor streams, or Bluetooth events:

// In AppDelegate
var locationSink: FlutterEventSink?

let locationChannel = FlutterEventChannel(
    name: "com.example.myapp/location",
    binaryMessenger: controller.binaryMessenger
)
locationChannel.setStreamHandler(self)

// FlutterStreamHandler
extension AppDelegate: FlutterStreamHandler {
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        locationSink = events
        startLocationUpdates()
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        locationSink = nil
        stopLocationUpdates()
        return nil
    }
}

// When location updates:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.last else { return }
    locationSink?([
        "latitude": location.coordinate.latitude,
        "longitude": location.coordinate.longitude,
        "accuracy": location.horizontalAccuracy,
    ])
}
// Dart side
static const _locationChannel = EventChannel('com.example.myapp/location');

Stream<LocationData> get locationStream {
  return _locationChannel
      .receiveBroadcastStream()
      .map((event) => LocationData.fromMap(event as Map<Object?, Object?>));
}

Required entitlements

For HealthKit, add to ios/Runner/Runner.entitlements:

<key>com.apple.developer.healthkit</key>
<true/>

And in Info.plist:

<key>NSHealthShareUsageDescription</key>
<string>We use HealthKit to show your step count.</string>

Calling existing Objective-C code

If you have existing Obj-C code, you can call it from Swift via a bridging header:

ios/Runner/Runner-Bridging-Header.h
// Runner-Bridging-Header.h
#import "LegacySDK.h"

Then use LegacySDK from Swift directly.

Threading

All Flutter method channel callbacks are called on the main thread. If your native code does async work (network, file I/O, sensor queries), always dispatch back to the main thread before calling result():

DispatchQueue.main.async {
    result(value)
}

Never call result() from a background thread — it will crash.

Common pitfalls

Channel name mismatch. The string in Dart and Swift must be identical. A typo silently results in the method never being called — result(FlutterMethodNotImplemented) is returned instead.

Calling result() more than once. Each method call must call result exactly once. Calling it twice crashes the app with a FlutterError.

Missing Info.plist entries. iOS requires usage descriptions for camera, location, health, contacts, and many other APIs. Missing them causes silent permission denial or App Store rejection.

Sign in to like, dislike, or report.

Adding native iOS code to a Flutter app — ANN Tech