Adding native iOS code to a Flutter app
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.