Calling Native Code from Flutter Is Easier Than You Think

Sooner or later you will build a Flutter app that needs to do something the core framework can’t. You might need to access a specific device sensor use a platform specific API or integrate a third party native SDK. When this happens developers often worry. They think they've hit a wall or that they need to start a complex and fragile integration process.

This is usually wrong. The need to talk to the native platform is not a sign of failure. It’s a sign that your application is doing something useful. And Flutter was designed for this. There is a clean and simple way to send messages between your Dart code and the native Swift or Kotlin code that runs underneath.

The Bridge is a MethodChannel

The core tool for this communication is the MethodChannel. The name is descriptive. It is a channel for invoking methods. You give the channel a unique name a string that both your Dart code and native code agree on. Think of it like a private radio frequency.

Once the channel is established your Dart code can send a message with a method name and some arguments. Your native code listens on that same channel. When it hears a method name it recognizes it runs some native code and can optionally send a result back. The whole process is asynchronous so it won’t block your UI.

It’s a simple idea. You are not compiling Dart to native or doing complex foreign function interface calls. You are just sending messages over a named pipe.

A Practical Example

Let’s build something simple but real. A common use case is getting information about the device itself like the battery level. Flutter doesn’t have a core API for this because the implementation is different on every platform.

First we define the channel and the method call in our Dart code. This might be inside a StatefulWidget or wherever you manage your app’s logic.

import 'package:flutter/services.dart';

// A unique name for our channel.
const platform = MethodChannel('samples.flutter.dev/battery');

Future<void> _getBatteryLevel() async {
  String batteryLevel;
  try {
    // Invoke the method on the channel. 
    // The string 'getBatteryLevel' must match the native side.
    final int result = await platform.invokeMethod('getBatteryLevel');
    batteryLevel = 'Battery level at $result % .';
  } on PlatformException catch (e) {
    // Handle the case where the platform call fails.
    batteryLevel = “Failed to get battery level: '${e.message}'.”;
  }

  // Update your state with the result here.
}

The Dart code is straightforward. It sends a message on the channel and awaits a response. The interesting work happens on the native side where we implement the other end of this channel.

The Android Side in Kotlin

On Android you’ll typically add the native code to your main Activity. This is usually located at android/app/src/main/kotlin/.../MainActivity.kt.

You configure the channel and then handle the incoming method calls.

package com.example.yourapp

import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES

class MainActivity: FlutterActivity() {
  // The same unique channel name from the Dart code.
  private val CHANNEL = “samples.flutter.dev/battery”

  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      // Check if the incoming method call is the one we want to handle.
      if (call.method == “getBatteryLevel”) {
        val batteryLevel = getBatteryLevel()

        if (batteryLevel != -1) {
          result.success(batteryLevel)
        } else {
          result.error(“UNAVAILABLE”, “Battery level not available.”, null)
        }
      } else {
        result.notImplemented()
      }
    }
  }

  private fun getBatteryLevel(): Int {
    val batteryLevel: Int
    if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
      val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
      batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    } else {
      val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
      batteryLevel = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    }

    return batteryLevel
  }
}

The Kotlin code sets up a handler. It checks the method string from Dart. If it matches getBatteryLevel it runs the native Android code to get the battery level and sends the integer back using result.success().

The iOS Side in Swift

On iOS the logic is similar. You’ll add your code to ios/Runner/AppDelegate.swift.

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    // The same unique channel name.
    let batteryChannel = FlutterMethodChannel(name: “samples.flutter.dev/battery”,
                                              binaryMessenger: controller.binaryMessenger)

    batteryChannel.setMethodCallHandler({ 
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      // Check the method name.
      guard call.method == “getBatteryLevel” else {
        result(FlutterMethodNotImplemented)
        return
      }
      self.receiveBatteryLevel(result: result)
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func receiveBatteryLevel(result: FlutterResult) {
    let device = UIDevice.current
    device.isBatteryMonitoringEnabled = true
    if device.batteryState == UIDevice.BatteryState.unknown {
      result(FlutterError(code: “UNAVAILABLE”,
                        message: “Battery level not available.”,
                        details: nil))
    } else {
      result(Int(device.batteryLevel * 100))
    }
  }
}

The pattern is the same. You create a FlutterMethodChannel with the same name. You set a handler. You check the method call string. You run your platform specific Swift code and return the value.

What This Teaches Us

The important thing isn’t the specifics of the battery API. It’s the pattern. You can use this for anything. You can pass arguments from Dart to native and get complex data structures like Maps and Lists back.

The key is to keep the surface area small. Define a few clear methods. Don’t try to build a complex API over the channel. It’s for simple requests and responses.

This is not the tool for streaming large amounts of data quickly. For that you would look at something like EventChannel. But for the vast majority of cases where you need to ask the platform a question or tell it to perform an action MethodChannel is the right tool. It is simple robust and built in.

Don’t be afraid of the native boundary. Crossing it is a normal part of building powerful applications.

— Rishi Banerjee
September 2025