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.